diff --git a/docs/reference/language/declarations.md b/docs/reference/language/declarations.md index af4518ce..3882ab78 100644 --- a/docs/reference/language/declarations.md +++ b/docs/reference/language/declarations.md @@ -155,7 +155,7 @@ storage_balance: storage_balance_initial: foreach: [snapshot, storage] - where: "snapshot == index(snapshot, 0)" + where: "position(snapshot) == 0" expression: soc == soc_initial ``` diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index c1227955..a7111839 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -165,27 +165,28 @@ A `where:` is a boolean mask, and true means "this coordinate exists". ```text where_expr ::= atom | "NOT" where_expr | where_expr ("AND"|"OR") where_expr | "(" where_expr ")" -atom ::= NAME | NAME COMPARATOR value | "True" | "False" +atom ::= NAME | NAME COMPARATOR value | POSITION COMPARATOR INTEGER + | "True" | "False" COMPARATOR ::= "<=" | ">=" | "==" | "!=" | "<" | ">" -value ::= NUMBER | QUOTED | NAME_OR_STRING | POSITION -POSITION ::= "index" "(" NAME "," INTEGER ")" +value ::= NUMBER | QUOTED | NAME_OR_STRING +POSITION ::= "position" "(" NAME [ "," "by" "=" NAME ] ")" QUOTED ::= "'" chars "'" | '"' chars '"' ``` -| Surface | Names a… | Meaning | -| ----------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` (bare) | parameter | what defined means is the **declaration's** to say: a `bool` is its own answer, a `str` is defined wherever the table has a row, and a number has to be finite as well — `0.0` counts, `inf` does not, though it is a value everywhere else | -| `name` (bare) | variable | the variable exists at this coordinate — the counterpart of the parameter row, and how you say which coordinates the row-dropping rule applies to | -| `name` (bare) | dimension | load error: it is true everywhere, so it reads as a condition and is not one. Compare it instead | -| `name OP value` | parameter | element-wise; a null compares false. The right-hand side is a literal number, or a bare name read as a string coordinate | -| `name OP value` | dimension | a filter on the frame's own coordinate column | -| `name` (bare) | lookup | defined: the label maps somewhere. A lookup may be [partial](dimensions.md#lookups), and this is how a declaration asks for the labels that do map | -| `name OP value` | lookup | a filter on the lookup's column of its `over` dimension's index — which therefore has to be in the frame. A null value is **false**, whatever the comparator | -| `name OP name` | two lookups | the one comparison whose both sides are structure. Legal only where both map out of the **same** dimension _and_ into the **same** one — `from != to` excludes a self-loop | -| `name OP index(name, i)` | one dimension, twice | the coordinate at position `i` of that dimension's own order — negative counts from the end. Both names must be the **same** dimension | -| `name OP index(name, i, by=lookup)` | a dimension and a lookup over it | the same, counted **within each group** the lookup makes — every period's first snapshot, whatever each period's length | -| `AND` `OR` `NOT` | — | case-insensitive; `NOT` binds tighter than `AND`, which binds tighter than `OR` | -| `True` / `False` | — | literals; `True` is the same as no `where` | +| Surface | Names a… | Meaning | +| -------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` (bare) | parameter | what defined means is the **declaration's** to say: a `bool` is its own answer, a `str` is defined wherever the table has a row, and a number has to be finite as well — `0.0` counts, `inf` does not, though it is a value everywhere else | +| `name` (bare) | variable | the variable exists at this coordinate — the counterpart of the parameter row, and how you say which coordinates the row-dropping rule applies to | +| `name` (bare) | dimension | load error: it is true everywhere, so it reads as a condition and is not one. Compare it instead | +| `name OP value` | parameter | element-wise; a null compares false. The right-hand side is a literal number, or a bare name read as a string coordinate | +| `name OP value` | dimension | a filter on the frame's own coordinate column | +| `name` (bare) | lookup | defined: the label maps somewhere. A lookup may be [partial](dimensions.md#lookups), and this is how a declaration asks for the labels that do map | +| `name OP value` | lookup | a filter on the lookup's column of its `over` dimension's index — which therefore has to be in the frame. A null value is **false**, whatever the comparator | +| `name OP name` | two lookups | the one comparison whose both sides are structure. Legal only where both map out of the **same** dimension _and_ into the **same** one — `from != to` excludes a self-loop | +| `position(name) OP i` | one dimension | where the row sits along that dimension's own order, as an integer — `0` is first, negative counts from the end. Both sides are integers, so every comparator reads the one way | +| `position(name, by=lookup) OP i` | a dimension and a lookup over it | the same, counted **within each group** the lookup makes — every period's first snapshot, whatever each period's length | +| `AND` `OR` `NOT` | — | case-insensitive; `NOT` binds tighter than `AND`, which binds tighter than `OR` | +| `True` / `False` | — | literals; `True` is the same as no `where` | The mask's dims must not exceed the frame it sits in ([dim algebra](#dim-algebra)), and an undeclared bare name is a @@ -226,8 +227,8 @@ load error naming the fix. A datetime boundary is a quoted ISO date — `snapshot > '2030-01-01'`, or `'2030-01-01T06:00'` with a time. Calendar arithmetic, resampling and timezone conversion stay data prep. -**`index(dim, i)` names a coordinate by where it sits**, so a boundary clause -survives the index being relabelled: +**`position(dim)` converts a dimension to where the row sits along it**, so a +boundary clause survives the index being relabelled: ```yaml dimensions: @@ -239,20 +240,30 @@ variables: constraints: soc_start: foreach: [snapshot] - where: "snapshot == index(snapshot, 0)" # not: snapshot == 0 + where: "position(snapshot) == 0" # not: snapshot == 0 expression: soc == soc_initial ``` A recurrence needs its first position seeded, and the label that happens to be there is a property of the data — relabel `[0, 1, 2]` to `[1, 2, 3]` and `snapshot == 0` matches nothing, leaving the recurrence unanchored. `-1` is the -last coordinate, `-2` the one before it. A position no coordinate occupies is +last position, `-2` the one before it. A position no coordinate occupies is an **error at bind**, not an empty mask: the clause exists to seed a row, and seeding none is the failure it was written to prevent. The order counted along is the dimension's own — the one `shift` walks, and the one the index declares — not the bytewise order a label comparison uses. +**The conversion is on the left, and that is what makes an ordering readable.** +`position(snapshot) > 0` is "not the first row", on any axis, because both +sides are integers. Naming the coordinate _at_ a position and comparing +coordinates against it would have made the same clause mean either that or "a +coordinate sorting after the first one" — two different masks wherever the +coordinates do not arrive sorted, and nothing in a file says they do +([#32](https://github.com/energy-models/math-spec/issues/32)). A comparison of +_values_ is still written against the dimension itself, where it always was: +`snapshot > '2030-01-01'`. + **`by=` counts inside each group a lookup makes**, which is the boundary a multi-period model wants — one seeded row per period rather than one per horizon: @@ -270,7 +281,7 @@ variables: constraints: soc_start: foreach: [snapshot] - where: "snapshot == index(snapshot, 0, by=period_of)" + where: "position(snapshot, by=period_of) == 0" expression: soc == at(soc_initial, by=period_of) ``` diff --git a/docs/reference/language/piecewise.md b/docs/reference/language/piecewise.md index 5d953452..46733060 100644 --- a/docs/reference/language/piecewise.md +++ b/docs/reference/language/piecewise.md @@ -85,7 +85,7 @@ weights with nothing making them a curve. **The breakpoint order is `over`'s index order**, the one every dimension has: the order its labels are first written in, which `shift` walks and -`index(bp, 0)` names. So the `bp` index is the curve's x-axis, and a values +`position(bp) == 0` names. So the `bp` index is the curve's x-axis, and a values parameter is a lookup against it — a table is a function of its coordinates and the order its rows arrive in means nothing, on either lane. "Strictly increasing breakpoints" below is increasing _in that order_: write the index diff --git a/docs/reference/notation.md b/docs/reference/notation.md index db12de1c..36df7332 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -73,7 +73,7 @@ parameters: | Symbol | Meaning | |---|---| -| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | +| $\mathcal{T}$ | index $t$ — `snapshot` (`int` coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | | $\mathcal{G}$ | index $g$ — `generator` with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\enspace \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ | | $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | | $\mathcal{Z}$ | index $z$ — `zone` | @@ -121,6 +121,12 @@ $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leav $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, not the order labels sort in — counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. + +$\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +$\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. + ### The objective #### `objective` @@ -378,11 +384,24 @@ a position in a dimension, and the same position within a group ```yaml first: foreach: [snapshot, generator] - where: "snapshot == index(snapshot, 0) OR snapshot == index(snapshot, 0, by=season_of)" + where: "position(snapshot) == 0 OR position(snapshot, by=season_of) == 0" expression: on == 1 ``` -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right)$$ + +#### `last` + +the same two counted from the end, which print against a size rather than as themselves + +```yaml +last: + foreach: [snapshot, generator] + where: "position(snapshot) == -1 OR position(snapshot, by=season_of) == -1" + expression: on == 0 +``` + +$$\mathit{on}_{t,g} = 0 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right)$$ #### `northern` @@ -715,11 +734,11 @@ cost_curve: method: lp ``` -$$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ +$$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) \neq 0$$ -$$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ +$$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = 0$$ -$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ +$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{pos}(b) = \lvert \mathcal{B} \rvert - 1$$ ### Sets carried to the solver diff --git a/src/math_spec/piecewise.py b/src/math_spec/piecewise.py index 4ad93aa1..61d77c15 100644 --- a/src/math_spec/piecewise.py +++ b/src/math_spec/piecewise.py @@ -254,7 +254,7 @@ def _expand_lp( d = pw.over run = f'({x_link.values} - shift({x_link.values}, over={d}, offset=1, edge=0))' rise = f'({y_link.values} - shift({y_link.values}, over={d}, offset=1, edge=0))' - interior = f'{mask} AND NOT {name}_starts' if mask else f'{d} != index({d}, 0)' + interior = f'{mask} AND NOT {name}_starts' if mask else f'position({d}) != 0' raw['constraints'][f'{name}_chord'] = { 'foreach': [*frame, d], 'where': interior, @@ -264,7 +264,7 @@ def _expand_lp( ), } edges = (('domain_lo', '>=', f'{name}_starts'), ('domain_hi', '<=', f'{name}_ends')) - axis = (('domain_lo', '>=', f'{d} == index({d}, 0)'), ('domain_hi', '<=', f'{d} == index({d}, -1)')) + axis = (('domain_lo', '>=', f'position({d}) == 0'), ('domain_hi', '<=', f'position({d}) == -1')) for suffix, sense, at in edges if mask else axis: if mask: raw.setdefault('parameters', {})[at] = { diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 0c2f1384..9169a6d5 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -663,37 +663,28 @@ def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str, def _resolve_position(node: UnresolvedPositionNode, ns: Namespace, context: str, errors: list[str]) -> WhereNode: - """Type ``lhs index(dim, i)`` — both sides must name the same dimension. + """Type ``position(dim) i`` — the name has to be a dimension. - Comparing one dimension's coordinate against another's would be comparing - labels across label spaces, which can only mask everything out; and - ``index`` of anything but a dimension has no coordinate order to count - along. + ``position()`` converts a dimension to the row's place along it, so it + takes the one thing that has an order to count along. Anything else has no + coordinate order, and the comparison would have nothing to be a position + *in*. ``by=`` groups that order, so it takes a lookup *over the dimension being counted*: the groups are its target's labels, and a lookup over anything else has no position within a group to name. """ - for named in (node.name, node.dimension): - if named not in ns.dimensions: - kind = ns.kind(named) - was = f'a {kind}' if kind else 'not declared' - errors.append( - f"{context}: index() counts along a dimension's coordinates, and " - f"'{named}' is {was}.\n Dimensions: {sorted(ns.dimensions)}" - ) - return node - if node.name != node.dimension: + if node.dimension not in ns.dimensions: + kind = ns.kind(node.dimension) + was = f'a {kind}' if kind else 'not declared' errors.append( - f"{context}: '{node.name} {node.op} index({node.dimension}, {node.position})' compares " - f"a '{node.name}' coordinate against a '{node.dimension}' one. No label of one is a " - f'label of the other, so the predicate can only mask everything out — index() names ' - f'a position in the dimension being tested.' + f"{context}: position() counts along a dimension's coordinates, and " + f"'{node.dimension}' is {was}.\n Dimensions: {sorted(ns.dimensions)}" ) return node if node.by is not None and _refuse_grouping(node, node.by, ns, context, errors): return node - return DimensionPositionNode(node.name, node.op, node.position, node.by) + return DimensionPositionNode(node.dimension, node.op, node.position, node.by) def _refuse_grouping(node: UnresolvedPositionNode, by: str, ns: Namespace, context: str, errors: list[str]) -> bool: @@ -703,7 +694,7 @@ def _refuse_grouping(node: UnresolvedPositionNode, by: str, ns: Namespace, conte inside each, so a lookup over another dimension carries no row of the one being indexed — there is nothing for a position to be a position *in*. """ - shown = f'index({node.dimension}, {node.position}, by={by})' + shown = f'position({node.dimension}, by={by})' if (kind := ns.kind(by)) != 'lookup': was = f'a {kind}' if kind else 'not declared' errors.append( diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index bff51c9e..0e4b7d68 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -114,6 +114,7 @@ def typeset( blocks += [fmt.glossary(group.title, group.entries) for group in walk.glossaries()] blocks += [fmt.note(text) for text in walk.convention_notes()] blocks += [fmt.note(text) for text in walk.translation_notes()] + blocks += [fmt.note(text) for text in walk.position_notes()] return fmt.document([*blocks, *rendered], standalone=standalone) diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 844568e0..48358e53 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -68,6 +68,7 @@ 'integers', 'binary_set', 'sos_set', + 'position', 'minimize', 'maximize', } @@ -188,6 +189,15 @@ def superscript(self, base: str, tail: str) -> str: ... def parenthesise(self, inner: str) -> str: ... + def cardinality(self, inner: str) -> str: + """How many members a set has: ``|T|``. + + A fence rather than an entry in :data:`OPERATOR_NAMES`, which is a + vocabulary of *infix* spellings — ``tests/typeset/test_typeset.py`` + compiles every one of them between two operands. + """ + ... + def fraction(self, numerator: str, denominator: str) -> str: ... def summation(self, domain: str, body: str) -> str: ... diff --git a/src/math_spec/typeset/latex.py b/src/math_spec/typeset/latex.py index 6e281890..e523df4d 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -80,6 +80,7 @@ class LatexFormat: 'integers': r'\mathbb{Z}', 'binary_set': r'\{0, 1\}', 'sos_set': r'\mathrm{SOS}', + 'position': r'\mathrm{pos}', 'minimize': r'\min', 'maximize': r'\max', } @@ -121,6 +122,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return rf'\left( {inner} \right)' + def cardinality(self, inner: str) -> str: + return rf'\lvert {inner} \rvert' + def fraction(self, numerator: str, denominator: str) -> str: return rf'\frac{{{numerator}}}{{{denominator}}}' diff --git a/src/math_spec/typeset/markdown.py b/src/math_spec/typeset/markdown.py index 1bb6a8cf..0a9924f0 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -83,6 +83,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return _LATEX.parenthesise(inner) + def cardinality(self, inner: str) -> str: + return _LATEX.cardinality(inner) + def fraction(self, numerator: str, denominator: str) -> str: return _LATEX.fraction(numerator, denominator) diff --git a/src/math_spec/typeset/typst.py b/src/math_spec/typeset/typst.py index 70a51cb5..a7a87b4b 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -90,6 +90,7 @@ class TypstFormat: 'integers': 'ZZ', 'binary_set': '{0, 1}', 'sos_set': 'upright("SOS")', + 'position': 'upright("pos")', 'minimize': 'min', 'maximize': 'max', } @@ -131,6 +132,9 @@ def superscript(self, base: str, tail: str) -> str: def parenthesise(self, inner: str) -> str: return f'({inner})' + def cardinality(self, inner: str) -> str: + return f'abs({inner})' + def fraction(self, numerator: str, denominator: str) -> str: return f'frac({numerator}, {denominator})' diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index cc159461..e71f164e 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -213,9 +213,11 @@ def indexed(self, symbol: str, dims: list[str]) -> str: class Walk: """Walks a validated schema, emitting :class:`Line`s in one format. - Stateful only in what it has *noticed* — which edge policies appeared and - whether a translation was counted inside a group, which the legend needs - to explain the symbols they print. + Stateful only in what it has *noticed* — which edge policies appeared, + whether a translation was counted inside a group, which positional forms + printed, and which dimensions were compared against a coordinate that is a + number; every one of them something the legend has to explain once the + equations print it. """ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fmt: Format) -> None: @@ -225,6 +227,8 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.format = fmt self.policies: set[str] = set() self.grouped = False + self.positions: set[str] = set() + self.numeric_coordinates: set[str] = set() def op(self, name: str) -> str: return self.format.operators[name] @@ -478,14 +482,20 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: return f'{left} {self.op(_PREDICATES[node.op])} {self.literal(node.value)}', 2 if isinstance(node, DimensionComparisonNode): + if isinstance(node.value, (int, float)): + # a label that is a number is the one the legend has to place: + # every other coordinate prints as prose and cannot be read as + # a position in the first place + self.numeric_coordinates.add(node.name) return f'{ctx.subscript(node.name)} {self.op(_PREDICATES[node.op])} {self.literal(node.value)}', 2 if isinstance(node, DimensionPositionNode): grouping = ( None if node.by is None else self.format.apply(self.format.upright(node.by), ctx.subscript(node.name)) ) - ordinal = self.position(node.name, node.position, grouping) - return f'{ctx.subscript(node.name)} {self.op(_PREDICATES[node.op])} {ordinal}', 2 + place = self.position(ctx.subscript(node.name), grouping) + ordinal = self.ordinal(node.name, node.position, grouping) + return f'{place} {self.op(_PREDICATES[node.op])} {ordinal}', 2 if isinstance(node, LookupComparisonNode): applied = self.format.apply(self.format.upright(node.name), ctx.subscript(node.over)) @@ -521,19 +531,41 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: def literal(self, value: float | str | datetime.date) -> str: return self.number(value) if isinstance(value, (int, float)) else self.format.prose(str(value)) - def position(self, dimension: str, at: int, grouping: str | None = None) -> str: - """``index(dim, i)`` as the coordinate it names. + def position(self, index: str, grouping: str | None = None) -> str: + """``position(dim)`` as the row's place along the dimension. - An upright application of the operator to the set, the same shape a - lookup gets — rather than ``min``/``max``, which would read the two - ends and leave every other position without a notation. *grouping* is - the lookup already applied to the row, and prints as a third argument - so the row a position is counted for is visible where the position is. + Applied to the *row* rather than to the set, because that is what it + converts: a coordinate to where that coordinate sits. *grouping* is the + lookup already applied to the row, and rides as a **subscript** rather + than as a second argument — a modifier saying which order is being + counted, the way an edge fill rides its translation. As an argument it + sat where the first one's integer sits, and read as a second position. """ - parts = [self.symbols.set[dimension], self.number(at)] + self.positions.add('grouped' if grouping is not None else 'plain') + symbol = self.op('position') if grouping is not None: - parts.append(grouping) - return self.format.apply(self.format.upright('index'), ', '.join(parts)) + symbol = self.format.subscript(symbol, [grouping]) + return self.format.apply(symbol, index) + + def ordinal(self, dimension: str, at: int, grouping: str | None = None) -> str: + """The position compared against, counted from the end where it is negative. + + A position is a place in the order, and the legend runs that order + from ``0`` to the size less one — so ``-1`` printed as itself asserts a + position the page has just said cannot exist. What it counts back from + is that size, and the *group's* size where the count is grouped, which + is the set those positions are positions in. + + ``0`` prints as ``0``: the file is 0-based and so is the page, so a + clause can be read off one and written into the other. + """ + if at >= 0: + return self.number(at) + self.positions.add('from_end') + size = self.symbols.set[dimension] + if grouping is not None: + size = self.format.subscript(size, [grouping]) + return f'{self.format.cardinality(size)} {self.op("minus")} {self.number(-at)}' def conjoined(self, ctx: _Context, *nodes: WhereNode | None) -> str: r"""The mask on a quantifier, as one condition. @@ -708,6 +740,11 @@ def _coords(self, dim: str) -> str: targeted = self.schema.targeted_of(dim) labels = self.schema.labels_of(dim) clauses = [] + if dim in self.numeric_coordinates: + # only where an equation compared this index against a number, + # which is the one place "position 3" and "the coordinate 3" are + # both readings of the same line + clauses.append(f' ({self.format.mono(self.schema.dimensions[dim].dtype)} coordinates)') if targeted: maps = self.format.joined( [ @@ -787,3 +824,44 @@ def translation_notes(self) -> list[str]: ) notes.append(note) return notes + + def position_notes(self) -> list[str]: + """A sentence for each positional symbol the model actually printed. + + Gated the way the translation notes are, and the first of them is what + the page cannot go without. A reader arrives from papers where the + index *is* the ordinal — sets are written as ``{1, …, T}`` there, so + nothing is marked because nothing needs to be — and this language + indexes by coordinates instead. A page printing both + ``pos(t) = 0`` and ``t >= 3`` therefore has to say once which of the + two is the position, or the reader recovers a different model from the + one the file holds. + """ + notes = [] + if self.positions: + index = self.format.math('t') + place = self.format.math(self.format.apply(self.op('position'), 't')) + dash = self.format.dash + notes.append( + f"{place} denotes where index {index} sits along its dimension's own order {dash} the order " + f'{self.format.mono("shift")} walks, not the order labels sort in {dash} counted from ' + f'{self.format.math("0")}. The index itself stays the coordinate, so {index} compares against ' + f'labels and {place} against positions.' + ) + if 'grouped' in self.positions: + applied = self.format.apply(self.format.upright('lookup'), 't') + grouped = self.format.math(self.format.apply(self.format.subscript(self.op('position'), [applied]), 't')) + group = self.format.math(self.format.subscript(self.format.script('T'), [applied])) + notes.append( + f'{grouped} counts within the group a lookup puts {self.format.math("t")} in: the subscript names ' + f'the map, {group} is the group it lands in, and that group has a first position of its own.' + ) + if 'from_end' in self.positions: + size = self.format.cardinality(self.format.script('T')) + last = self.format.math(f'{size} {self.op("minus")} {self.number(1)}') + notes.append( + f'{self.format.math(size)} denotes the size of the set being counted along, and a position ' + f'counted from the end prints against it {self.format.dash} {last} is the last position, one ' + f'less than the size because the first is {self.format.math("0")}.' + ) + return notes diff --git a/src/math_spec/where_parser.py b/src/math_spec/where_parser.py index 5c695b44..1d236cdf 100644 --- a/src/math_spec/where_parser.py +++ b/src/math_spec/where_parser.py @@ -20,6 +20,7 @@ from __future__ import annotations +import re from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, cast @@ -66,16 +67,15 @@ class UnresolvedComparisonNode: @dataclass class UnresolvedPositionNode: - """``lhs index(dim, i)`` before either name is checked. + """``position(dim) i`` before the name is checked. - Kept apart from :class:`UnresolvedComparisonNode` because its right-hand - side names a dimension *and* a position, which no literal can carry. - ``resolution.py`` types it into :class:`DimensionPositionNode`. + Kept apart from :class:`UnresolvedComparisonNode` because its left-hand + side is not a name but an *application* to one, which no bare name can + carry. ``resolution.py`` types it into :class:`DimensionPositionNode`. """ - name: str - op: PredicateOperator dimension: str + op: PredicateOperator position: int by: str | None = None @@ -119,16 +119,22 @@ class DimensionComparisonNode: @dataclass class DimensionPositionNode: - """Compare a dimension's coordinates against one named by *position*. + """Compare where a row sits along a dimension against a position. - ``where: "snapshot == index(snapshot, 0)"`` — the boundary of a recurrence - named by where it sits rather than by the label that happens to be there, - so the clause survives the index being relabelled. Negative counts from - the end, ``-1`` being the last. + ``where: "position(snapshot) == 0"`` — the boundary of a recurrence named + by where it sits rather than by the label that happens to be there, so the + clause survives the index being relabelled. Negative counts from the end, + ``-1`` being the last. + + **Both sides are integers**, which is what makes every comparator read one + way. Naming the coordinate *at* a position and comparing coordinates + against it left an ordering meaning either that or a comparison of + positions, and the two part company on an axis whose coordinates do not + arrive sorted (#32). With ``by`` it is the boundary of *each group* the lookup makes — - ``index(snapshot, 0, by=period_of)`` is every period's first snapshot, and - a row reads its own group's, the broadcast ``at(by=)`` already defines. + ``position(snapshot, by=period_of) == 0`` is every period's first snapshot, + and a row reads its own group's, the broadcast ``at(by=)`` already defines. Resolved rather than lowered to a literal: which label sits at a position is a property of the *data*, so the position travels and each lane reads @@ -244,35 +250,17 @@ def _bare(value: float | str) -> float | str: return str(value) if isinstance(value, _Quoted) else value -def _position(tokens: pp.ParseResults) -> _Position: - """``index(dim, i)`` off the tokens the grammar captured, ``by=`` included.""" - by = str(tokens[2]) if len(tokens) > 2 else None - return _Position(str(tokens[0]), int(cast('float', tokens[1])), by) - - -@dataclass(frozen=True) -class _Position: - """``index(dim, i)`` as the grammar saw it, before any name is checked. - - Like :class:`_Quoted` this lives between the grammar and the comparison's - parse action: which dimension the left-hand side names is resolution's - business, so the triple travels only that far. - """ - - dimension: str - at: int - by: str | None = None - +def _position_comparison(tokens: pp.ParseResults) -> UnresolvedPositionNode: + """``position(dim[, by=lookup]) i`` off the tokens the grammar captured.""" + *call, op, at = tokens + dimension, by = call[0], call[1] if len(call) > 1 else None + return UnresolvedPositionNode( + str(dimension), cast('PredicateOperator', op), int(cast('float', at)), None if by is None else str(by) + ) -def _comparison(name: str, op: Any, value: Any) -> UnresolvedComparisonNode | UnresolvedPositionNode: - """The comparison node one right-hand side asks for. - A position is its own node from the start because it is the one right-hand - side that is neither a literal nor a name — nothing downstream could tell - it from a parameter called ``index``. - """ - if isinstance(value, _Position): - return UnresolvedPositionNode(name, op, value.dimension, value.at, value.by) +def _comparison(name: str, op: Any, value: Any) -> UnresolvedComparisonNode: + """The comparison node a name-against-a-literal right-hand side asks for.""" return UnresolvedComparisonNode(name, op, _bare(value), quoted=isinstance(value, _Quoted)) @@ -305,25 +293,33 @@ def _build_where_grammar() -> pp.ParserElement: ) grouped_by = pp.Suppress(',') + pp.Suppress(pp.Keyword('by')) + pp.Suppress('=') + name - index_call = ( - pp.Suppress(pp.Keyword('index')) - + pp.Suppress('(') - + name - + pp.Suppress(',') - + integer - + pp.Optional(grouped_by) - + pp.Suppress(')') - ).set_parse_action(_position) - comparator = pp.one_of('<= >= == != < >') - comparison = (name + comparator + (index_call | number | quoted | name)).set_parse_action( + + # Leads the `atom` alternation below: `position` would otherwise be taken + # for a bare name. See `DimensionPositionNode` for why it converts on the + # left rather than naming the coordinate at a position (#32). + position_call = ( + pp.Suppress(pp.Keyword('position')) + pp.Suppress('(') + name + pp.Optional(grouped_by) + pp.Suppress(')') + ) + position_comparison = (position_call + comparator + integer).set_parse_action(_position_comparison) + + comparison = (name + comparator + (number | quoted | name)).set_parse_action( # pyrefly: ignore[implicit-any-lambda] lambda t: _comparison(t[0], t[1], t[2]) ) # pyrefly: ignore[implicit-any-lambda] existence = name.copy().set_parse_action(lambda t: UnresolvedNameNode(t[0])) - atom = true_lit | false_lit | comparison | existence | (pp.Suppress('(') + where_expr + pp.Suppress(')')) + # `position_comparison` leads: it starts with a keyword that `existence` + # would otherwise take for a bare name, and `comparison` for a parameter. + atom = ( + true_lit + | false_lit + | position_comparison + | comparison + | existence + | (pp.Suppress('(') + where_expr + pp.Suppress(')')) + ) NOT = pp.CaselessKeyword('NOT').suppress() # pyrefly: ignore[implicit-any-lambda] @@ -360,6 +356,16 @@ def fold(tokens: pp.ParseResults) -> Any: _WHERE_GRAMMAR = _build_where_grammar() +#: The spelling this grammar dropped, and its rewrite (#32). A retired syntax +#: speaks before the generic mismatch, the same way a retired kwarg does in +#: `operators.call_shape_error`: "Expected end of text, found '('" is what every +#: model written against the old spelling would otherwise get. +_INDEX_CALL = re.compile(r'\bindex\s*\(') +_INDEX_REWRITE = ( + "\n\n index() is now position(), and converts on the left: write 'position(dim) == i' " + "for 'dim == index(dim, i)', and 'position(dim, by=lookup) == i' for the grouped form." +) + def parse_where(text: str) -> WhereNode: """Parse a where string into an AST. @@ -371,5 +377,7 @@ def parse_where(text: str) -> WhereNode: result = _WHERE_GRAMMAR.parse_string(text, parse_all=True) except pp.ParseException as e: msg = f'Failed to parse where string: {text!r}\n{e}' + if _INDEX_CALL.search(text): + msg += _INDEX_REWRITE raise SchemaError(msg) from e return cast('WhereNode', result[0]) diff --git a/tests/test_parser.py b/tests/test_parser.py index 3de78215..7b7fd820 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -12,6 +12,7 @@ import pytest +from math_spec.errors import SchemaError from math_spec.expression_parser import ( BinaryOperatorNode, ComparisonNode, @@ -28,6 +29,7 @@ OrNode, UnresolvedComparisonNode, UnresolvedNameNode, + UnresolvedPositionNode, parse_where, ) @@ -146,3 +148,51 @@ def test_a_quoted_right_hand_side_is_a_label(text, value, quoted): assert isinstance(node, UnresolvedComparisonNode) assert node.value == value assert node.quoted is quoted + + +@pytest.mark.parametrize( + ('text', 'op', 'position', 'by'), + [ + ('position(snapshot) == 0', '==', 0, None), + ('position(snapshot) != 0', '!=', 0, None), + ('position(snapshot) > 0', '>', 0, None), + ('position(snapshot) <= -2', '<=', -2, None), + ('position(snapshot) == -1', '==', -1, None), + ('position(snapshot, by=period_of) == 0', '==', 0, 'period_of'), + ], + ids=['first', 'not first', 'after the first', 'band from the back', 'last', 'grouped'], +) +def test_position_converts_a_dimension_to_where_a_row_sits(text, op, position, by): + """`position(dim)` is the left-hand side, so every comparator reads one way (#32).""" + node = parse_where(text) + assert isinstance(node, UnresolvedPositionNode) + assert node.dimension == 'snapshot' + assert node.op == op + assert node.position == position + assert node.by == by + + +def test_a_position_is_not_confused_with_a_name(): + """`position` leads the alternation, so it is not read as a bare name.""" + assert isinstance(parse_where('position(t) == 0 AND p_max > 0'), AndNode) + + +def test_a_coordinate_comparison_is_still_a_value_comparison(): + """The other half of #32: comparing the dimension itself is unchanged.""" + node = parse_where("snapshot > '2030-01-01'") + assert isinstance(node, UnresolvedComparisonNode) + assert node.value == '2030-01-01' + + +def test_the_old_index_spelling_names_its_rewrite(): + """`index(dim, i)` is what every model wrote before #32, so this failure is read most.""" + with pytest.raises(SchemaError) as excinfo: + parse_where('snapshot == index(snapshot, 0)') + assert 'index() is now position()' in str(excinfo.value) + assert "write 'position(dim) == i'" in str(excinfo.value) + + +def test_an_unrelated_parse_failure_says_nothing_about_positions(): + with pytest.raises(SchemaError) as excinfo: + parse_where('p_max >') + assert 'position()' not in str(excinfo.value) diff --git a/tests/test_validation.py b/tests/test_validation.py index 83cd6d88..b3393d08 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -11,7 +11,10 @@ import pytest +from math_spec.errors import LanguageError +from math_spec.resolution import Namespace, where_of from math_spec.validation import load_model, validate_expressions +from math_spec.where_parser import DimensionPositionNode if TYPE_CHECKING: from math_spec.model import Model @@ -293,3 +296,62 @@ def test_the_version_gates_no_behaviour(self): bare = load_model(self._model()) declared = load_model(self._model(version=0)) assert bare.model_dump(exclude={'version'}) == declared.model_dump(exclude={'version'}) + + +class TestPositionResolves: + """`position(dim)` — the conversion #32 put on the left-hand side. + + The dimension has to be one, and a `by=` has to be a lookup over *that* + dimension: the groups are its target's labels, and a lookup over anything + else carries no row for a position to be a position in. + """ + + @staticmethod + def _schema() -> Model: + return load_model( + { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'period': {'dtype': 'int', 'values': [2030, 2040]}}, + 'lookups': { + 'period_of': {'over': 'snapshot', 'into': 'period'}, + 'starts_at': {'over': 'period', 'into': 'snapshot'}, + }, + 'parameters': {'load': {'dims': ['snapshot']}}, + 'variables': {'p': {'foreach': ['snapshot']}}, + } + ) + + @pytest.mark.parametrize( + ('mask', 'position', 'by'), + [ + ('position(snapshot) == 0', 0, None), + ('position(snapshot) > 0', 0, None), + ('position(snapshot) == -1', -1, None), + ('position(snapshot) < -2', -2, None), + ('position(snapshot, by=period_of) == 0', 0, 'period_of'), + ], + ids=['first', 'after the first', 'last', 'before the final two', 'first of each period'], + ) + def test_it_resolves(self, mask: str, position: int, by: str | None): + schema = self._schema() + node = where_of(mask, Namespace.of(schema), 'the mask') + assert isinstance(node, DimensionPositionNode) + assert node.name == 'snapshot' + assert node.position == position + assert node.by == by + + @pytest.mark.parametrize( + ('mask', 'fragments'), + [ + ('position(load) == 0', ["counts along a dimension's coordinates", "'load' is a parameter"]), + ('position(nope) == 0', ["'nope' is not declared"]), + ('position(snapshot, by=load) == 0', ['groups by', '``by=`` takes a lookup']), + ('position(snapshot, by=starts_at) == 0', ["along 'snapshot'", "lookup over 'period'"]), + ], + ids=['a parameter', 'undeclared', 'by= is not a lookup', 'by= is over another dim'], + ) + def test_it_refuses(self, mask: str, fragments: list[str]): + schema = self._schema() + with pytest.raises(LanguageError) as excinfo: + where_of(mask, Namespace.of(schema), 'the mask') + for fragment in fragments: + assert fragment in str(excinfo.value) diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index ddaf395f..e4840a01 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -9,7 +9,7 @@ \paragraph{Sets} \begin{description} -\item[$\mathcal{T}$] index $t$ --- \texttt{snapshot} with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ +\item[$\mathcal{T}$] index $t$ --- \texttt{snapshot} (\texttt{int} coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ \item[$\mathcal{G}$] index $g$ --- \texttt{generator} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ \item[$\mathcal{B}$] index $b$ --- \texttt{bus} with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ \item[$\mathcal{Z}$] index $z$ --- \texttt{zone} @@ -56,6 +56,12 @@ \noindent $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (\texttt{shift(by=lookup)}), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +\noindent $\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order --- the order \texttt{shift} walks, not the order labels sort in --- counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. + +\noindent $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +\noindent $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it --- $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. + \paragraph{Objective} \begin{align} && \max & \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom} @@ -82,7 +88,8 @@ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathrm{budget} && \forall\, g \in \mathcal{G} \,:\, \mathrm{cost}_{g} \text{ is defined} \\ \text{running} && \theta_{b} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ -\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right) \\ +\text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right) \\ +\text{last} && \mathit{on}_{t,g} & = 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ \text{efficiency} && p_{t,g} & \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index c3334bab..746cea9d 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -6,7 +6,7 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | Symbol | Meaning | |---|---| -| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | +| $\mathcal{T}$ | index $t$ — `snapshot` (`int` coordinates) with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | | $\mathcal{G}$ | index $g$ — `generator` with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\enspace \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ | | $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | | $\mathcal{Z}$ | index $z$ — `zone` | @@ -54,6 +54,12 @@ $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leav $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, not the order labels sort in — counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. + +$\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. + +$\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. + #### Objective $$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ @@ -138,7 +144,11 @@ $$\theta_{b} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T}, **`first`** -$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right)$$ +$$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right)$$ + +**`last`** + +$$\mathit{on}_{t,g} = 0 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right)$$ **`northern`** diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 477f2862..35113da2 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -152,8 +152,12 @@ constraints: expression: theta <= load first: # a position in a dimension, and the same position within a group foreach: [snapshot, generator] - where: "snapshot == index(snapshot, 0) OR snapshot == index(snapshot, 0, by=season_of)" + where: "position(snapshot) == 0 OR position(snapshot, by=season_of) == 0" expression: on == 1 + last: # the same two counted from the end, which print against a size rather than as themselves + foreach: [snapshot, generator] + where: "position(snapshot) == -1 OR position(snapshot, by=season_of) == -1" + expression: on == 0 northern: # a lookup compared to a label, to another lookup, and to nothing foreach: [snapshot, bus] where: "zone_of == 'north' AND zone_of != area_of AND zone_of" diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index ca55d692..d5336a96 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -4,7 +4,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 {net} \~ ^ \\ \*star\* \@ref \ == Sets -/ $cal(T)$: index $t$ --- `snapshot` with $upright("season_of"): cal(T) arrow.r cal(S)$ +/ $cal(T)$: index $t$ --- `snapshot` (`int` coordinates) with $upright("season_of"): cal(T) arrow.r cal(S)$ / $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_tech"): cal(G) arrow.r cal(E)$ carrying label $upright("tech")$ / $cal(B)$: index $b$ --- `bus` with $upright("zone_of"): cal(B) arrow.r cal(Z), upright("area_of"): cal(B) arrow.r cal(Z)$ / $cal(Z)$: index $z$ --- `zone` @@ -46,6 +46,12 @@ $t minus.square_(v) k$ denotes translation with $v$ standing where index $t-k$ l $t minus.o^(upright("lookup")(t)) k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t minus.square_(v)^(upright("lookup")(t)) k$ is both at once. +$upright("pos")(t)$ denotes where index $t$ sits along its dimension's own order --- the order `shift` walks, not the order labels sort in --- counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $upright("pos")(t)$ against positions. + +$upright("pos")_(upright("lookup")(t))(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $cal(T)_(upright("lookup")(t))$ is the group it lands in, and that group has a first position of its own. + +$abs(cal(T))$ denotes the size of the set being counted along, and a position counted from the end prints against it --- $abs(cal(T)) - 1$ is the last position, one less than the size because the first is $0$. + == Objective #set math.equation(numbering: "(1)") $ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot upright("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) dot upright("growth")^(upright("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("p")^(upright("max"))_(g) - italic("reserve") - italic("headroom") $ @@ -71,7 +77,8 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") \ upright("scalar") & italic("units")_(g) & <= upright("budget") & forall g in cal(G) colon upright("cost")_(g) upright(" is defined") \ upright("running") & theta_(b) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ - upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (t = upright("index")(cal(T), 0) or t = upright("index")(cal(T), 0, upright("season_of")(t))) \ + upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")_(upright("season_of")(t))(t) = 0) \ + upright("last") & italic("on")_(t,g) & = 0 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = abs(cal(T)) - 1 or upright("pos")_(upright("season_of")(t))(t) = abs(cal(T)_(upright("season_of")(t))) - 1) \ upright("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ upright("efficiency") & p_(t,g) & <= upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 1172580b..adf0aa46 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -382,6 +382,75 @@ def test_the_legend_explains_wraparound_only_when_it_is_used(fmt: Format): assert 'cyclic translation' not in typeset(DISPATCH, fmt) +def _selected(mask: str) -> dict[str, Any]: + """One constraint carrying *mask*, over a dimension a lookup groups.""" + return { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'season': {'dtype': 'str'}}, + 'lookups': {'season_of': {'over': 'snapshot', 'into': 'season'}}, + 'variables': {'soc': {'foreach': ['snapshot'], 'bounds': {'lower': 0}}}, + 'constraints': {'seed': {'foreach': ['snapshot'], 'where': mask, 'expression': 'soc == 0'}}, + } + + +@EVERY_FORMAT +def test_a_position_from_the_end_prints_against_the_size(fmt: Format): + """``-1`` is not a position, and the page has already said so. + + The legend gives the order ``0`` to one end and the size less one to the + other, so an equation asserting a position *is* ``-1`` has no solution + under the only reading offered for it — Python's index sugar, read as + math. The sign is known where it prints, so the page can say what the file + means instead. + """ + text = typeset(_selected('position(snapshot) == -1'), fmt) + assert f'{fmt.cardinality(fmt.script("T"))} {fmt.operators["minus"]} 1' in text + assert f'{fmt.operators["equal"]} -1' not in text + + +@EVERY_FORMAT +def test_a_grouped_position_rides_a_subscript_rather_than_a_second_argument(fmt: Format): + """The group is a modifier — which order is counted — not another position. + + As ``pos(t, season_of(t))`` the second argument sits where a reader of the + first one expects an integer, and nothing says it means "within". + """ + text = typeset(_selected('position(snapshot, by=season_of) == 0'), fmt) + applied = fmt.apply(fmt.upright('season_of'), 't') + assert fmt.apply(fmt.subscript(fmt.operators['position'], [applied]), 't') in text + + +@EVERY_FORMAT +def test_the_legend_explains_a_position_only_where_one_prints(fmt: Format): + """Each positional symbol is introduced where it is used, and nowhere else. + + The first note is the one the page cannot go without: a reader arrives + from papers whose index *is* the ordinal, so a page printing both + ``pos(t) = 0`` and ``t >= 3`` has to say once which of the two is the + coordinate. + """ + plain = typeset(_selected('position(snapshot) == 0'), fmt) + assert 'against positions' in plain + assert 'against positions' not in typeset(DISPATCH, fmt) + assert 'counts within the group' not in plain, 'no grouped position printed' + assert 'counted from the end' not in plain, 'no position counted from the end printed' + assert 'counts within the group' in typeset(_selected('position(snapshot, by=season_of) == 0'), fmt) + assert 'counted from the end' in typeset(_selected('position(snapshot) == -1'), fmt) + + +@EVERY_FORMAT +def test_a_dimension_compared_against_a_number_says_what_its_coordinates_are(fmt: Format): + """``t >= 3`` is the line the convention this notation inverts reads wrong. + + A comparison against a label that is a *number* is the one that can be + taken for a position, so the legend places it: every other coordinate + prints as prose and could not be read as one. + """ + text = typeset(_selected('snapshot >= 3'), fmt) + assert f'({fmt.mono("int")} coordinates)' in text + assert f'({fmt.mono("str")} coordinates)' not in text, 'season is compared against nothing' + assert 'coordinates)' not in typeset(_selected('position(snapshot) == 0'), fmt) + + @EVERY_FORMAT def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(fmt: Format): """``---`` is TeX's em-dash ligature and Typst's, and nothing in Markdown.