diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 8856e925..96a6aa7e 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` | @@ -116,6 +116,12 @@ $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped. +$\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` @@ -377,7 +383,20 @@ first: expression: on == 1 ``` -$$\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}(t, \mathrm{season\_of}(t)) = 0 \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` @@ -645,7 +664,7 @@ $$\mathit{op\_cost}_{t,g} \cdot \left( \mathit{bp}^{\mathrm{x}}_{g,b} - \mathit{ $$p_{t,g} \ge \mathit{bp}^{\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 \mathit{bp}^{\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) = -1$$ +$$p_{t,g} \le \mathit{bp}^{\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/typeset/__init__.py b/src/math_spec/typeset/__init__.py index c756a5fb..e8f2f004 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -113,6 +113,7 @@ def typeset( if legend: blocks += [fmt.glossary(group.title, group.entries) for group in walk.glossaries()] 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 65aa6504..46a6182d 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -180,6 +180,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 d820064d..8eb6ad01 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -119,6 +119,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 cdeb6afa..c78dc97b 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -80,6 +80,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 238f9329..af9ad0fb 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -129,6 +129,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 0f4353dc..ee8b3a75 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -214,7 +214,9 @@ class Walk: """Walks a validated schema, emitting :class:`Line`s in one format. Stateful only in what it has *noticed* — which edge policies appeared, - which the legend needs to explain the symbols they print. + which positional forms printed, and which dimensions were compared against + a coordinate that is a number; all three are things the legend has to + explain once the equations print them. """ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fmt: Format) -> None: @@ -223,6 +225,8 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.symbols = symbols self.format = fmt self.policies: set[str] = set() + self.positions: set[str] = set() + self.numeric_coordinates: set[str] = set() def op(self, name: str) -> str: return self.format.operators[name] @@ -458,6 +462,11 @@ 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): @@ -465,7 +474,8 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: None if node.by is None else self.format.apply(self.format.upright(node.by), ctx.subscript(node.name)) ) place = self.position(ctx.subscript(node.name), grouping) - return f'{place} {self.op(_PREDICATES[node.op])} {self.number(node.position)}', 2 + 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)) @@ -506,12 +516,36 @@ def position(self, index: str, grouping: str | None = None) -> str: 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 prints as a second argument so - the group a position is counted within is visible where the position - is. + 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. """ - inner = index if grouping is None else f'{index}, {grouping}' - return self.format.apply(self.op('position'), inner) + self.positions.add('grouped' if grouping is not None else 'plain') + symbol = self.op('position') + if grouping is not None: + 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: parts = [self.where(n, ctx, need=1) for n in nodes if n is not None] @@ -677,6 +711,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( [ @@ -716,3 +755,44 @@ def translation_notes(self) -> list[str]: f'at that boundary is built and carries {self.format.math("v")} rather than being dropped.' ) 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/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index b29a10fe..dfe404b8 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} @@ -51,6 +51,12 @@ \noindent $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (\texttt{shift(edge=v)}), so the row at that boundary is built and carries $v$ rather than being dropped. +\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 \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} + -\mathit{headroom} @@ -77,7 +83,8 @@ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathit{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathit{budget} && \forall\, g \in \mathcal{G} \,:\, \mathit{cost}_{g} \text{ is defined} \\ \text{running} && \mathit{theta}_{b} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathit{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( \mathrm{pos}(t) = 0 \vee \mathrm{pos}(t, \mathrm{season\_of}(t)) = 0 \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 \mathit{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{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index 144bcac9..54e69633 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` | @@ -49,6 +49,12 @@ $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped. +$\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 \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} + -\mathit{headroom}$$ @@ -133,7 +139,11 @@ $$\mathit{theta}_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mat **`first`** -$$\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}(t, \mathrm{season\_of}(t)) = 0 \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 a0f6ebf9..d24cec89 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -153,6 +153,10 @@ constraints: foreach: [snapshot, generator] 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 6e3fc5d7..f7653794 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` @@ -41,6 +41,12 @@ $t minus.o k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $t minus.square_(v) k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped. +$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 italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) dot italic("growth")^(italic("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p^(upright("max"))_(g) - italic("reserve") + -italic("headroom") $ @@ -66,7 +72,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) & <= italic("budget") \ upright("scalar") & italic("units")_(g) & <= italic("budget") & forall g in cal(G) colon italic("cost")_(g) upright(" is defined") \ upright("running") & italic("theta")_(b) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon italic("theta")_(b) upright(" exists") and t >= 3 \ - upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")(t, upright("season_of")(t)) = 0) \ + 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) & <= italic("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("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 61fa467e..56ffdfc6 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -355,6 +355,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.