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 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)