diff --git a/docs/examples/operators.md b/docs/examples/operators.md index a0bdfb56..9aa2b3eb 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -427,6 +427,180 @@ 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` + +```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}$ + +### `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 aa07dddd..c694ef1e 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -25,6 +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 @@ -145,6 +150,79 @@ 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 +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 +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 7bcea9f3..02b615d5 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -288,6 +288,42 @@ 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}$$ + +#### `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.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_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/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..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,10 +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'][, 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 843615b4..c1c95a81 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,18 @@ 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) + # 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"])}' @@ -406,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 e4840a01..aacc15d1 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -81,6 +81,9 @@ \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{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 746cea9d..9c159a23 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -114,6 +114,18 @@ $$\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}$$ + +**`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 35113da2..21bd9ea2 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -124,6 +124,15 @@ 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 + 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 d5336a96..d90078a5 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -70,6 +70,9 @@ $ 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("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 f9ad41e6..fd2ca39c 100644 --- a/tools/spec_math.py +++ b/tools/spec_math.py @@ -57,6 +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', }