mathspec 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.
- mathspec/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The walk: program → typeset lines. Written once, for every format.
|
|
6
|
+
|
|
7
|
+
Everything here is a decision about the *math* — where a bracket changes the
|
|
8
|
+
reading, which dimension a reduction binds, that a mask belongs on the ∀ rather
|
|
9
|
+
than in the equation, that a translation shows at the leaf it re-indexes. None
|
|
10
|
+
of it is about syntax, so none is duplicated per format.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field, replace
|
|
16
|
+
from typing import TYPE_CHECKING, assert_never
|
|
17
|
+
|
|
18
|
+
from mathspec.errors import SchemaError, did_you_mean
|
|
19
|
+
from mathspec.program import (
|
|
20
|
+
Add,
|
|
21
|
+
And,
|
|
22
|
+
BooleanLiteral,
|
|
23
|
+
Cases,
|
|
24
|
+
Constant,
|
|
25
|
+
CountComparison,
|
|
26
|
+
DimensionComparison,
|
|
27
|
+
DimensionPosition,
|
|
28
|
+
Direction,
|
|
29
|
+
Divide,
|
|
30
|
+
Dual,
|
|
31
|
+
Expression,
|
|
32
|
+
ExpressionComparison,
|
|
33
|
+
GroupSum,
|
|
34
|
+
Mask,
|
|
35
|
+
Multiply,
|
|
36
|
+
Named,
|
|
37
|
+
Negate,
|
|
38
|
+
Not,
|
|
39
|
+
Or,
|
|
40
|
+
Parameter,
|
|
41
|
+
ParameterComparison,
|
|
42
|
+
ParameterDefined,
|
|
43
|
+
Partition,
|
|
44
|
+
Power,
|
|
45
|
+
Predicate,
|
|
46
|
+
PredicateOperator,
|
|
47
|
+
Pullback,
|
|
48
|
+
PulledBackPredicate,
|
|
49
|
+
RelationComparison,
|
|
50
|
+
RelationDefined,
|
|
51
|
+
RelationPairComparison,
|
|
52
|
+
Sum,
|
|
53
|
+
Translate,
|
|
54
|
+
TranslatedPredicate,
|
|
55
|
+
Variable,
|
|
56
|
+
VariableDefined,
|
|
57
|
+
WindowSum,
|
|
58
|
+
)
|
|
59
|
+
from mathspec.typesetting.format import Line, OperatorName
|
|
60
|
+
from mathspec.typesetting.legend import TranslationPolicy, policy_of
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
import datetime
|
|
64
|
+
from collections.abc import Iterable, Mapping
|
|
65
|
+
|
|
66
|
+
from mathspec._expression_parser import BinaryOperator
|
|
67
|
+
from mathspec.program import PiecewiseDeclaration, Program, SosDeclaration
|
|
68
|
+
from mathspec.typesetting.format import Format
|
|
69
|
+
from mathspec.typesetting.symbols import Symbols
|
|
70
|
+
|
|
71
|
+
#: Operator precedence, for deciding brackets. A reduction sits at the bottom
|
|
72
|
+
#: with ``+``: an unbracketed sum reads as capturing whatever follows it, so as
|
|
73
|
+
#: a factor it has to be bracketed.
|
|
74
|
+
_PRECEDENCE: dict[BinaryOperator, int] = {'+': 1, '-': 1, '*': 2, '/': 2, '**': 3}
|
|
75
|
+
_ATOM = 5
|
|
76
|
+
|
|
77
|
+
#: The same for a predicate: ``OR`` binds loosest, then ``AND``, then ``NOT``;
|
|
78
|
+
#: a comparison sits above the connectives and is bracketed under none.
|
|
79
|
+
_WHERE_PRECEDENCE = {'or': 0, 'and': 1, 'comparison': 2, 'not': 3}
|
|
80
|
+
|
|
81
|
+
#: The predicates that are one relation between two sides, which a line may
|
|
82
|
+
#: align on the way it aligns a constraint.
|
|
83
|
+
AlignedComparison = (
|
|
84
|
+
ParameterComparison
|
|
85
|
+
| ExpressionComparison
|
|
86
|
+
| CountComparison
|
|
87
|
+
| DimensionComparison
|
|
88
|
+
| DimensionPosition
|
|
89
|
+
| RelationComparison
|
|
90
|
+
| RelationPairComparison
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_PREDICATES: dict[PredicateOperator, OperatorName] = {
|
|
94
|
+
'==': 'equal',
|
|
95
|
+
'!=': 'ne',
|
|
96
|
+
'<=': 'le',
|
|
97
|
+
'>=': 'ge',
|
|
98
|
+
'<': 'lt',
|
|
99
|
+
'>': 'gt',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: Edge policy -> the operator pair that renders it, backward then forward —
|
|
104
|
+
#: the vacated row dropped, wrapped, or filled.
|
|
105
|
+
_TRANSLATIONS: dict[TranslationPolicy, tuple[OperatorName, OperatorName]] = {
|
|
106
|
+
'plain': ('minus', 'plus'),
|
|
107
|
+
'wrap': ('cyclic_minus', 'cyclic_plus'),
|
|
108
|
+
'edge': ('edge_minus', 'edge_plus'),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class _Step:
|
|
114
|
+
"""One translation of an index, and what stands where it vacated.
|
|
115
|
+
|
|
116
|
+
``fill`` is the rendered ``edge=`` value, and it rides on the operator
|
|
117
|
+
rather than in the legend because it is per call site: one model may pad a
|
|
118
|
+
sum with ``0`` and a product with ``1``, and one legend entry cannot say
|
|
119
|
+
which term is which. It is empty for the two policies that substitute
|
|
120
|
+
nothing.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
by: int | str
|
|
124
|
+
policy: TranslationPolicy
|
|
125
|
+
fill: str = ''
|
|
126
|
+
#: The rendered group a partitioned translation walks inside, or empty.
|
|
127
|
+
within: str = ''
|
|
128
|
+
|
|
129
|
+
def merged(self, other: _Step) -> _Step | None:
|
|
130
|
+
"""*other* applied under this one as a single translation, or ``None`` where it is two.
|
|
131
|
+
|
|
132
|
+
Only numbers add, and only under an identical policy, fill and group:
|
|
133
|
+
a cyclic step under an acyclic one folded into ``t ⊖ 2`` claims the
|
|
134
|
+
outer step wraps when it drops.
|
|
135
|
+
"""
|
|
136
|
+
if isinstance(self.by, str) or isinstance(other.by, str):
|
|
137
|
+
return None
|
|
138
|
+
same = (self.policy, self.fill, self.within) == (other.policy, other.fill, other.within)
|
|
139
|
+
return replace(self, by=self.by + other.by) if same else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class _Context:
|
|
144
|
+
"""What a subscript means at this point in the tree.
|
|
145
|
+
|
|
146
|
+
``offsets`` is how ``shift`` renders: it emits no operator of its own but
|
|
147
|
+
re-indexes its operand, so the translation shows at the *leaves*. Its steps
|
|
148
|
+
are outermost first, the order they apply to the index — the outer shift of
|
|
149
|
+
``shift(shift(x, offset=a), offset=b)`` moves ``t`` to ``t - b``, and the inner one
|
|
150
|
+
reads ``x`` from there.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
walk: Walk
|
|
154
|
+
offsets: dict[str, tuple[_Step, ...]] = field(default_factory=dict)
|
|
155
|
+
#: dim -> the rendered subscript that replaces its index, as ``at`` re-indexes a leaf.
|
|
156
|
+
pullbacks: dict[str, str] = field(default_factory=dict)
|
|
157
|
+
#: Every dimension whose index is in use here — the frame, then one entry
|
|
158
|
+
#: per reduction entered — so a reduction over one takes a fresh dummy.
|
|
159
|
+
bound: tuple[str, ...] = ()
|
|
160
|
+
|
|
161
|
+
def translated(self, dim: str, step: _Step) -> _Context:
|
|
162
|
+
steps = self.offsets.get(dim, ())
|
|
163
|
+
merged = steps[-1].merged(step) if steps else None
|
|
164
|
+
steps = (*steps[:-1], merged) if merged is not None else (*steps, step)
|
|
165
|
+
return _Context(self.walk, {**self.offsets, dim: steps}, self.pullbacks, self.bound)
|
|
166
|
+
|
|
167
|
+
def pulled_back(self, dim: str, rendered: str) -> _Context:
|
|
168
|
+
return _Context(self.walk, self.offsets, {**self.pullbacks, dim: rendered}, self.bound)
|
|
169
|
+
|
|
170
|
+
def reducing(self, dim: str) -> tuple[str, _Context]:
|
|
171
|
+
"""A dummy index for a reduction over *dim*, and the context its body reads under.
|
|
172
|
+
|
|
173
|
+
The plain index where nothing outside the reduction uses it; primed
|
|
174
|
+
once per enclosing use of the same dimension, so ``sum(q, by=bus_of)``
|
|
175
|
+
under ``∀ g`` sums over ``g'`` and its condition can still name ``g``.
|
|
176
|
+
"""
|
|
177
|
+
primes = "'" * self.bound.count(dim)
|
|
178
|
+
dummy = f'{self.walk.symbols.index[dim]}{primes}'
|
|
179
|
+
body = _Context(self.walk, self.offsets, {**self.pullbacks, dim: dummy}, (*self.bound, dim))
|
|
180
|
+
return dummy, body
|
|
181
|
+
|
|
182
|
+
def subscript(self, dim: str) -> str:
|
|
183
|
+
"""The index for *dim* here: its pullback if it has one, then every translation.
|
|
184
|
+
|
|
185
|
+
A pullback is a base like any other rather than a stopping point.
|
|
186
|
+
``at`` and ``shift`` both re-index the leaf and the leaf has one
|
|
187
|
+
subscript, so a reading that showed only whichever ran last dropped the
|
|
188
|
+
other operator out of the equation.
|
|
189
|
+
"""
|
|
190
|
+
text = self.pullbacks.get(dim, self.walk.symbols.index[dim])
|
|
191
|
+
translated = False
|
|
192
|
+
for step in self.offsets.get(dim, ()):
|
|
193
|
+
if step.by == 0:
|
|
194
|
+
continue
|
|
195
|
+
base = self.walk.format.parenthesise(text) if translated else text
|
|
196
|
+
amount = self.walk.symbols.name[step.by] if isinstance(step.by, str) else str(abs(step.by))
|
|
197
|
+
text = f'{base} {self.walk._translation(step)} {amount}'
|
|
198
|
+
translated = True
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
def indexed(self, symbol: str, dims: list[str]) -> str:
|
|
202
|
+
return self.walk.format.subscript(symbol, [self.subscript(d) for d in dims])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _unsigned(node: Expression) -> Expression | None:
|
|
206
|
+
"""*node* without its leading minus — on the node, or on the first factor of a product it heads — else ``None``."""
|
|
207
|
+
if isinstance(node, Negate):
|
|
208
|
+
return node.operand
|
|
209
|
+
if isinstance(node, Multiply | Divide):
|
|
210
|
+
first, second = (node.left, node.right) if isinstance(node, Multiply) else (node.numerator, node.divisor)
|
|
211
|
+
if (head := _unsigned(first)) is not None:
|
|
212
|
+
return Multiply(head, second) if isinstance(node, Multiply) else Divide(head, second)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Walk:
|
|
217
|
+
"""Walks a program, emitting :class:`Line`s in one format.
|
|
218
|
+
|
|
219
|
+
:meth:`equations` prints every section; what those sections use, the
|
|
220
|
+
legend reads off the program (:func:`~mathspec.typesetting.legend.notice`).
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(
|
|
224
|
+
self,
|
|
225
|
+
program: Program,
|
|
226
|
+
symbols: Symbols,
|
|
227
|
+
fmt: Format,
|
|
228
|
+
*,
|
|
229
|
+
inline_expressions: bool = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
self.program = program
|
|
232
|
+
self.symbols = symbols
|
|
233
|
+
self.format = fmt
|
|
234
|
+
#: Substitute each plain named expression where it is used, rather than
|
|
235
|
+
#: printing its symbol there and its definition once.
|
|
236
|
+
self.inline_expressions = inline_expressions
|
|
237
|
+
|
|
238
|
+
def _frame_of(self, name: str) -> list[str]:
|
|
239
|
+
"""The dims named expression *name* is read over, as its declaration carries them."""
|
|
240
|
+
return list(self.program.expressions[name].dims)
|
|
241
|
+
|
|
242
|
+
def _op(self, name: OperatorName) -> str:
|
|
243
|
+
return self.format.operators[name]
|
|
244
|
+
|
|
245
|
+
def _translation(self, step: _Step) -> str:
|
|
246
|
+
"""The operator for one translation, its fill below and its group above.
|
|
247
|
+
|
|
248
|
+
Two slots, because one subscript holding both says nothing about which
|
|
249
|
+
is the fill and which the group. A named offset is always backward,
|
|
250
|
+
since ``offset=-p`` is refused at load.
|
|
251
|
+
"""
|
|
252
|
+
backward, forward = _TRANSLATIONS[step.policy]
|
|
253
|
+
operator = self._op(backward if isinstance(step.by, str) or step.by > 0 else forward)
|
|
254
|
+
if step.fill:
|
|
255
|
+
operator = self.format.subscript(operator, [step.fill])
|
|
256
|
+
if not step.within:
|
|
257
|
+
return operator
|
|
258
|
+
return self.format.superscript(operator, step.within)
|
|
259
|
+
|
|
260
|
+
def _relation_read(self, name: str, at: Mapping[str, str], read: str) -> str:
|
|
261
|
+
"""Relation *name*'s column *read* as a function at the columns *at* fixes: ``bus(g)``, ``zone_of(g, p)`` or ``ends.bus0(l)``.
|
|
262
|
+
|
|
263
|
+
*at* maps each key role to the index it is read at. The function is
|
|
264
|
+
named after the relation alone where the key determines one column, and
|
|
265
|
+
after the column read otherwise.
|
|
266
|
+
"""
|
|
267
|
+
lk = self.program.relations[name]
|
|
268
|
+
function = name if len(lk.values) == 1 else f'{name}.{read}'
|
|
269
|
+
return self.format.apply(self.format.upright(function), self.format.joined([at[k] for k in lk.key], ''))
|
|
270
|
+
|
|
271
|
+
def _relation_row(self, name: str, at: Mapping[str, str]) -> str:
|
|
272
|
+
"""That relation *name* has a row at the key *at* fixes.
|
|
273
|
+
|
|
274
|
+
A keyed table is a function, so the claim is that it is defined there:
|
|
275
|
+
``gen_zone(g, t) is defined``. A bare one is a set of rows, and every
|
|
276
|
+
column of it is a key column, so the row is written out:
|
|
277
|
+
``(g, b) ∈ connection``.
|
|
278
|
+
"""
|
|
279
|
+
lk = self.program.relations[name]
|
|
280
|
+
key = self.format.joined([at[k] for k in lk.key], '')
|
|
281
|
+
if lk.values:
|
|
282
|
+
return f'{self.format.apply(self.format.upright(name), key)} {self.format.prose(" is defined")}'
|
|
283
|
+
return f'{self.format.parenthesise(key)} {self._op("in")} {self.format.upright(name)}'
|
|
284
|
+
|
|
285
|
+
def _frame_key(self, name: str, ctx: _Context) -> dict[str, str]:
|
|
286
|
+
"""Relation *name*'s key roles at the frame's own indices of their dimensions."""
|
|
287
|
+
lk = self.program.relations[name]
|
|
288
|
+
return {k: ctx.subscript(lk.dim(k)) for k in lk.key}
|
|
289
|
+
|
|
290
|
+
def _value_read(self, name: str, column: str, ctx: _Context) -> str:
|
|
291
|
+
"""A keyed relation's value *column* read at the frame's own indices of its key: ``period_of(t)``."""
|
|
292
|
+
return self._relation_read(name, self._frame_key(name, ctx), column)
|
|
293
|
+
|
|
294
|
+
def _tuple(self, reads: list[str]) -> str:
|
|
295
|
+
"""Several reads as one group label: the read alone where there is one, a bracketed tuple otherwise."""
|
|
296
|
+
return reads[0] if len(reads) == 1 else self.format.parenthesise(self.format.joined(reads, ''))
|
|
297
|
+
|
|
298
|
+
def _context(self, frame: Iterable[str] = ()) -> _Context:
|
|
299
|
+
return _Context(self, bound=tuple(frame))
|
|
300
|
+
|
|
301
|
+
def _number(self, value: float) -> str:
|
|
302
|
+
if value == float('inf'):
|
|
303
|
+
return self._op('infinity')
|
|
304
|
+
if value == int(value):
|
|
305
|
+
return str(int(value))
|
|
306
|
+
mantissa, _, exponent = repr(value).partition('e')
|
|
307
|
+
if not exponent:
|
|
308
|
+
return mantissa
|
|
309
|
+
power = self.format.superscript('10', str(int(exponent)))
|
|
310
|
+
return power if mantissa == '1' else f'{mantissa} {self._op("times")} {power}'
|
|
311
|
+
|
|
312
|
+
# -- arithmetic --------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _expression(self, node: Expression, ctx: _Context, *, need: int = 0) -> str:
|
|
315
|
+
text, precedence = self._arithmetic(node, ctx)
|
|
316
|
+
return self.format.parenthesise(text) if precedence < need else text
|
|
317
|
+
|
|
318
|
+
def _arithmetic(self, node: Expression, ctx: _Context) -> tuple[str, int]:
|
|
319
|
+
"""Render *node*, returning the text and the precedence it binds at."""
|
|
320
|
+
if isinstance(node, Named):
|
|
321
|
+
if self.inline_expressions and not isinstance(node.body, Cases):
|
|
322
|
+
return self._arithmetic(node.body, ctx)
|
|
323
|
+
return ctx.indexed(self.symbols.name[node.name], self._frame_of(node.name)), _ATOM
|
|
324
|
+
|
|
325
|
+
if isinstance(node, Constant):
|
|
326
|
+
return self._number(node.value), _ATOM if node.value >= 0 else 1
|
|
327
|
+
|
|
328
|
+
if isinstance(node, Parameter):
|
|
329
|
+
return ctx.indexed(self.symbols.name[node.name], list(self.program.parameters[node.name].dims)), _ATOM
|
|
330
|
+
|
|
331
|
+
if isinstance(node, Variable):
|
|
332
|
+
return ctx.indexed(self.symbols.name[node.name], list(self.program.variables[node.name].dims)), _ATOM
|
|
333
|
+
|
|
334
|
+
if isinstance(node, Negate):
|
|
335
|
+
text, precedence = self._arithmetic(node.operand, ctx)
|
|
336
|
+
operand = self.format.parenthesise(text) if precedence < 2 else text
|
|
337
|
+
return f'{self._op("minus")}{operand}', 2
|
|
338
|
+
|
|
339
|
+
if isinstance(node, Add | Multiply | Divide | Power):
|
|
340
|
+
return self._binary(node, ctx)
|
|
341
|
+
|
|
342
|
+
if isinstance(node, Sum):
|
|
343
|
+
return self._sum(node, ctx)
|
|
344
|
+
|
|
345
|
+
if isinstance(node, GroupSum):
|
|
346
|
+
return self._group_sum(node, ctx)
|
|
347
|
+
|
|
348
|
+
if isinstance(node, Pullback):
|
|
349
|
+
return self._pullback(node, ctx)
|
|
350
|
+
|
|
351
|
+
if isinstance(node, Translate):
|
|
352
|
+
return self._translate(node, ctx)
|
|
353
|
+
|
|
354
|
+
if isinstance(node, WindowSum):
|
|
355
|
+
return self._window_sum(node, ctx)
|
|
356
|
+
|
|
357
|
+
if isinstance(node, Cases):
|
|
358
|
+
return self.format.cases(self._arms(node, ctx)), _ATOM
|
|
359
|
+
|
|
360
|
+
if isinstance(node, Dual):
|
|
361
|
+
return self._dual(node, ctx), _ATOM
|
|
362
|
+
|
|
363
|
+
assert_never(node)
|
|
364
|
+
|
|
365
|
+
def _dual(self, node: Dual, ctx: _Context) -> str:
|
|
366
|
+
"""λ subscripted by the constraint's symbol, then the indices of the constraint's own frame."""
|
|
367
|
+
frame = self._sorted(frozenset(self.program.constraints[node.constraint].dims))
|
|
368
|
+
return self.format.subscript(
|
|
369
|
+
self._op('dual'), [self.symbols.constraint[node.constraint], *(ctx.subscript(d) for d in frame)]
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def _binary(self, node: Add | Multiply | Divide | Power, ctx: _Context) -> tuple[str, int]:
|
|
373
|
+
"""Render a binary operator, bracketing only where the reading demands.
|
|
374
|
+
|
|
375
|
+
A subtraction arrives as an addition of a negation and prints as the
|
|
376
|
+
subtraction it was: ``a + -b`` is ``a - b`` and ``a - -b`` is ``a + b``,
|
|
377
|
+
the sign folding until the right operand carries none. Subtraction
|
|
378
|
+
raises the requirement on its right operand by one: ``a - (b - c)``
|
|
379
|
+
and ``a - (b + c)`` need the bracket; ``a - b*c`` does not. A negated
|
|
380
|
+
factor is bracketed, since ``a · -b`` is a spelling nobody reads. A
|
|
381
|
+
power is atomic to everything but another power, a stacked
|
|
382
|
+
superscript being ambiguous.
|
|
383
|
+
"""
|
|
384
|
+
if isinstance(node, Divide):
|
|
385
|
+
top = self._expression(node.numerator, ctx)
|
|
386
|
+
bottom = self._expression(node.divisor, ctx)
|
|
387
|
+
return self.format.fraction(top, bottom), _ATOM
|
|
388
|
+
if isinstance(node, Power):
|
|
389
|
+
base = self._expression(node.base, ctx, need=_PRECEDENCE['**'] + 1)
|
|
390
|
+
return self.format.superscript(base, self._expression(node.exponent, ctx)), _PRECEDENCE['**']
|
|
391
|
+
op: BinaryOperator = '*' if isinstance(node, Multiply) else '+'
|
|
392
|
+
precedence = _PRECEDENCE[op]
|
|
393
|
+
left = self._expression(node.left, ctx, need=precedence)
|
|
394
|
+
operand = node.right
|
|
395
|
+
if op == '+':
|
|
396
|
+
while (unsigned := _unsigned(operand)) is not None:
|
|
397
|
+
operand, op = unsigned, '-' if op == '+' else '+'
|
|
398
|
+
negated_factor = op == '*' and isinstance(operand, Negate)
|
|
399
|
+
need = _ATOM if negated_factor else _PRECEDENCE[op] + (1 if op == '-' else 0)
|
|
400
|
+
right = self._expression(operand, ctx, need=need)
|
|
401
|
+
names: dict[BinaryOperator, OperatorName] = {'*': 'cdot', '+': 'plus', '-': 'minus'}
|
|
402
|
+
return self.format.joined([left, right], self._op(names[op])), precedence
|
|
403
|
+
|
|
404
|
+
def _sum(self, node: Sum, ctx: _Context) -> tuple[str, int]:
|
|
405
|
+
"""A reduction over named dims: one dummy index per dim, in declaration order."""
|
|
406
|
+
memberships = []
|
|
407
|
+
inner = ctx
|
|
408
|
+
for d in self._sorted(frozenset(node.over)):
|
|
409
|
+
dummy, inner = inner.reducing(d)
|
|
410
|
+
memberships.append(self._membership(d, dummy))
|
|
411
|
+
domain = self.format.joined(memberships, '')
|
|
412
|
+
return self.format.summation(domain, self._reduction_body(node.operand, inner)), _PRECEDENCE['+']
|
|
413
|
+
|
|
414
|
+
def _group_sum(self, node: GroupSum, ctx: _Context) -> tuple[str, int]:
|
|
415
|
+
"""A sum through a relation: a dummy per consumed dim, and the row it joins on as the domain's condition."""
|
|
416
|
+
direction = node.direction
|
|
417
|
+
dummies: dict[str, str] = {}
|
|
418
|
+
inner = ctx
|
|
419
|
+
for d in direction.consumed_dims:
|
|
420
|
+
dummies[d], inner = inner.reducing(d)
|
|
421
|
+
conditions = list(self._grouping(direction, dummies, ctx))
|
|
422
|
+
domain = (
|
|
423
|
+
f'{self.format.joined([self._membership(d, dummies[d]) for d in direction.consumed_dims], "")} '
|
|
424
|
+
f'{self._op("such_that")} {self.format.joined(conditions, self._op("and"))}'
|
|
425
|
+
)
|
|
426
|
+
return self.format.summation(domain, self._reduction_body(node.operand, inner)), _PRECEDENCE['+']
|
|
427
|
+
|
|
428
|
+
def _pullback(self, node: Pullback, ctx: _Context) -> tuple[str, int]:
|
|
429
|
+
"""``at`` emits no operator of its own: it re-indexes the operand, so the read shows at the leaves."""
|
|
430
|
+
return self._arithmetic(node.operand, self._pulled_back(node.direction, ctx))
|
|
431
|
+
|
|
432
|
+
def _translate(self, node: Translate, ctx: _Context) -> tuple[str, int]:
|
|
433
|
+
"""``shift`` emits no operator of its own: it re-indexes the operand, so the translation shows at the leaves.
|
|
434
|
+
|
|
435
|
+
``edge='wrap'`` and a number are the two policies that print a symbol
|
|
436
|
+
of their own; absent is the bare shift, whose vacated positions are
|
|
437
|
+
absent.
|
|
438
|
+
"""
|
|
439
|
+
fill = '' if node.fill is None else self._number(node.fill)
|
|
440
|
+
step = _Step(node.offset, policy_of(node), fill, self._group(node.partition))
|
|
441
|
+
return self._arithmetic(node.operand, ctx.translated(node.along, step))
|
|
442
|
+
|
|
443
|
+
def _window_sum(self, node: WindowSum, ctx: _Context) -> tuple[str, int]:
|
|
444
|
+
"""``sum_back``: a sum over the positions behind the row, the lag written as a translation of the index."""
|
|
445
|
+
step = _Step(1, policy_of(node), within=self._group(node.partition))
|
|
446
|
+
source, inner = ctx.reducing(node.along)
|
|
447
|
+
lag = f'{ctx.subscript(node.along)} {self._translation(step)} {source}'
|
|
448
|
+
domain = (
|
|
449
|
+
f'{source} {self._op("in")} {self.symbols.set[node.along]} {self._op("such_that")} '
|
|
450
|
+
f'0 {self._op("le")} {lag} {self._op("lt")} {self._width(node.width)}'
|
|
451
|
+
)
|
|
452
|
+
body = self._reduction_body(node.operand, inner)
|
|
453
|
+
return self.format.summation(domain, body), _PRECEDENCE['+']
|
|
454
|
+
|
|
455
|
+
def _pulled_back(self, direction: Direction, ctx: _Context) -> _Context:
|
|
456
|
+
"""*ctx* with each dimension *direction* consumes read at the relation, as ``at`` re-indexes a leaf."""
|
|
457
|
+
at = {r: ctx.subscript(direction.dim(r)) for r in (*direction.produced, *direction.joined)}
|
|
458
|
+
for read in direction.consumed:
|
|
459
|
+
ctx = ctx.pulled_back(direction.dim(read), self._relation_read(direction.name, at, read))
|
|
460
|
+
return ctx
|
|
461
|
+
|
|
462
|
+
def _grouping(self, direction: Direction, dummies: Mapping[str, str], ctx: _Context) -> list[str]:
|
|
463
|
+
"""The conditions a grouped sum's domain carries for one direction: what it fixes of the row it joins on.
|
|
464
|
+
|
|
465
|
+
A direction fixes its relation's key either way, so each value column
|
|
466
|
+
it fixes — consumed or produced, one lookup at the key either way — is
|
|
467
|
+
that column read there. One that fixes none of them asks only that the
|
|
468
|
+
row is there, because a value column it does not touch is not read,
|
|
469
|
+
and a bare relation has no value column to read at all.
|
|
470
|
+
"""
|
|
471
|
+
at = {
|
|
472
|
+
**{r: dummies[direction.dim(r)] for r in direction.consumed},
|
|
473
|
+
**{r: ctx.subscript(direction.dim(r)) for r in (*direction.joined, *direction.produced)},
|
|
474
|
+
}
|
|
475
|
+
fixed = [r for r in self.program.relations[direction.name].values if r in at]
|
|
476
|
+
if not fixed:
|
|
477
|
+
return [self._relation_row(direction.name, at)]
|
|
478
|
+
return [f'{self._relation_read(direction.name, at, r)} {self._op("equal")} {at[r]}' for r in fixed]
|
|
479
|
+
|
|
480
|
+
def _group(self, partition: Partition | None) -> str:
|
|
481
|
+
"""A ``by=`` as the superscript its translation operator carries.
|
|
482
|
+
|
|
483
|
+
The bare index, not the subscript in force: the group is a property of
|
|
484
|
+
the row being written, and a window whose operand is itself translated
|
|
485
|
+
still asks which group *that row* is in.
|
|
486
|
+
"""
|
|
487
|
+
if partition is None:
|
|
488
|
+
return ''
|
|
489
|
+
at = {r: self.symbols.index[partition.dim(r)] for r in (partition.along, *partition.joined)}
|
|
490
|
+
return self._tuple([self._relation_read(partition.name, at, r) for r in partition.group])
|
|
491
|
+
|
|
492
|
+
def _width(self, width: int | str) -> str:
|
|
493
|
+
"""``sum_back``'s ``window=``: a number, or a parameter's own symbol.
|
|
494
|
+
|
|
495
|
+
Unsubscripted where it is named, as a translation's named offset is:
|
|
496
|
+
the symbol identifies the parameter and the legend carries its dims,
|
|
497
|
+
where repeating them inside a summation's domain crowds out the
|
|
498
|
+
condition that domain exists to state.
|
|
499
|
+
"""
|
|
500
|
+
if isinstance(width, str):
|
|
501
|
+
return self.symbols.name[width]
|
|
502
|
+
return self._number(float(width))
|
|
503
|
+
|
|
504
|
+
def _membership(self, dim: str, index: str | None = None) -> str:
|
|
505
|
+
return f'{index or self.symbols.index[dim]} {self._op("in")} {self.symbols.set[dim]}'
|
|
506
|
+
|
|
507
|
+
def _reduction_body(self, node: Expression, ctx: _Context) -> str:
|
|
508
|
+
"""What sits to the right of a sum, bracketed only where it must be.
|
|
509
|
+
|
|
510
|
+
A sum binds everything up to the next ``+`` or ``-`` at its own level,
|
|
511
|
+
so an additive body needs the bracket and nothing else does — including
|
|
512
|
+
a nested reduction, which is unambiguous.
|
|
513
|
+
"""
|
|
514
|
+
additive = isinstance(node, Negate | Add)
|
|
515
|
+
return self._expression(node, ctx, need=2 if additive else 0)
|
|
516
|
+
|
|
517
|
+
# -- where strings -----------------------------------------------------
|
|
518
|
+
|
|
519
|
+
def _predicate(self, node: Predicate, ctx: _Context, *, need: int = 0) -> str:
|
|
520
|
+
text, precedence = self._where(node, ctx)
|
|
521
|
+
return self.format.parenthesise(text) if precedence < need else text
|
|
522
|
+
|
|
523
|
+
def _where(self, node: Predicate, ctx: _Context) -> tuple[str, int]:
|
|
524
|
+
comparison = _WHERE_PRECEDENCE['comparison']
|
|
525
|
+
if isinstance(node, BooleanLiteral):
|
|
526
|
+
assert not node.value, 'an always-true mask is folded away or refused before anything prints it'
|
|
527
|
+
return self._op('false'), _ATOM
|
|
528
|
+
|
|
529
|
+
if isinstance(node, ParameterDefined):
|
|
530
|
+
indexed = ctx.indexed(self.symbols.name[node.name], list(node.dims))
|
|
531
|
+
if self.program.parameters[node.name].dtype == 'bool':
|
|
532
|
+
return indexed, _ATOM
|
|
533
|
+
return f'{indexed} {self.format.prose(" is defined")}', comparison
|
|
534
|
+
|
|
535
|
+
if isinstance(node, VariableDefined):
|
|
536
|
+
return (
|
|
537
|
+
f'{ctx.indexed(self.symbols.name[node.name], list(node.dims))} {self.format.prose(" exists")}',
|
|
538
|
+
comparison,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
if isinstance(node, AlignedComparison):
|
|
542
|
+
left, right = self.sides(node, ctx)
|
|
543
|
+
return f'{left} {right}', comparison
|
|
544
|
+
|
|
545
|
+
if isinstance(node, TranslatedPredicate):
|
|
546
|
+
moved = ctx.translated(node.along, _Step(node.offset, 'plain'))
|
|
547
|
+
return self._where(node.operand.root, moved)
|
|
548
|
+
|
|
549
|
+
if isinstance(node, PulledBackPredicate):
|
|
550
|
+
return self._where(node.operand.root, self._pulled_back(node.direction, ctx))
|
|
551
|
+
|
|
552
|
+
if isinstance(node, RelationDefined):
|
|
553
|
+
return self._relation_row(node.name, self._frame_key(node.name, ctx)), comparison
|
|
554
|
+
|
|
555
|
+
if isinstance(node, Not):
|
|
556
|
+
return (
|
|
557
|
+
f'{self._op("not")} {self._predicate(node.operand, ctx, need=_WHERE_PRECEDENCE["not"])}',
|
|
558
|
+
_WHERE_PRECEDENCE['not'],
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
if isinstance(node, And):
|
|
562
|
+
need = _WHERE_PRECEDENCE['and']
|
|
563
|
+
sides = [self._predicate(node.left, ctx, need=need), self._predicate(node.right, ctx, need=need)]
|
|
564
|
+
return self.format.joined(sides, self._op('and')), need
|
|
565
|
+
|
|
566
|
+
if isinstance(node, Or):
|
|
567
|
+
need = _WHERE_PRECEDENCE['or']
|
|
568
|
+
sides = [self._predicate(node.left, ctx, need=need), self._predicate(node.right, ctx, need=need)]
|
|
569
|
+
return self.format.joined(sides, self._op('or')), need
|
|
570
|
+
|
|
571
|
+
assert_never(node)
|
|
572
|
+
|
|
573
|
+
def sides(self, node: AlignedComparison, ctx: _Context) -> tuple[str, str]:
|
|
574
|
+
"""One comparison as its two sides, the relation symbol leading the right.
|
|
575
|
+
|
|
576
|
+
Split so that a line whose whole predicate is one comparison aligns on
|
|
577
|
+
the relation, as a constraint does.
|
|
578
|
+
"""
|
|
579
|
+
if isinstance(node, ParameterComparison):
|
|
580
|
+
left, right = ctx.indexed(self.symbols.name[node.name], list(node.dims)), self._literal(node.value)
|
|
581
|
+
elif isinstance(node, ExpressionComparison):
|
|
582
|
+
left, right = self._expression(node.left, ctx), self._expression(node.right, ctx)
|
|
583
|
+
elif isinstance(node, DimensionComparison):
|
|
584
|
+
left, right = ctx.subscript(node.name), self._literal(node.value)
|
|
585
|
+
elif isinstance(node, DimensionPosition):
|
|
586
|
+
grouping = (
|
|
587
|
+
None
|
|
588
|
+
if node.partition is None
|
|
589
|
+
else self._tuple([self._value_read(node.partition.name, c, ctx) for c in node.partition.group])
|
|
590
|
+
)
|
|
591
|
+
left = self._position(ctx.subscript(node.name), grouping)
|
|
592
|
+
right = self._ordinal(node.name, node.position, grouping)
|
|
593
|
+
elif isinstance(node, RelationComparison):
|
|
594
|
+
left, right = self._value_read(node.name, node.column, ctx), self._literal(node.value)
|
|
595
|
+
elif isinstance(node, RelationPairComparison):
|
|
596
|
+
left = self._value_read(node.name, node.column, ctx)
|
|
597
|
+
right = self._value_read(node.other, node.other_column, ctx)
|
|
598
|
+
elif isinstance(node, CountComparison):
|
|
599
|
+
index, inner = ctx.reducing(node.over)
|
|
600
|
+
counted = self.format.set_of(
|
|
601
|
+
self._membership(node.over, index), self._predicate(node.predicate.root, inner)
|
|
602
|
+
)
|
|
603
|
+
left, right = self.format.cardinality(counted), self._number(node.value)
|
|
604
|
+
else:
|
|
605
|
+
assert_never(node)
|
|
606
|
+
return left, f'{self._op(_PREDICATES[node.op])} {right}'
|
|
607
|
+
|
|
608
|
+
def _literal(self, value: float | str | datetime.date) -> str:
|
|
609
|
+
return self._number(value) if isinstance(value, int | float) else self.format.quoted(str(value))
|
|
610
|
+
|
|
611
|
+
def _position(self, index: str, grouping: str | None) -> str:
|
|
612
|
+
"""``position(dim)`` applied to the row, *grouping* as a subscript — as an argument it read as a second position."""
|
|
613
|
+
symbol = self._op('position')
|
|
614
|
+
if grouping is not None:
|
|
615
|
+
symbol = self.format.subscript(symbol, [grouping])
|
|
616
|
+
return self.format.apply(symbol, index)
|
|
617
|
+
|
|
618
|
+
def _ordinal(self, dimension: str, at: int, grouping: str | None) -> str:
|
|
619
|
+
"""The position compared against; a negative one counts back from the size of the set it is a position in — the group's where grouped."""
|
|
620
|
+
if at >= 0:
|
|
621
|
+
return self._number(at)
|
|
622
|
+
size = self.symbols.set[dimension]
|
|
623
|
+
if grouping is not None:
|
|
624
|
+
size = self.format.subscript(size, [grouping])
|
|
625
|
+
return f'{self.format.cardinality(size)} {self._op("minus")} {self._number(-at)}'
|
|
626
|
+
|
|
627
|
+
def _condition(self, ctx: _Context, mask: Mask | None) -> str:
|
|
628
|
+
"""The mask on a quantifier, printed.
|
|
629
|
+
|
|
630
|
+
A mask every row passes arrives as ``None`` — resolution folds it,
|
|
631
|
+
so this prints what a program carries — and a quantifier with no
|
|
632
|
+
condition prints none.
|
|
633
|
+
"""
|
|
634
|
+
return '' if mask is None else self._predicate(mask.root, ctx)
|
|
635
|
+
|
|
636
|
+
def _quantifier(self, dims: list[str], condition: str) -> str:
|
|
637
|
+
if not dims and not condition:
|
|
638
|
+
return ''
|
|
639
|
+
over = self.format.joined([self._membership(d) for d in dims], '')
|
|
640
|
+
if not condition:
|
|
641
|
+
return f'{self._op("forall")} {over}'
|
|
642
|
+
if not over:
|
|
643
|
+
return f'{self.format.prose("where ")} {condition}'
|
|
644
|
+
return f'{self._op("forall")} {over} {self._op("such_that")} {condition}'
|
|
645
|
+
|
|
646
|
+
# -- declarations ------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
def equations(self) -> list[tuple[str, list[Line]]]:
|
|
649
|
+
"""Every titled section of equations."""
|
|
650
|
+
return [
|
|
651
|
+
('Objective', self._objective()),
|
|
652
|
+
('Subject to', self._constraints()),
|
|
653
|
+
('Definitions', self._definitions()),
|
|
654
|
+
('Variable domains', self._variables()),
|
|
655
|
+
('Assumptions', self._assumptions()),
|
|
656
|
+
]
|
|
657
|
+
|
|
658
|
+
def _objective(self) -> list[Line]:
|
|
659
|
+
"""The objective's line.
|
|
660
|
+
|
|
661
|
+
The expression is scalar — every reduction in it is one the file wrote
|
|
662
|
+
— so it renders like any other, and the line carries no label: the
|
|
663
|
+
block has no name, and the section heading already says what it is.
|
|
664
|
+
"""
|
|
665
|
+
objective = self.program.objective
|
|
666
|
+
if objective is None:
|
|
667
|
+
return []
|
|
668
|
+
sense = self._op('minimize' if objective.sense == 'minimize' else 'maximize')
|
|
669
|
+
return [Line(label='', left=sense, right=self._expression(objective.expression, self._context()))]
|
|
670
|
+
|
|
671
|
+
def _constraints(self) -> list[Line]:
|
|
672
|
+
"""Every constraint, then every curve.
|
|
673
|
+
|
|
674
|
+
A ``piecewise:`` block restricts what its link expressions may be
|
|
675
|
+
together, which is what a row does, so it prints here rather than among
|
|
676
|
+
the domains — where a set prints, being a property of one variable.
|
|
677
|
+
"""
|
|
678
|
+
return [
|
|
679
|
+
*(self._constraint(name) for name in self.program.constraints),
|
|
680
|
+
*(self._piecewise(name) for name in self.program.piecewise),
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
def _constraint(self, name: str) -> Line:
|
|
684
|
+
constraint = self.program.constraints[name]
|
|
685
|
+
ctx = self._context(frame=constraint.dims)
|
|
686
|
+
condition = self._condition(ctx, constraint.where)
|
|
687
|
+
return Line(
|
|
688
|
+
label=name,
|
|
689
|
+
left=self._expression(constraint.lhs, ctx),
|
|
690
|
+
right=f'{self._op(_PREDICATES[constraint.sense])} {self._expression(constraint.rhs, ctx)}',
|
|
691
|
+
condition=self._quantifier(list(constraint.dims), condition),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
def _definitions(self) -> list[Line]:
|
|
695
|
+
"""One line per named expression, in declaration order, defining it.
|
|
696
|
+
|
|
697
|
+
A use prints the symbol and the block prints here, as a paper states a
|
|
698
|
+
quantity it names. Every declared one prints, used or not. Inlining
|
|
699
|
+
substitutes away the plain ones the math reads; a ``cases`` block has
|
|
700
|
+
no single body to substitute, and an entry the math never reads has
|
|
701
|
+
nowhere to be substituted *into*, so both still print.
|
|
702
|
+
"""
|
|
703
|
+
return [self.definition(name) for name in self.defined()]
|
|
704
|
+
|
|
705
|
+
def defined(self) -> list[str]:
|
|
706
|
+
"""The named expressions that print under their own symbol: every one, or only the unsubstitutable when inlining.
|
|
707
|
+
|
|
708
|
+
Inlining leaves a name standing only where substitution cannot reach
|
|
709
|
+
it — a ``cases`` block, and an entry the objective and constraints
|
|
710
|
+
never read, which is a quantity reported back rather than solved for.
|
|
711
|
+
"""
|
|
712
|
+
entries = self.program.expressions
|
|
713
|
+
if not self.inline_expressions:
|
|
714
|
+
return list(entries)
|
|
715
|
+
return [name for name, entry in entries.items() if isinstance(entry.expression, Cases) or not entry.in_math]
|
|
716
|
+
|
|
717
|
+
def definition(self, name: str) -> Line:
|
|
718
|
+
"""The line defining one named expression, ``symbol = body`` over its frame."""
|
|
719
|
+
body = self.program.expressions[name].expression
|
|
720
|
+
frame = self._frame_of(name)
|
|
721
|
+
ctx = self._context(frame)
|
|
722
|
+
rendered = self.format.cases(self._arms(body, ctx)) if isinstance(body, Cases) else self._expression(body, ctx)
|
|
723
|
+
return Line(
|
|
724
|
+
label=name,
|
|
725
|
+
left=ctx.indexed(self.symbols.name[name], frame),
|
|
726
|
+
right=f'{self._op("equal")} {rendered}',
|
|
727
|
+
condition=self._quantifier(frame, ''),
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
def line(self, name: str) -> Line:
|
|
731
|
+
"""The one line *name* prints as: a named expression, a constraint, an assumption, a curve, or a variable's domain.
|
|
732
|
+
|
|
733
|
+
An assumption is looked up where the document prints it from, so a
|
|
734
|
+
condition a curve's method states is a line a reader can ask for
|
|
735
|
+
before the curve is written out.
|
|
736
|
+
|
|
737
|
+
Raises:
|
|
738
|
+
SchemaError: *name* is declared as none of the five, or as two — a
|
|
739
|
+
constraint may share a variable's name, and one line prints
|
|
740
|
+
one of them.
|
|
741
|
+
"""
|
|
742
|
+
program = self.program
|
|
743
|
+
kinds = {
|
|
744
|
+
'named expression': (program.expressions, self.definition),
|
|
745
|
+
'constraint': (program.constraints, self._constraint),
|
|
746
|
+
'assumption': (program.assumptions, self._assumption),
|
|
747
|
+
'curve': (program.piecewise, self._piecewise),
|
|
748
|
+
'variable': (program.variables, self._variable),
|
|
749
|
+
}
|
|
750
|
+
found = [kind for kind, (group, _) in kinds.items() if name in group]
|
|
751
|
+
if not found:
|
|
752
|
+
everything = {n for group, _ in kinds.values() for n in group}
|
|
753
|
+
msg = (
|
|
754
|
+
f"'{name}' is not a named expression, constraint, assumption, curve or variable. "
|
|
755
|
+
f'{did_you_mean(name, everything)}'
|
|
756
|
+
)
|
|
757
|
+
raise SchemaError(msg)
|
|
758
|
+
if len(found) > 1:
|
|
759
|
+
msg = f"'{name}' is declared twice, as {found[0]} and as {found[1]}, and one line prints one of them — rename one."
|
|
760
|
+
raise SchemaError(msg)
|
|
761
|
+
return kinds[found[0]][1](name)
|
|
762
|
+
|
|
763
|
+
def _arms(self, node: Cases, ctx: _Context) -> list[tuple[str, str]]:
|
|
764
|
+
"""Each region as its value and the words saying where it applies.
|
|
765
|
+
|
|
766
|
+
Which region is the fallback is a fact about the math, so the *walk*
|
|
767
|
+
says "if" or "otherwise" and a Format only stacks the rows: the last
|
|
768
|
+
region is the ``otherwise``, since resolution builds it as the
|
|
769
|
+
remainder of the others, and its mask is never printed.
|
|
770
|
+
"""
|
|
771
|
+
*stated, last = node.regions
|
|
772
|
+
arms = [(self._expression(region.value, ctx), self._arm_condition(region.when, ctx)) for region in stated]
|
|
773
|
+
return [*arms, (self._expression(last.value, ctx), self.format.prose('otherwise'))]
|
|
774
|
+
|
|
775
|
+
def _arm_condition(self, when: Mask, ctx: _Context) -> str:
|
|
776
|
+
return f'{self.format.prose("if ")} {self._predicate(when.root, ctx, need=_WHERE_PRECEDENCE["and"])}'
|
|
777
|
+
|
|
778
|
+
def _variables(self) -> list[Line]:
|
|
779
|
+
"""One line per variable, and one more for a set the variable carries.
|
|
780
|
+
|
|
781
|
+
A ``sos:`` block restricts the *domain* — which members of a family may
|
|
782
|
+
be nonzero at once — so it prints under this heading, beside the
|
|
783
|
+
variable it is a property of, rather than among the constraints, where
|
|
784
|
+
it would read as a row a solver holds.
|
|
785
|
+
"""
|
|
786
|
+
sets = {block.variable: (key, block) for key, block in self.program.sos.items()}
|
|
787
|
+
lines = []
|
|
788
|
+
for name, block in self.program.variables.items():
|
|
789
|
+
lines.append(self._variable(name))
|
|
790
|
+
if name in sets:
|
|
791
|
+
lines.append(self._sos(name, *sets[name], self._context(frame=block.dims)))
|
|
792
|
+
return lines
|
|
793
|
+
|
|
794
|
+
def _variable(self, name: str) -> Line:
|
|
795
|
+
block = self.program.variables[name]
|
|
796
|
+
ctx = self._context(frame=block.dims)
|
|
797
|
+
symbol = ctx.indexed(self.symbols.name[name], list(block.dims))
|
|
798
|
+
condition = self._quantifier(list(block.dims), self._condition(ctx, block.where))
|
|
799
|
+
lower, upper = block.lower, block.upper
|
|
800
|
+
|
|
801
|
+
if block.domain == 'binary':
|
|
802
|
+
left, right = symbol, f'{self._op("in")} {self._op("binary_set")}'
|
|
803
|
+
else:
|
|
804
|
+
if lower is not None and upper is not None:
|
|
805
|
+
left = f'{self._bound(ctx, lower)} {self._op("le")} {symbol}'
|
|
806
|
+
right = f'{self._op("le")} {self._bound(ctx, upper)}'
|
|
807
|
+
elif upper is not None:
|
|
808
|
+
left, right = symbol, f'{self._op("le")} {self._bound(ctx, upper)}'
|
|
809
|
+
elif lower is not None:
|
|
810
|
+
left, right = symbol, f'{self._op("ge")} {self._bound(ctx, lower)}'
|
|
811
|
+
else:
|
|
812
|
+
domain = self._op('integers' if block.domain == 'integer' else 'reals')
|
|
813
|
+
left, right = symbol, f'{self._op("in")} {domain}'
|
|
814
|
+
if block.domain == 'integer' and (lower is not None or upper is not None):
|
|
815
|
+
right = f'{right}, {symbol} {self._op("in")} {self._op("integers")}'
|
|
816
|
+
return Line(label=name, left=left, right=right, condition=condition)
|
|
817
|
+
|
|
818
|
+
def _sos(self, name: str, key: str, block: SosDeclaration, ctx: _Context) -> Line:
|
|
819
|
+
"""The variable's family along the set's dim, as one member of the SOS set, quantified over the other dims."""
|
|
820
|
+
dims = self.program.variables[name].dims
|
|
821
|
+
family = self.format.parenthesise(ctx.indexed(self.symbols.name[name], list(dims)))
|
|
822
|
+
return Line(
|
|
823
|
+
label=key,
|
|
824
|
+
left=self.format.subscript(family, [self._membership(block.along)]),
|
|
825
|
+
right=f'{self._op("in")} {self._op("sos_set")}{block.sos_type}',
|
|
826
|
+
condition=self._quantifier([d for d in dims if d != block.along], ''),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# -- assumptions -------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
def _assumptions(self) -> list[Line]:
|
|
832
|
+
"""What the model assumes of its data, in the order a program carries it.
|
|
833
|
+
|
|
834
|
+
A curve's conditions stand here with the file's own, because the
|
|
835
|
+
method states them in the same language: the reader sees every
|
|
836
|
+
condition the data is held to, whoever stated it.
|
|
837
|
+
"""
|
|
838
|
+
return [self._assumption(name) for name in self.program.assumptions]
|
|
839
|
+
|
|
840
|
+
def _assumption(self, name: str) -> Line:
|
|
841
|
+
"""One assumption: the predicate over the frame both its masks name, under its ``where``."""
|
|
842
|
+
assumption = self.program.assumptions[name]
|
|
843
|
+
holds, where = assumption.predicate, assumption.where
|
|
844
|
+
frame = self._sorted(holds.dims | (where.dims if where is not None else frozenset()))
|
|
845
|
+
ctx = self._context(frame)
|
|
846
|
+
if isinstance(holds.root, AlignedComparison):
|
|
847
|
+
left, right = self.sides(holds.root, ctx)
|
|
848
|
+
else:
|
|
849
|
+
left, right = self._predicate(holds.root, ctx), ''
|
|
850
|
+
return Line(label=name, left=left, right=right, condition=self._quantifier(frame, self._condition(ctx, where)))
|
|
851
|
+
|
|
852
|
+
def _piecewise(self, name: str) -> Line:
|
|
853
|
+
"""One ``piecewise:`` block as the curve it states, over the frame it states one per coordinate of.
|
|
854
|
+
|
|
855
|
+
The links' expressions are a point, and the block says that point lies
|
|
856
|
+
on the piecewise-linear locus through the breakpoints. A bounded link
|
|
857
|
+
states one side of the locus instead, so there the locus prints as the
|
|
858
|
+
function of the pinned link that it is and the link's own sign says
|
|
859
|
+
which side.
|
|
860
|
+
"""
|
|
861
|
+
block = self.program.piecewise[name]
|
|
862
|
+
links = [link.expression for link in block.links]
|
|
863
|
+
frame = list(block.frame)
|
|
864
|
+
ctx = self._context([*frame, block.over])
|
|
865
|
+
locus = self._locus(block, ctx)
|
|
866
|
+
bounded = next((i for i, link in enumerate(block.links) if link.sign != '=='), None)
|
|
867
|
+
if bounded is None:
|
|
868
|
+
left = self._tuple([self._expression(node, ctx) for node in links])
|
|
869
|
+
right = f'{self._op("in")} {locus}'
|
|
870
|
+
else:
|
|
871
|
+
pinned = links[1 - bounded]
|
|
872
|
+
left = self._expression(links[bounded], ctx)
|
|
873
|
+
sign = self._op(_PREDICATES[block.links[bounded].sign])
|
|
874
|
+
right = f'{sign} {self.format.apply(locus, self._expression(pinned, ctx))}'
|
|
875
|
+
return Line(label=name, left=left, right=right, condition=self._quantifier(frame, ''))
|
|
876
|
+
|
|
877
|
+
def _locus(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
|
|
878
|
+
"""The set the links lie on: the curve through the breakpoints, or the hull ``convex`` relaxes it onto.
|
|
879
|
+
|
|
880
|
+
A gate multiplies it, which is what gating a curve does — the weights
|
|
881
|
+
sum to the gate, so the locus is the origin where the gate is 0 and the
|
|
882
|
+
curve where it is 1.
|
|
883
|
+
"""
|
|
884
|
+
operator = self._op('hull' if block.method == 'convex' else 'curve')
|
|
885
|
+
through = self.format.subscript(operator, [self._breakpoints(block, ctx)])
|
|
886
|
+
values = self.format.joined(
|
|
887
|
+
[
|
|
888
|
+
ctx.indexed(self.symbols.name[link.values], list(self.program.parameters[link.values].dims))
|
|
889
|
+
for link in block.links
|
|
890
|
+
],
|
|
891
|
+
'',
|
|
892
|
+
)
|
|
893
|
+
locus = self.format.apply(through, values)
|
|
894
|
+
gate = self._gate(block, ctx)
|
|
895
|
+
return f'{gate} {self._op("cdot")} {locus}' if gate else locus
|
|
896
|
+
|
|
897
|
+
def _breakpoints(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
|
|
898
|
+
"""Which breakpoints the curve runs through: every one of the dimension, or the ones ``points:`` admits.
|
|
899
|
+
|
|
900
|
+
A ``points:`` naming a boolean parameter reads as the flag it is, and
|
|
901
|
+
one naming a values parameter as the rows that parameter has, which is
|
|
902
|
+
the same reading a ``where`` gives either of them.
|
|
903
|
+
"""
|
|
904
|
+
over = self._membership(block.over)
|
|
905
|
+
if block.points is None:
|
|
906
|
+
return over
|
|
907
|
+
admitted = ParameterDefined(block.points, tuple(self.program.parameters[block.points].dims))
|
|
908
|
+
return f'{over} {self._op("such_that")} {self._predicate(admitted, ctx)}'
|
|
909
|
+
|
|
910
|
+
def _gate(self, block: PiecewiseDeclaration, ctx: _Context) -> str:
|
|
911
|
+
"""The factor an ``activity:`` puts on the locus, or ``''`` where the block has none.
|
|
912
|
+
|
|
913
|
+
Where the gate is a variable that does not exist at every coordinate
|
|
914
|
+
the curve is built for, the factor is the gate where it exists and 1
|
|
915
|
+
where it does not — the two rows the expansion writes there, because a
|
|
916
|
+
variable that does not exist takes its row with it and would leave the
|
|
917
|
+
curve unstated rather than ungated. ``absence: zero`` is the other
|
|
918
|
+
reading and pins the curve off, which is the factor on its own.
|
|
919
|
+
"""
|
|
920
|
+
if (activity := block.activity) is None:
|
|
921
|
+
return ''
|
|
922
|
+
gate = self.program.variables[activity]
|
|
923
|
+
symbol = ctx.indexed(self.symbols.name[activity], list(gate.dims))
|
|
924
|
+
mask = gate.where
|
|
925
|
+
if mask is None or gate.absence == 'zero':
|
|
926
|
+
return symbol
|
|
927
|
+
where = self._predicate(mask.root, ctx, need=_WHERE_PRECEDENCE['and'])
|
|
928
|
+
return self.format.cases(
|
|
929
|
+
[(symbol, f'{self.format.prose("if ")} {where}'), ('1', self.format.prose('otherwise'))]
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
def _bound(self, ctx: _Context, value: Expression) -> str:
|
|
933
|
+
"""A bound as the file wrote it: a number, or a parameter indexed over its dims."""
|
|
934
|
+
if isinstance(value, Parameter):
|
|
935
|
+
return ctx.indexed(self.symbols.name[value.name], list(self.program.parameters[value.name].dims))
|
|
936
|
+
assert isinstance(value, Constant), 'a bound is a number or the name of a parameter'
|
|
937
|
+
return self._number(value.value)
|
|
938
|
+
|
|
939
|
+
def _sorted(self, dims: frozenset[str]) -> list[str]:
|
|
940
|
+
order = list(self.program.dimensions)
|
|
941
|
+
return sorted(dims, key=order.index)
|