From 5d43331d9eb1527e728bc5dd2a7a159d1a543d92 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:24:49 +0200 Subject: [PATCH 1/2] feat: sum_forward, the window sum_back already was pointed the other way `sum_back` reaches back along an axis; nothing reached forward. With a literal width that is a composition and belongs in `macros:` --- but the composition is not the same operator turned around: - `shift` past the end drops the rows it vacates, or fills them with a number. A window running off the axis is short, not empty, and the row being written is always inside its own window, so none is ever lost. - A width that is a parameter has no forward spelling at all. It would need `offset=-(p-1)`, a negative named offset, which the language refuses so a translation's direction is never something the data decides. Per-entity forward windows --- a minimum down time, a notice period --- were unsayable. So it is one operator pointed two ways, and it lands as one branch: the width, the edge policy, the summation and the pullback are all shared, and only which side of the difference carries the prime tells them apart. `0 <= t - t' < n` becomes `0 <= t' - t < n`, and nothing else in the equation moves --- which the golden diff says out loud, being pure addition with no rendered line disturbed. No new node type: an operator is a `FunctionCallNode` and its name, so the public surface that grows is `BUILTIN_NAMES`, by one string. Refs #49 Co-Authored-By: Claude Opus 5 (1M context) --- docs/examples/operators.md | 102 ++++++++++++++++++ docs/reference/language/operators.md | 36 +++++++ docs/reference/notation.md | 24 +++++ examples/operators/sum_forward.yaml | 29 +++++ .../operators/sum_forward_by_parameter.yaml | 29 +++++ examples/operators/sum_forward_wrap.yaml | 29 +++++ src/math_spec/degree.py | 2 +- src/math_spec/dimensions.py | 3 +- src/math_spec/operators.py | 7 ++ src/math_spec/typesetting/walk.py | 17 ++- tests/typesetting/golden/latex.out | 2 + tests/typesetting/golden/markdown.out | 8 ++ tests/typesetting/golden/model.yaml | 6 ++ tests/typesetting/golden/typst.out | 2 + tools/spec_math.py | 3 + 15 files changed, 295 insertions(+), 4 deletions(-) create mode 100644 examples/operators/sum_forward.yaml create mode 100644 examples/operators/sum_forward_by_parameter.yaml create mode 100644 examples/operators/sum_forward_wrap.yaml diff --git a/docs/examples/operators.md b/docs/examples/operators.md index a0bdfb56..a9ee0e0d 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -427,6 +427,108 @@ objective: { sense: minimize, expression: sum(on) } ``` $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_forward(array, over=dim, within=n)` + +`examples/operators/sum_forward.yaml` + +```yaml +description: >- + A leading window of a fixed width: a unit that shuts down in the next three + hours is running now, so it runs its minimum time *before* it may stop. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=3) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < 3} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_forward(array, over=dim, within=p)` + +`examples/operators/sum_forward_by_parameter.yaml` + +```yaml +description: >- + A leading window whose width is data — `within:` names an integer parameter, + so each unit runs for its *own* minimum time before it may stop. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=min_up) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_forward(array, over=dim, within=p, edge='wrap')` + +`examples/operators/sum_forward_wrap.yaml` + +```yaml +description: >- + A leading window on a representative period that repeats, so the window at + the last hour reaches around into the first. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=min_up, edge='wrap') <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' \ominus h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index aa07dddd..433d45b0 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -25,6 +25,9 @@ Dimension arguments are name-checked at load time, so | `sum_back(array, over=dim, within=n)` | the sum of the last `n` positions along `dim`, ending at _t_ | | `sum_back(array, over=dim, within=p)` | `p` an integer parameter: each entity gets **its own** window length | | `sum_back(array, over=dim, within=p, edge='wrap')` | the window reaches around the axis rather than stopping short at its start | +| `sum_forward(array, over=dim, within=n)` | the sum of the next `n` positions along `dim`, starting at _t_ | +| `sum_forward(array, over=dim, within=p)` | `p` an integer parameter: each entity gets **its own** window length | +| `sum_forward(array, over=dim, within=p, edge='wrap')` | the window reaches around the axis rather than stopping short at its end | `array` is any expression of the right dim set, so these read a **parameter** as readily as a variable. Each row as the typesetter prints it is @@ -145,6 +148,36 @@ there is a load error, because adding a constant is something the expression can say for itself. `edge='wrap'` makes the window reach around instead, which is what a representative period that repeats asks for. +## `sum_forward` + +`sum_forward(x, over=d, within=n)` is [`sum_back`](#sum_back) the other way: the +sum of the next `n` positions along `d`, **starting** at the one being written. +Every rule above holds unchanged — the dimension survives, a width of `1` is `x` +itself, `within=` may name an integer parameter under the same two conditions, +and `edge=` takes `'wrap'` or nothing. + +Only the edge it runs off changes. A window that reaches past the *end* of the +axis is short, not empty, so again no row is lost; `edge='wrap'` reaches around +into the start instead. + +```yaml +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=min_up) <= on +``` + +As math the two are transposes — the primed index moves to the other side of the +subtraction, $0 \le t - t' < n$ becoming $0 \le t' - t < n$, and nothing else in +the equation moves. + +A fixed width composes out of `shift` in either direction, but a forward window +does not reduce to one: `shift` past the end **drops** the rows it vacates or +fills them with a number, where a short window keeps them. And a width that is a +parameter has no forward spelling at all, since it would need a negative named +offset — which the language refuses, so that a translation's direction is never +something the data decides ([`shift`](#shift)). + ## `shift` `shift(x, over=d, offset=n)` reaches along an axis: it is the value at _t−n_, in @@ -360,6 +393,9 @@ language is rendered the same way, on one page: | `sum_back(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_back(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_back(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_forward(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < 3} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_forward(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_forward(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' \ominus h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound — terms translated past the edge are simply absent. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 7bcea9f3..0fd04e59 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -288,6 +288,30 @@ history: $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +#### `horizon` + +a leading window: the same three positions, counted the other way + +```yaml +horizon: + foreach: [snapshot, generator] + expression: sum_forward(on, over=snapshot, within=3) <= units +``` + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' - t < 3} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### `outlook` + +the same leading window, its width in the data and its edge wrapped + +```yaml +outlook: + foreach: [snapshot, generator] + expression: sum_forward(on, over=snapshot, within=min_up, edge='wrap') <= units +``` + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### `pullback` at(), which re-indexes through a lookup instead of an offset diff --git a/examples/operators/sum_forward.yaml b/examples/operators/sum_forward.yaml new file mode 100644 index 00000000..a1756990 --- /dev/null +++ b/examples/operators/sum_forward.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A leading window of a fixed width: a unit that shuts down in the next three + hours is running now, so it runs its minimum time *before* it may stop. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=3) <= on + +objective: { sense: minimize, expression: sum(on) } diff --git a/examples/operators/sum_forward_by_parameter.yaml b/examples/operators/sum_forward_by_parameter.yaml new file mode 100644 index 00000000..eb4ec828 --- /dev/null +++ b/examples/operators/sum_forward_by_parameter.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A leading window whose width is data — `within:` names an integer parameter, + so each unit runs for its *own* minimum time before it may stop. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=min_up) <= on + +objective: { sense: minimize, expression: sum(on) } diff --git a/examples/operators/sum_forward_wrap.yaml b/examples/operators/sum_forward_wrap.yaml new file mode 100644 index 00000000..71b356f6 --- /dev/null +++ b/examples/operators/sum_forward_wrap.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A leading window on a representative period that repeats, so the window at + the last hour reaches around into the first. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + stopped: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + runs_before_it_stops: + foreach: [unit, hour] + expression: sum_forward(stopped, over=hour, within=min_up, edge='wrap') <= on + +objective: { sense: minimize, expression: sum(on) } diff --git a/src/math_spec/degree.py b/src/math_spec/degree.py index 92519ff9..41df5624 100644 --- a/src/math_spec/degree.py +++ b/src/math_spec/degree.py @@ -307,7 +307,7 @@ def _multi_term(node: ExpressionNode) -> bool: #: The operators that fold several coordinates onto one, and so turn a term #: into a sum of terms. ``at`` and ``shift`` re-index and are not here: they #: move a term, leaving one term where there was one. -_REDUCTIONS = frozenset({'sum', 'sum_back'}) +_REDUCTIONS = frozenset({'sum', 'sum_back', 'sum_forward'}) def check_expression(node: ExpressionNode, context: str, *, ceiling: int = 1) -> None: diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index e252b357..683bec9a 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -26,6 +26,7 @@ plus the dim l is over shift(x, over=d, offset=n) -> same dims as x; error if x has no d sum_back(x, over=d, within=n) -> same dims as x; error if x has no d + sum_forward(x, over=d, within=n) -> the same, the other way and at the declaration level:: @@ -214,7 +215,7 @@ def _dims_call( ) return (inner - set(by.into)) | {by.dimension} - if node.name in ('shift', 'sum_back'): + if node.name in ('shift', 'sum_back', 'sum_forward'): inner = _dims(node.args[0], schema, context) over = node.kwargs['over'] assert isinstance(over, DimensionNode) diff --git a/src/math_spec/operators.py b/src/math_spec/operators.py index 4f2ed72a..721ba9cc 100644 --- a/src/math_spec/operators.py +++ b/src/math_spec/operators.py @@ -102,6 +102,13 @@ def optional(self) -> frozenset[str]: required_value_kwargs=('within',), edge_kwargs=('edge',), ), + 'sum_forward': Builtin( + 1, + "sum_forward(, over=, within=[, edge='wrap'])", + dimension_kwargs=('over',), + required_value_kwargs=('within',), + edge_kwargs=('edge',), + ), 'shift': Builtin( 1, "shift(, over=, offset=[, edge='wrap'|][, by=])", diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 843615b4..37f27481 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -80,6 +80,12 @@ 'edge': ('edge_minus', 'edge_plus'), } +#: The window operators, and whether the row being written leads the difference +#: their domain counts. That order is the whole of what separates them: +#: ``sum_back`` counts ``t - t'``, outward from the row, and ``sum_forward`` +#: counts ``t' - t``, toward it. +_WINDOWS = {'sum_back': True, 'sum_forward': False} + PRIME = "'" @@ -355,6 +361,11 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: substitution appears at the leaves; falling through to the summation would render it as a sum over the fine dim, silently the wrong equation. + + The two windows are one branch because they are one operator pointed + two ways: everything about them — the width, the edge policy, the + summation, the pullback — is shared, and only which side of the + subtraction carries the prime tells them apart. """ if node.name == 'shift': dim = node.kwargs['over'] @@ -367,13 +378,15 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: step = replace(step, within=partition.names[0]) return self._arithmetic(node.args[0], ctx.translated(dim.name, step)) - if node.name == 'sum_back': + if node.name in _WINDOWS: over = node.kwargs['over'] assert isinstance(over, DimensionNode) step = _Step(1, 'wrap' if isinstance(node.kwargs.get('edge'), EdgeNode) else 'plain') self.policies.add(step.policy) source = f'{self.symbols.index[over.name]}{PRIME}' - lag = f'{ctx.subscript(over.name)} {self.translation(step)} {source}' + written = ctx.subscript(over.name) + left, right = (written, source) if _WINDOWS[node.name] else (source, written) + lag = f'{left} {self.translation(step)} {right}' domain = ( f'{source} {self.op("in")} {self.symbols.set[over.name]} {self.op("such_that")} ' f'0 {self.op("le")} {lag} {self.op("lt")} {self._width(node.kwargs["within"])}' diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index e4840a01..fc4630b1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -81,6 +81,8 @@ \text{held\_in\_season} && p_{t,g} & \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{window} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{history} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{horizon} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t' - t < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{outlook} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{pullback} && \mathit{spill}_{t} & \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{grouped\_twice} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} & \le \mathrm{tech\_cap}_{b,e} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B},\ e \in \mathcal{E} \\ \text{pulled\_back\_twice} && \mathit{units}_{g} & \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} && \forall\, g \in \mathcal{G} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 746cea9d..d2e8e683 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -114,6 +114,14 @@ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t - t' < 3} \mathit{on}_{ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +**`horizon`** + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' - t < 3} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`outlook`** + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + **`pullback`** $$\mathit{spill}_{t} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 35113da2..10bb7576 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -124,6 +124,12 @@ constraints: history: # the same window, its width in the data and its edge wrapped foreach: [snapshot, generator] expression: sum_back(on, over=snapshot, within=min_up, edge='wrap') <= units + horizon: # a leading window: the same three positions, counted the other way + foreach: [snapshot, generator] + expression: sum_forward(on, over=snapshot, within=3) <= units + outlook: # the same leading window, its width in the data and its edge wrapped + foreach: [snapshot, generator] + expression: sum_forward(on, over=snapshot, within=min_up, edge='wrap') <= units pullback: # at(), which re-indexes through a lookup instead of an offset foreach: [snapshot, bus] expression: spill <= at(zone_cap, by=zone_of) diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index d5336a96..5ec3ae24 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -70,6 +70,8 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("held_in_season") & p_(t,g) & <= p_(t minus.square_(0)^(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ upright("window") & sum_(t' in cal(T) colon 0 <= t - t' < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("history") & sum_(t' in cal(T) colon 0 <= t minus.o t' < upright("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ + upright("horizon") & sum_(t' in cal(T) colon 0 <= t' - t < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ + upright("outlook") & sum_(t' in cal(T) colon 0 <= t' minus.o t < upright("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("pullback") & italic("spill")_(t) & <= upright("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ upright("grouped_twice") & sum_(g in cal(G) colon upright("gen_bus")(g) = b and upright("gen_tech")(g) = e) p_(t,g) & <= upright("tech_cap")_(b,e) & forall t in cal(T), b in cal(B), e in cal(E) \ upright("pulled_back_twice") & italic("units")_(g) & <= upright("tech_cap")_(upright("gen_bus")(g),upright("gen_tech")(g)) & forall g in cal(G) \ diff --git a/tools/spec_math.py b/tools/spec_math.py index f9ad41e6..ab6efb26 100644 --- a/tools/spec_math.py +++ b/tools/spec_math.py @@ -57,6 +57,9 @@ 'sum_back(array, over=dim, within=n)': 'sum_back', 'sum_back(array, over=dim, within=p)': 'sum_back_by_parameter', "sum_back(array, over=dim, within=p, edge='wrap')": 'sum_back_wrap', + 'sum_forward(array, over=dim, within=n)': 'sum_forward', + 'sum_forward(array, over=dim, within=p)': 'sum_forward_by_parameter', + "sum_forward(array, over=dim, within=p, edge='wrap')": 'sum_forward_wrap', } From 22e25ec18cf38490c9fb51780200bef16cd73d51 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:41:44 +0200 Subject: [PATCH 2/2] fix: let a window stop at each group's edge, as its checks already assumed `dimensions.py` partition-checked the windows and `operators.py` said `by=` worked on them, but neither `Builtin` carried `lookup_kwargs`, so `call_shape_error` refused the call before either could speak. Half a feature, checked but unreachable. The half that was missing is the render. Resolution is generic --- it reads `lookup_kwargs` --- so the signature alone makes `by=` arrive as a `LookupNode`; what had to be built is the group on the operator inside the domain, where `shift` carries it on a leaf subscript instead. It is the bare index there, not the subscript in force: the group is a property of the row being written, and a window over a translated operand still asks which group *that row* is in. What asks for it is representative periods. A dozen typical days stand in for a year and their hours are numbered consecutively, but that numbering is storage order, not a timeline --- so an unpartitioned window sums straight across the seam between two samples and returns a schedule coupling days that never touch. Nothing could warn about it, the axis being ordered and the language having no other way to learn the order is not time. Not sayable another way either: the partition is a condition inside the summation's domain, and a `where` masks which rows are built rather than how far one reaches. Closes #57 Co-Authored-By: Claude Opus 5 (1M context) --- docs/examples/operators.md | 72 +++++++++++++++++++ docs/reference/language/operators.md | 47 ++++++++++++ docs/reference/notation.md | 12 ++++ examples/operators/sum_back_partitioned.yaml | 31 ++++++++ .../operators/sum_forward_partitioned.yaml | 31 ++++++++ src/math_spec/operators.py | 16 +++-- src/math_spec/typesetting/walk.py | 17 ++++- tests/typesetting/golden/latex.out | 1 + tests/typesetting/golden/markdown.out | 4 ++ tests/typesetting/golden/model.yaml | 3 + tests/typesetting/golden/typst.out | 1 + tools/spec_math.py | 2 + 12 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 examples/operators/sum_back_partitioned.yaml create mode 100644 examples/operators/sum_forward_partitioned.yaml diff --git a/docs/examples/operators.md b/docs/examples/operators.md index a9ee0e0d..9aa2b3eb 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -428,6 +428,42 @@ objective: { sense: minimize, expression: sum(on) } $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ +### `sum_back(array, over=dim, within=n, by=lookup)` + +`examples/operators/sum_back_partitioned.yaml` + +```yaml +description: >- + A window that stops at each group's edge: representative days are separate + samples rather than consecutive hours, so a window must not reach across the + seam between two of them. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + day: { dtype: str } + +lookups: + day_of: { over: hour, into: day } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_inside_its_day: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=3, by=day_of) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h -^{\mathrm{day\_of}(h)} h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + ### `sum_forward(array, over=dim, within=n)` `examples/operators/sum_forward.yaml` @@ -529,6 +565,42 @@ objective: { sense: minimize, expression: sum(on) } ``` $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' \ominus h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ + +### `sum_forward(array, over=dim, within=n, by=lookup)` + +`examples/operators/sum_forward_partitioned.yaml` + +```yaml +description: >- + A window that stops at each group's edge: representative days are separate + samples rather than consecutive hours, so a window must not reach across the + seam between two of them. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + day: { dtype: str } + +lookups: + day_of: { over: hour, into: day } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_inside_its_day: + foreach: [unit, hour] + expression: sum_forward(started, over=hour, within=3, by=day_of) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' -^{\mathrm{day\_of}(h)} h < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 433d45b0..c694ef1e 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -25,9 +25,11 @@ Dimension arguments are name-checked at load time, so | `sum_back(array, over=dim, within=n)` | the sum of the last `n` positions along `dim`, ending at _t_ | | `sum_back(array, over=dim, within=p)` | `p` an integer parameter: each entity gets **its own** window length | | `sum_back(array, over=dim, within=p, edge='wrap')` | the window reaches around the axis rather than stopping short at its start | +| `sum_back(array, over=dim, within=n, by=lookup)` | the window stops at each group's edge rather than reaching across it | | `sum_forward(array, over=dim, within=n)` | the sum of the next `n` positions along `dim`, starting at _t_ | | `sum_forward(array, over=dim, within=p)` | `p` an integer parameter: each entity gets **its own** window length | | `sum_forward(array, over=dim, within=p, edge='wrap')` | the window reaches around the axis rather than stopping short at its end | +| `sum_forward(array, over=dim, within=n, by=lookup)` | the window stops at each group's edge rather than reaching across it | `array` is any expression of the right dim set, so these read a **parameter** as readily as a variable. Each row as the typesetter prints it is @@ -148,6 +150,49 @@ there is a load error, because adding a constant is something the expression can say for itself. `edge='wrap'` makes the window reach around instead, which is what a representative period that repeats asks for. +### A window that stops at each group's edge + +`by=` partitions the axis the window walks, so it reaches only within the group +the row being written is in. It is the same `by=` as +[`shift`'s](#a-translation-that-stops-at-each-groups-edge), and takes a lookup +**over the dimension being summed over**. + +```yaml +dimensions: + unit: { dtype: str } + hour: { dtype: int } + day: { dtype: str } +lookups: + day_of: { over: hour, into: day } +variables: + started: { foreach: [unit, hour], domain: binary } + on: { foreach: [unit, hour], domain: binary } +constraints: + stays_up_inside_its_day: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=3, by=day_of) <= on +``` + +What asks for this is a model built on **representative periods**. A dozen typical +days stand in for a year, each carrying a weight, and their hours are numbered +consecutively — but that numbering is a storage order, not a timeline. The last +hour of one representative day does not precede the first hour of the next in any +physical sense; they are separate samples. + +An unpartitioned window sums straight across that seam. It builds, it solves, and +the commitment it returns couples two independent days through a boundary that +does not exist — which nothing warns about, because the axis is ordered and the +language has no other way to know the order is not time. + +The `edge=` rules then read one group at a time, exactly as they do for `shift`: +bare, a window at a group's opening is **short**, reaching no further back than +the group starts; `edge='wrap'` closes **each group** onto its own end, which is +what a representative day standing for a day that repeats asks for. + +A coordinate the lookup sends nowhere is in no group, so its window holds only +itself. `sum_forward` partitions identically — the group bounds the direction it +reaches, whichever that is. + ## `sum_forward` `sum_forward(x, over=d, within=n)` is [`sum_back`](#sum_back) the other way: the @@ -393,9 +438,11 @@ language is rendered the same way, on one page: | `sum_back(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_back(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_back(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_back(array, over=dim, within=n, by=lookup)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h -^{\mathrm{day\_of}(h)} h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_forward(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < 3} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_forward(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' - h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | | `sum_forward(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' \ominus h < \mathrm{min\_up}} \mathit{stopped}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_forward(array, over=dim, within=n, by=lookup)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h' -^{\mathrm{day\_of}(h)} h < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound — terms translated past the edge are simply absent. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 0fd04e59..02b615d5 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -312,6 +312,18 @@ outlook: $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +#### `seasonal_window` + +a window partitioned by a lookup: the group rides on the operator + +```yaml +seasonal_window: + foreach: [snapshot, generator] + expression: sum_back(on, over=snapshot, within=3, by=season_of) <= units +``` + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t -^{\mathrm{season\_of}(t)} t' < 3} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### `pullback` at(), which re-indexes through a lookup instead of an offset diff --git a/examples/operators/sum_back_partitioned.yaml b/examples/operators/sum_back_partitioned.yaml new file mode 100644 index 00000000..10f45490 --- /dev/null +++ b/examples/operators/sum_back_partitioned.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A window that stops at each group's edge: representative days are separate + samples rather than consecutive hours, so a window must not reach across the + seam between two of them. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + day: { dtype: str } + +lookups: + day_of: { over: hour, into: day } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_inside_its_day: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=3, by=day_of) <= on + +objective: { sense: minimize, expression: sum(on) } diff --git a/examples/operators/sum_forward_partitioned.yaml b/examples/operators/sum_forward_partitioned.yaml new file mode 100644 index 00000000..f6adea54 --- /dev/null +++ b/examples/operators/sum_forward_partitioned.yaml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A window that stops at each group's edge: representative days are separate + samples rather than consecutive hours, so a window must not reach across the + seam between two of them. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + day: { dtype: str } + +lookups: + day_of: { over: hour, into: day } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_inside_its_day: + foreach: [unit, hour] + expression: sum_forward(started, over=hour, within=3, by=day_of) <= on + +objective: { sense: minimize, expression: sum(on) } diff --git a/src/math_spec/operators.py b/src/math_spec/operators.py index 721ba9cc..d87f853c 100644 --- a/src/math_spec/operators.py +++ b/src/math_spec/operators.py @@ -78,10 +78,10 @@ def optional(self) -> frozenset[str]: #: and a lookup carries its own dimensions, so the sibling kwargs that used to #: restate them (``sum``'s ``over=`` beside ``group_by=``, ``at``'s ``onto=``) #: are gone — what the two-keyword spelling once said, the name's *kind* now -#: says, checked at load. ``by=`` on ``shift`` and -#: ``sum_back`` partitions the axis the operator walks, which is the same -#: lookup in a different position: it says which rows are neighbours, not which -#: group a term lands in. +#: says, checked at load. ``by=`` on ``shift`` and on the two +#: windows partitions the axis the operator walks, which is the same lookup in +#: a different position: it says which rows are neighbours, not which group a +#: term lands in. BUILTINS: dict[str, Builtin] = { 'sum': Builtin( 1, @@ -97,17 +97,21 @@ def optional(self) -> frozenset[str]: ), 'sum_back': Builtin( 1, - "sum_back(, over=, within=[, edge='wrap'])", + "sum_back(, over=, within=[, edge='wrap'][, by=])", dimension_kwargs=('over',), + lookup_kwargs=('by',), required_value_kwargs=('within',), edge_kwargs=('edge',), + optional_kwargs=('by',), ), 'sum_forward': Builtin( 1, - "sum_forward(, over=, within=[, edge='wrap'])", + "sum_forward(, over=, within=[, edge='wrap'][, by=])", dimension_kwargs=('over',), + lookup_kwargs=('by',), required_value_kwargs=('within',), edge_kwargs=('edge',), + optional_kwargs=('by',), ), 'shift': Builtin( 1, diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 37f27481..c1c95a81 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -386,7 +386,10 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: source = f'{self.symbols.index[over.name]}{PRIME}' written = ctx.subscript(over.name) left, right = (written, source) if _WINDOWS[node.name] else (source, written) - lag = f'{left} {self.translation(step)} {right}' + # a partition rides the operator here exactly as it does on a leaf + # translation, and for the same reason: what the group changes is + # where the axis ends, not which coordinate is being written + lag = f'{left} {self.translation(step, self._group(node.kwargs.get("by"), over.name))} {right}' domain = ( f'{source} {self.op("in")} {self.symbols.set[over.name]} {self.op("such_that")} ' f'0 {self.op("le")} {lag} {self.op("lt")} {self._width(node.kwargs["within"])}' @@ -419,6 +422,18 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: domain = self.format.joined([self.membership(d) for d in dims], '') return self.format.summation(domain, self.reduction_body(node.args[0], ctx)), _PRECEDENCE['+'] + def _group(self, by: ArithmeticNode | None, dim: str) -> str: + """A window's ``by=`` as the superscript its operator carries. + + The bare index, not the subscript in force: the group is a property of + the row being written, and a window whose operand is itself translated + still asks which group *that row* is in. + """ + if by is None: + return '' + assert isinstance(by, LookupNode) + return self.format.apply(self.format.upright(by.names[0]), self.symbols.index[dim]) + def _width(self, node: ArithmeticNode) -> str: """``sum_back``'s ``within=``: a number, or a parameter's own symbol. diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index fc4630b1..aacc15d1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -83,6 +83,7 @@ \text{history} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{horizon} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t' - t < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{outlook} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{seasonal\_window} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t -^{\mathrm{season\_of}(t)} t' < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{pullback} && \mathit{spill}_{t} & \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{grouped\_twice} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} & \le \mathrm{tech\_cap}_{b,e} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B},\ e \in \mathcal{E} \\ \text{pulled\_back\_twice} && \mathit{units}_{g} & \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} && \forall\, g \in \mathcal{G} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index d2e8e683..9c159a23 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -122,6 +122,10 @@ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' - t < 3} \mathit{on}_{ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t' \ominus t < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +**`seasonal_window`** + +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t -^{\mathrm{season\_of}(t)} t' < 3} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + **`pullback`** $$\mathit{spill}_{t} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 10bb7576..21bd9ea2 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -130,6 +130,9 @@ constraints: outlook: # the same leading window, its width in the data and its edge wrapped foreach: [snapshot, generator] expression: sum_forward(on, over=snapshot, within=min_up, edge='wrap') <= units + seasonal_window: # a window partitioned by a lookup: the group rides on the operator + foreach: [snapshot, generator] + expression: sum_back(on, over=snapshot, within=3, by=season_of) <= units pullback: # at(), which re-indexes through a lookup instead of an offset foreach: [snapshot, bus] expression: spill <= at(zone_cap, by=zone_of) diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 5ec3ae24..d90078a5 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -72,6 +72,7 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("history") & sum_(t' in cal(T) colon 0 <= t minus.o t' < upright("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("horizon") & sum_(t' in cal(T) colon 0 <= t' - t < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("outlook") & sum_(t' in cal(T) colon 0 <= t' minus.o t < upright("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ + upright("seasonal_window") & sum_(t' in cal(T) colon 0 <= t -^(upright("season_of")(t)) t' < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("pullback") & italic("spill")_(t) & <= upright("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ upright("grouped_twice") & sum_(g in cal(G) colon upright("gen_bus")(g) = b and upright("gen_tech")(g) = e) p_(t,g) & <= upright("tech_cap")_(b,e) & forall t in cal(T), b in cal(B), e in cal(E) \ upright("pulled_back_twice") & italic("units")_(g) & <= upright("tech_cap")_(upright("gen_bus")(g),upright("gen_tech")(g)) & forall g in cal(G) \ diff --git a/tools/spec_math.py b/tools/spec_math.py index ab6efb26..fd2ca39c 100644 --- a/tools/spec_math.py +++ b/tools/spec_math.py @@ -57,9 +57,11 @@ 'sum_back(array, over=dim, within=n)': 'sum_back', 'sum_back(array, over=dim, within=p)': 'sum_back_by_parameter', "sum_back(array, over=dim, within=p, edge='wrap')": 'sum_back_wrap', + 'sum_back(array, over=dim, within=n, by=lookup)': 'sum_back_partitioned', 'sum_forward(array, over=dim, within=n)': 'sum_forward', 'sum_forward(array, over=dim, within=p)': 'sum_forward_by_parameter', "sum_forward(array, over=dim, within=p, edge='wrap')": 'sum_forward_wrap', + 'sum_forward(array, over=dim, within=n, by=lookup)': 'sum_forward_partitioned', }