nimopt 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nimopt/__init__.py +55 -0
- nimopt/absence.py +191 -0
- nimopt/coefficient.py +345 -0
- nimopt/constraint.py +202 -0
- nimopt/definition.py +325 -0
- nimopt/explanation.py +174 -0
- nimopt/files.py +470 -0
- nimopt/model.py +529 -0
- nimopt/models/__init__.py +26 -0
- nimopt/models/_arithmetic.py +35 -0
- nimopt/models/commitment.py +95 -0
- nimopt/models/dispatch.py +60 -0
- nimopt/models/expansion.py +153 -0
- nimopt/models/fleet.py +65 -0
- nimopt/models/nodal.py +86 -0
- nimopt/models/profiled.py +78 -0
- nimopt/models/recourse.py +156 -0
- nimopt/models/sector.py +103 -0
- nimopt/models/storage.py +114 -0
- nimopt/models/transport.py +114 -0
- nimopt/names.py +8 -0
- nimopt/param.py +145 -0
- nimopt/progress.py +85 -0
- nimopt/py.typed +0 -0
- nimopt/row.py +191 -0
- nimopt/session.py +216 -0
- nimopt/sets.py +301 -0
- nimopt/solution.py +111 -0
- nimopt/solvers/__init__.py +75 -0
- nimopt/solvers/base.py +111 -0
- nimopt/solvers/gurobi.py +230 -0
- nimopt/solvers/highs.py +261 -0
- nimopt/solvers/mosek.py +292 -0
- nimopt/solvers/options.py +135 -0
- nimopt/spelling.py +264 -0
- nimopt/symbol.py +109 -0
- nimopt/term.py +553 -0
- nimopt/variable.py +247 -0
- nimopt-0.1.0.dist-info/METADATA +280 -0
- nimopt-0.1.0.dist-info/RECORD +42 -0
- nimopt-0.1.0.dist-info/WHEEL +4 -0
- nimopt-0.1.0.dist-info/licenses/LICENSE +21 -0
nimopt/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""An LP/MILP builder whose constraint blocks are labeled sparse arrays."""
|
|
2
|
+
|
|
3
|
+
from nimopt.absence import Absence
|
|
4
|
+
from nimopt.coefficient import Coefficient
|
|
5
|
+
from nimopt.constraint import Constraint
|
|
6
|
+
from nimopt.definition import Definition
|
|
7
|
+
from nimopt.explanation import Explanation
|
|
8
|
+
from nimopt.files import load, loads, save
|
|
9
|
+
from nimopt.model import Assembled, Model
|
|
10
|
+
from nimopt.names import COLUMN, ROW
|
|
11
|
+
from nimopt.param import Param
|
|
12
|
+
from nimopt.row import Row
|
|
13
|
+
from nimopt.session import Diagnosis, Session
|
|
14
|
+
from nimopt.sets import Alias, Set, product, subset, subset_of
|
|
15
|
+
from nimopt.solution import Solution
|
|
16
|
+
from nimopt.solvers import Option, available, capabilities, options
|
|
17
|
+
from nimopt.term import Expression, Relation, Sum, Term
|
|
18
|
+
from nimopt.variable import Variable
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"COLUMN",
|
|
24
|
+
"ROW",
|
|
25
|
+
"Absence",
|
|
26
|
+
"Alias",
|
|
27
|
+
"Assembled",
|
|
28
|
+
"Coefficient",
|
|
29
|
+
"Constraint",
|
|
30
|
+
"Definition",
|
|
31
|
+
"Diagnosis",
|
|
32
|
+
"Explanation",
|
|
33
|
+
"Expression",
|
|
34
|
+
"Model",
|
|
35
|
+
"Option",
|
|
36
|
+
"Param",
|
|
37
|
+
"Relation",
|
|
38
|
+
"Row",
|
|
39
|
+
"Session",
|
|
40
|
+
"Set",
|
|
41
|
+
"Solution",
|
|
42
|
+
"Sum",
|
|
43
|
+
"Term",
|
|
44
|
+
"Variable",
|
|
45
|
+
"__version__",
|
|
46
|
+
"available",
|
|
47
|
+
"capabilities",
|
|
48
|
+
"load",
|
|
49
|
+
"loads",
|
|
50
|
+
"options",
|
|
51
|
+
"product",
|
|
52
|
+
"save",
|
|
53
|
+
"subset",
|
|
54
|
+
"subset_of",
|
|
55
|
+
]
|
nimopt/absence.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Which coordinates fell out of a constraint, and by which rule."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from nimblend import Domain, SparseArray
|
|
8
|
+
|
|
9
|
+
from nimopt.names import COLUMN
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class DroppedRow:
|
|
14
|
+
"""A row a constraint did not state, and what removed it."""
|
|
15
|
+
|
|
16
|
+
coordinate: dict[str, Any]
|
|
17
|
+
rule: str
|
|
18
|
+
detail: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class DroppedTerm:
|
|
23
|
+
"""A term missing from a row that stands, and what removed it."""
|
|
24
|
+
|
|
25
|
+
coordinate: dict[str, Any]
|
|
26
|
+
variable: str
|
|
27
|
+
rule: str
|
|
28
|
+
detail: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Absence:
|
|
33
|
+
"""What a constraint set out to state, what it states, and what fell.
|
|
34
|
+
|
|
35
|
+
`stated_by` is `"terms"` where the rows are derived from what the terms
|
|
36
|
+
reach and `"over"` where they are stated outright. Under `"over"` nothing
|
|
37
|
+
is dropped and an empty `dropped_rows` is structural rather than a
|
|
38
|
+
constraint that happened to lose nothing.
|
|
39
|
+
|
|
40
|
+
A coefficient absent inside a sum removes a term and leaves the row
|
|
41
|
+
standing; a term absent along a free dimension removes the row, because a
|
|
42
|
+
row missing one of its terms states something that was not written.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
constraint: str
|
|
46
|
+
stated_by: str
|
|
47
|
+
expected: int
|
|
48
|
+
standing: int
|
|
49
|
+
dropped_rows: tuple[DroppedRow, ...]
|
|
50
|
+
dropped_terms: tuple[DroppedTerm, ...]
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
lines = [
|
|
54
|
+
f"{self.constraint} {self.standing} of {self.expected} rows "
|
|
55
|
+
f"stated by {self.stated_by}"
|
|
56
|
+
]
|
|
57
|
+
for dropped in self.dropped_rows:
|
|
58
|
+
lines.append(
|
|
59
|
+
f" row absent {_at(dropped.coordinate)} "
|
|
60
|
+
f"{dropped.rule} ({dropped.detail})"
|
|
61
|
+
)
|
|
62
|
+
for dropped in self.dropped_terms:
|
|
63
|
+
lines.append(
|
|
64
|
+
f" term absent {_at(dropped.coordinate)} {dropped.variable} "
|
|
65
|
+
f"{dropped.rule} ({dropped.detail})"
|
|
66
|
+
)
|
|
67
|
+
return "\n".join(lines)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _at(coordinate: Mapping[str, Any]) -> str:
|
|
71
|
+
return ", ".join(f"{d}={v!r}" for d, v in coordinate.items())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _coordinates(domain: Domain) -> list[dict[str, Any]]:
|
|
75
|
+
"""One dict of dimension to label per member of a domain.
|
|
76
|
+
|
|
77
|
+
A label is handed over as the Python value it stands for, so a caller
|
|
78
|
+
comparing one against a literal, and a rendering of it, both read plainly.
|
|
79
|
+
"""
|
|
80
|
+
labels = domain.labels()
|
|
81
|
+
return [{d: labels[d][k].item() for d in domain.dims} for k in range(domain.size)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _absent_terms(
|
|
85
|
+
rows: Domain, term: Any, before: SparseArray, after: SparseArray
|
|
86
|
+
) -> list[DroppedTerm]:
|
|
87
|
+
"""Terms a coefficient removed from rows that still stand.
|
|
88
|
+
|
|
89
|
+
A term is identified by the coordinate it carries once its coefficient has
|
|
90
|
+
placed it. Every such coordinate is the entries the variable brought,
|
|
91
|
+
crossed with each member of a dimension the coefficient introduced; the
|
|
92
|
+
ones the coefficient does not carry are the terms that are gone.
|
|
93
|
+
"""
|
|
94
|
+
if not term.summed:
|
|
95
|
+
return []
|
|
96
|
+
keep = tuple(d for d in after.dims if d != COLUMN)
|
|
97
|
+
carried = after.domain(keep)
|
|
98
|
+
brought = tuple(d for d in before.dims if d != COLUMN)
|
|
99
|
+
entries = before.domain(brought)
|
|
100
|
+
introduced = tuple(d for d in keep if d not in brought)
|
|
101
|
+
whole = entries.expand(introduced, carried.coords).transpose(*keep)
|
|
102
|
+
lost = whole.difference(carried)
|
|
103
|
+
at = lost.coordinates()[[keep.index(d) for d in rows.dims]]
|
|
104
|
+
standing = rows.positions_of_coordinates(at) >= 0
|
|
105
|
+
variable = term.variable.name
|
|
106
|
+
detail = term.coefficient.name
|
|
107
|
+
return [
|
|
108
|
+
DroppedTerm(coordinate, variable, "absent-coefficient", detail)
|
|
109
|
+
for coordinate, keeping in zip(_coordinates(lost), standing)
|
|
110
|
+
if keeping
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Recorder:
|
|
115
|
+
"""What a constraint's shape pass dropped, gathered as it narrows.
|
|
116
|
+
|
|
117
|
+
A dropped row leaves no trace in the matrix, so it is attributed while the
|
|
118
|
+
narrowing runs rather than read back from it. Each narrowing sees only what
|
|
119
|
+
survived the one before, so a coordinate is attributed to one rule.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self.stated_by = "terms"
|
|
124
|
+
self.expected: int | None = None
|
|
125
|
+
self.rows = []
|
|
126
|
+
self.terms = []
|
|
127
|
+
self._reached = []
|
|
128
|
+
self._coefficients = []
|
|
129
|
+
self._standing: Domain | None = None
|
|
130
|
+
|
|
131
|
+
def term_rows(self, term: Any, domain: Domain) -> None:
|
|
132
|
+
"""The rows one term of the expression reaches."""
|
|
133
|
+
self._reached.append((term, domain))
|
|
134
|
+
|
|
135
|
+
def reached(self, frame: Sequence[str], rows: Domain) -> None:
|
|
136
|
+
"""The rows the terms reach, against the product the frame spans.
|
|
137
|
+
|
|
138
|
+
A coordinate no term reaches is attributed to the first term that
|
|
139
|
+
misses it, so each is named once and by the term that lost it.
|
|
140
|
+
"""
|
|
141
|
+
whole = Domain.full(frame, rows.coords)
|
|
142
|
+
self.expected = whole.size
|
|
143
|
+
standing = whole
|
|
144
|
+
for term, domain in self._reached:
|
|
145
|
+
reduced = standing.intersect(domain)
|
|
146
|
+
self._record_rows(
|
|
147
|
+
standing, reduced, "term-does-not-reach", term.variable.name
|
|
148
|
+
)
|
|
149
|
+
standing = reduced
|
|
150
|
+
|
|
151
|
+
def stated(self, rows: Domain) -> None:
|
|
152
|
+
"""The rows an `over=` states outright, which drops nothing."""
|
|
153
|
+
self.stated_by = "over"
|
|
154
|
+
self.expected = rows.size
|
|
155
|
+
self.rows = []
|
|
156
|
+
|
|
157
|
+
def dropped(self, before: Domain, after: Domain, rule: str, detail: str) -> None:
|
|
158
|
+
"""The rows one narrowing removed."""
|
|
159
|
+
self._record_rows(before, after, rule, detail)
|
|
160
|
+
|
|
161
|
+
def _record_rows(
|
|
162
|
+
self, before: Domain, after: Domain, rule: str, detail: str
|
|
163
|
+
) -> None:
|
|
164
|
+
lost = before.difference(after)
|
|
165
|
+
self.rows.extend(DroppedRow(at, rule, detail) for at in _coordinates(lost))
|
|
166
|
+
|
|
167
|
+
def coefficient(self, term: Any, before: SparseArray, after: SparseArray) -> None:
|
|
168
|
+
"""A term's entries either side of the coefficient that multiplied it."""
|
|
169
|
+
self._coefficients.append((term, before, after))
|
|
170
|
+
|
|
171
|
+
def settled(self, rows: Domain) -> None:
|
|
172
|
+
"""The rows the constraint states, once every narrowing has run."""
|
|
173
|
+
self._standing = rows
|
|
174
|
+
for held in self._coefficients:
|
|
175
|
+
self.terms.extend(_absent_terms(rows, *held))
|
|
176
|
+
|
|
177
|
+
def absence(self, name: str) -> Absence:
|
|
178
|
+
"""What this recorder gathered, as the answer a caller reads."""
|
|
179
|
+
if self.expected is None or self._standing is None:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"constraint {name!r} has not settled its rows; an absence is "
|
|
182
|
+
f"read once the shape pass has run"
|
|
183
|
+
)
|
|
184
|
+
return Absence(
|
|
185
|
+
constraint=name,
|
|
186
|
+
stated_by=self.stated_by,
|
|
187
|
+
expected=self.expected,
|
|
188
|
+
standing=self._standing.size,
|
|
189
|
+
dropped_rows=tuple(self.rows),
|
|
190
|
+
dropped_terms=tuple(self.terms),
|
|
191
|
+
)
|
nimopt/coefficient.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""A coefficient: a parameter read at its sets, or a combination of them."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from nimblend import combined_dims
|
|
7
|
+
|
|
8
|
+
from nimopt.sets import check_members, reference
|
|
9
|
+
from nimopt.symbol import Symbol, read_bare
|
|
10
|
+
|
|
11
|
+
NOT_A_COEFFICIENT = (
|
|
12
|
+
"a coefficient is a parameter; build one with `Param.from_dense` or "
|
|
13
|
+
"`Param.from_long` and read it at its sets. A product of two expressions "
|
|
14
|
+
"is not linear."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_BINARY = {
|
|
18
|
+
"+": lambda a, b: a + b,
|
|
19
|
+
"-": lambda a, b: a - b,
|
|
20
|
+
"*": lambda a, b: a * b,
|
|
21
|
+
"**": lambda a, b: a**b,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _rendered(operand: Any) -> str:
|
|
26
|
+
"""How an operand reads inside a combination's name."""
|
|
27
|
+
return operand.name if isinstance(operand, Coefficient) else str(operand)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _finite_divisor(divisor: Any, named: str) -> None:
|
|
31
|
+
"""Refuse a divisor that is zero, naming what is divided.
|
|
32
|
+
|
|
33
|
+
A numpy scalar divides to infinity where a Python number raises, so the
|
|
34
|
+
rule is stated rather than left to the arithmetic: a coefficient reaching
|
|
35
|
+
a solver is finite.
|
|
36
|
+
"""
|
|
37
|
+
if float(divisor) == 0.0:
|
|
38
|
+
raise ZeroDivisionError(
|
|
39
|
+
f"{named} is divided by zero; a divisor of zero states a "
|
|
40
|
+
f"coefficient no solver can read, so it is prepared before it "
|
|
41
|
+
f"reaches an expression"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _finite_quotient(array: Any, name: str) -> None:
|
|
46
|
+
"""Refuse a divisor carrying a zero, naming the first coordinate it sits at."""
|
|
47
|
+
at = np.flatnonzero(array.values() == 0.0)
|
|
48
|
+
if at.size:
|
|
49
|
+
where = {
|
|
50
|
+
d: labels[at[0]].item() for d, labels in array.domain().labels().items()
|
|
51
|
+
}
|
|
52
|
+
raise ZeroDivisionError(
|
|
53
|
+
f"divisor {name} carries a zero at {at.size} coordinate(s), the "
|
|
54
|
+
f"first at {where}; a quotient there states a coefficient no "
|
|
55
|
+
f"solver can read"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Coefficient:
|
|
60
|
+
"""What a term reads as its coefficient.
|
|
61
|
+
|
|
62
|
+
A `name` to report, the `dims` it carries, the array it `materialise()`s
|
|
63
|
+
to, and a reading at its sets. A parameter's reference and a combination
|
|
64
|
+
of coefficients both answer that surface, so a verb reporting a
|
|
65
|
+
coefficient reads one thing whichever it is handed.
|
|
66
|
+
|
|
67
|
+
The arithmetic is here because both answer it alike: a coefficient
|
|
68
|
+
meeting an expression multiplies its terms, one meeting another
|
|
69
|
+
coefficient or a number states a combination that computes where the
|
|
70
|
+
matrix is built, and everything else is declined so the operand on the
|
|
71
|
+
right is offered its turn.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__array_ufunc__ = None
|
|
75
|
+
__hash__ = None
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def name(self) -> str:
|
|
79
|
+
"""What this coefficient is called where it is reported."""
|
|
80
|
+
raise NotImplementedError
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def dims(self) -> tuple[str, ...]:
|
|
84
|
+
"""The dimensions this coefficient carries."""
|
|
85
|
+
raise NotImplementedError
|
|
86
|
+
|
|
87
|
+
def materialise(self) -> Any:
|
|
88
|
+
"""The array this coefficient computes to."""
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
|
|
91
|
+
def held(self) -> Any:
|
|
92
|
+
"""The array this coefficient already holds, or None where it holds none.
|
|
93
|
+
|
|
94
|
+
A parameter that is bound holds its array, so a fact about its values
|
|
95
|
+
is in hand where the arithmetic is written. A combination holds none:
|
|
96
|
+
computing one to read a fact off it is the build, done early.
|
|
97
|
+
"""
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def parameters(self) -> tuple[Any, ...]:
|
|
101
|
+
"""The parameters this coefficient reads, in order of appearance."""
|
|
102
|
+
raise NotImplementedError
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, sets: Any) -> Any:
|
|
105
|
+
"""Refuse a second reading of a coefficient already read."""
|
|
106
|
+
raise TypeError(
|
|
107
|
+
f"coefficient {self.name} is already read at {self.dims}; a "
|
|
108
|
+
f"coefficient is read at its sets once"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _read(self, sets: Any, holder: Any) -> dict[str, Any]:
|
|
112
|
+
"""The members `sets` fixes, checked against the dimensions carried."""
|
|
113
|
+
given, shifts, fixed = reference(sets, self.dims)
|
|
114
|
+
if shifts:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"coefficient {self.name} is read at a lag {sorted(shifts)}; "
|
|
117
|
+
f"state the lag at the variable's reference, where a "
|
|
118
|
+
f"coefficient multiplies the row it lands on"
|
|
119
|
+
)
|
|
120
|
+
if given != self.dims:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"coefficient {self.name} is over {self.dims}; got {given}"
|
|
123
|
+
)
|
|
124
|
+
check_members(holder.sets, fixed, f"coefficient {self.name}")
|
|
125
|
+
return fixed
|
|
126
|
+
|
|
127
|
+
def _combine(self, other: Any, symbol: str, flip: bool = False) -> Any:
|
|
128
|
+
if isinstance(other, np.ndarray):
|
|
129
|
+
raise TypeError(NOT_A_COEFFICIENT)
|
|
130
|
+
if not isinstance(other, (Coefficient, int, float, np.number)):
|
|
131
|
+
return NotImplemented
|
|
132
|
+
return Derived(other, self, symbol) if flip else Derived(self, other, symbol)
|
|
133
|
+
|
|
134
|
+
def _applied(self, other: Any) -> Any:
|
|
135
|
+
from nimopt.term import Expression
|
|
136
|
+
|
|
137
|
+
other = read_bare(other)
|
|
138
|
+
if isinstance(other, Expression):
|
|
139
|
+
return Expression([t.with_coefficient(self) for t in other.terms])
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def __mul__(self, other: Any) -> Any:
|
|
143
|
+
applied = self._applied(other)
|
|
144
|
+
return self._combine(other, "*") if applied is None else applied
|
|
145
|
+
|
|
146
|
+
def __rmul__(self, other: Any) -> Any:
|
|
147
|
+
applied = self._applied(other)
|
|
148
|
+
if applied is not None:
|
|
149
|
+
return applied
|
|
150
|
+
if isinstance(other, (int, float, np.number)):
|
|
151
|
+
return Derived(self, other, "*")
|
|
152
|
+
return self._combine(other, "*", flip=True)
|
|
153
|
+
|
|
154
|
+
def __add__(self, other: Any) -> Any:
|
|
155
|
+
return self._combine(other, "+")
|
|
156
|
+
|
|
157
|
+
def __radd__(self, other: Any) -> Any:
|
|
158
|
+
return self._combine(other, "+", flip=True)
|
|
159
|
+
|
|
160
|
+
def __sub__(self, other: Any) -> Any:
|
|
161
|
+
return self._combine(other, "-")
|
|
162
|
+
|
|
163
|
+
def __rsub__(self, other: Any) -> Any:
|
|
164
|
+
return self._combine(other, "-", flip=True)
|
|
165
|
+
|
|
166
|
+
def __truediv__(self, other: Any) -> Any:
|
|
167
|
+
other = read_bare(other)
|
|
168
|
+
if isinstance(other, (int, float, np.number)):
|
|
169
|
+
_finite_divisor(other, f"coefficient {self.name}")
|
|
170
|
+
elif isinstance(other, Coefficient):
|
|
171
|
+
in_hand = other.held()
|
|
172
|
+
if in_hand is not None:
|
|
173
|
+
_finite_quotient(in_hand, other.name)
|
|
174
|
+
return self._combine(other, "/")
|
|
175
|
+
|
|
176
|
+
def __rtruediv__(self, other: Any) -> Any:
|
|
177
|
+
return self._combine(other, "/", flip=True)
|
|
178
|
+
|
|
179
|
+
def __neg__(self) -> Any:
|
|
180
|
+
return Derived(self, None, "-")
|
|
181
|
+
|
|
182
|
+
def __rpow__(self, other: Any) -> Any:
|
|
183
|
+
raise TypeError(
|
|
184
|
+
f"a power takes a number, and {_rendered(other)} raised to "
|
|
185
|
+
f"coefficient {self.name} varies by coordinate; nimopt expresses a "
|
|
186
|
+
f"linear term, and such a value is data a caller prepares"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def __pow__(self, other: Any) -> Any:
|
|
190
|
+
if not isinstance(other, (int, float, np.number)):
|
|
191
|
+
raise TypeError(
|
|
192
|
+
f"a power takes a number, and {_rendered(other)} carries "
|
|
193
|
+
f"dimensions; an exponent that varies by coordinate is data a "
|
|
194
|
+
f"caller prepares before a parameter exists"
|
|
195
|
+
)
|
|
196
|
+
return Derived(self, other, "**")
|
|
197
|
+
|
|
198
|
+
def __abs__(self) -> Any:
|
|
199
|
+
raise TypeError(
|
|
200
|
+
f"coefficient {self.name} has no absolute value here; nimopt "
|
|
201
|
+
f"reduces with `Sum` over its sets, and a magnitude is prepared "
|
|
202
|
+
f"before a parameter exists"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _no_row(self, other: Any) -> Any:
|
|
206
|
+
"""Refuse a comparison of two coefficients, declining one that states a row.
|
|
207
|
+
|
|
208
|
+
An expression or a symbol answers the comparison itself, so this
|
|
209
|
+
declines and Python offers it the reflected operator, which reverses
|
|
210
|
+
the sense: `capacity[G, T] >= gen[G, T]` is the row
|
|
211
|
+
`gen[G, T] <= capacity[G, T]`.
|
|
212
|
+
"""
|
|
213
|
+
from nimopt.term import Expression
|
|
214
|
+
|
|
215
|
+
if isinstance(other, (Expression, Symbol)):
|
|
216
|
+
return NotImplemented
|
|
217
|
+
raise TypeError(
|
|
218
|
+
f"coefficient {self.name} compared with {_rendered(other)} states "
|
|
219
|
+
f"no row; an equation needs a variable on one side of it"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
__le__ = _no_row
|
|
223
|
+
__ge__ = _no_row
|
|
224
|
+
__lt__ = _no_row
|
|
225
|
+
__gt__ = _no_row
|
|
226
|
+
__eq__ = _no_row
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class Derived(Coefficient):
|
|
230
|
+
"""Two coefficients and an operator, or one coefficient and a number.
|
|
231
|
+
|
|
232
|
+
The combination holds handles: it states its dimensions from its
|
|
233
|
+
operands' and computes once, where the term it multiplies materialises.
|
|
234
|
+
A coefficient is therefore written in a definition before any data
|
|
235
|
+
exists.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self, left: Any, right: Any, symbol: str) -> None:
|
|
239
|
+
self.left = left
|
|
240
|
+
self.right = right
|
|
241
|
+
self.symbol = symbol
|
|
242
|
+
carried = [
|
|
243
|
+
operand.dims
|
|
244
|
+
for operand in (left, right)
|
|
245
|
+
if isinstance(operand, Coefficient)
|
|
246
|
+
]
|
|
247
|
+
self._dims = combined_dims(*carried) if len(carried) == 2 else carried[0]
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def name(self) -> str:
|
|
251
|
+
"""The arithmetic this combination states, as it was written."""
|
|
252
|
+
if self.right is None:
|
|
253
|
+
return f"({self.symbol}{self.left.name})"
|
|
254
|
+
return f"({_rendered(self.left)} {self.symbol} {_rendered(self.right)})"
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def dims(self) -> tuple[str, ...]:
|
|
258
|
+
"""The dimensions the combination carries, read from its operands'.
|
|
259
|
+
|
|
260
|
+
They are settled where the combination is written, so two operands
|
|
261
|
+
sharing no dimension are refused there rather than at build.
|
|
262
|
+
"""
|
|
263
|
+
return self._dims
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def sets(self) -> tuple[Any, ...]:
|
|
267
|
+
"""The sets the dimensions this carries are declared over."""
|
|
268
|
+
held = {s.name: s for p in self.parameters() for s in p.sets}
|
|
269
|
+
return tuple(held[d] for d in self.dims)
|
|
270
|
+
|
|
271
|
+
def parameters(self) -> tuple[Any, ...]:
|
|
272
|
+
"""The parameters this combination reads, in order of appearance."""
|
|
273
|
+
found = {}
|
|
274
|
+
for operand in (self.left, self.right):
|
|
275
|
+
if isinstance(operand, Coefficient):
|
|
276
|
+
for parameter in operand.parameters():
|
|
277
|
+
found.setdefault(parameter.name, parameter)
|
|
278
|
+
return tuple(found.values())
|
|
279
|
+
|
|
280
|
+
def __repr__(self) -> str:
|
|
281
|
+
return f"Derived({self.name!r}, {self.dims})"
|
|
282
|
+
|
|
283
|
+
def materialise(self) -> Any:
|
|
284
|
+
"""The array this combination computes to, over the frame it states."""
|
|
285
|
+
left = self.left
|
|
286
|
+
if isinstance(left, Coefficient):
|
|
287
|
+
left = left.materialise()
|
|
288
|
+
if self.right is None:
|
|
289
|
+
return -left
|
|
290
|
+
right = self.right
|
|
291
|
+
if isinstance(right, Coefficient):
|
|
292
|
+
right = right.materialise()
|
|
293
|
+
if self.symbol == "/":
|
|
294
|
+
if isinstance(right, (int, float, np.number)):
|
|
295
|
+
_finite_divisor(right, f"coefficient {_rendered(self.left)}")
|
|
296
|
+
else:
|
|
297
|
+
_finite_quotient(right, self.right.name)
|
|
298
|
+
return left / right
|
|
299
|
+
return _BINARY[self.symbol](left, right)
|
|
300
|
+
|
|
301
|
+
def __getitem__(self, sets: Any) -> "DerivedRef":
|
|
302
|
+
"""This combination read at its sets, as a parameter is read at its.
|
|
303
|
+
|
|
304
|
+
The reading is checked against the dimensions the combination
|
|
305
|
+
carries, so a transposed or short spelling is refused where it is
|
|
306
|
+
written rather than a layer away.
|
|
307
|
+
"""
|
|
308
|
+
return DerivedRef(self, self._read(sets, self))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class DerivedRef(Coefficient):
|
|
312
|
+
"""A derived coefficient read at its sets, and at the members fixed."""
|
|
313
|
+
|
|
314
|
+
def __init__(self, derived: Any, fixed: dict[str, Any] | None = None) -> None:
|
|
315
|
+
self.derived = derived
|
|
316
|
+
self.fixed = dict(fixed) if fixed else {}
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def name(self) -> str:
|
|
320
|
+
"""The arithmetic the combination this reads states."""
|
|
321
|
+
return self.derived.name
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def dims(self) -> tuple[str, ...]:
|
|
325
|
+
"""The dimensions the reference carries, without those it fixes."""
|
|
326
|
+
return tuple(d for d in self.derived.dims if d not in self.fixed)
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def sets(self) -> tuple[Any, ...]:
|
|
330
|
+
"""The sets the dimensions this carries are declared over."""
|
|
331
|
+
return self.derived.sets
|
|
332
|
+
|
|
333
|
+
def parameters(self) -> tuple[Any, ...]:
|
|
334
|
+
"""The parameters the combination this reads carries."""
|
|
335
|
+
return self.derived.parameters()
|
|
336
|
+
|
|
337
|
+
def __repr__(self) -> str:
|
|
338
|
+
return f"DerivedRef({self.name!r}, {self.dims})"
|
|
339
|
+
|
|
340
|
+
def materialise(self) -> Any:
|
|
341
|
+
"""The combination's array, read at the members this reference fixes."""
|
|
342
|
+
array = self.derived.materialise()
|
|
343
|
+
if not self.fixed:
|
|
344
|
+
return array
|
|
345
|
+
return array.sel(self.fixed)
|