diff --git a/docs/reference/language/dimensions.md b/docs/reference/language/dimensions.md index 7b4373b7..45a40a4a 100644 --- a/docs/reference/language/dimensions.md +++ b/docs/reference/language/dimensions.md @@ -85,11 +85,12 @@ lookups: period_of: { over: snapshot, into: period } ``` -| Field | | | -| ------------- | -------------------------------------------------------------------- | -------------- | -| `over` | required — the dimension whose members carry the map | | -| `into` | required — the dimension its values are labels of, other than `over` | | -| `description` | free text, never parsed | default `null` | +| Field | | | +| ------------- | --------------------------------------------------------------------------------------------------------------- | -------------- | +| `over` | required — the dimension whose members carry the map | | +| `into` | required — the dimension its values are labels of, other than `over` | | +| `per` | the dimensions the map is conditioned on, besides those two ([below](#maps-that-vary-along-a-second-dimension)) | default `[]` | +| `description` | free text, never parsed | default `null` | The target must be a declared dimension, and it must differ from `over`. The values are checked against it when the data binds, which is the check that makes @@ -114,6 +115,46 @@ Every lookup name joins the flat namespace, so a lookup may not shadow a dimension, and that includes its own target. The map from `generator` onto `bus` is called `gen_bus`, never a second `bus`. +### Maps that vary along a second dimension + +`over:` alone gives every generator one zone for the whole model. A generator +whose bidding zone changes by period needs a second key, and `per:` names it: + +```yaml +dimensions: + generator: { dtype: str } + zone: { dtype: str } + period: { dtype: int } +lookups: + zone_of: { over: generator, into: zone, per: [period] } +parameters: + demand: { dims: [zone, period] } +variables: + p: { foreach: [generator, period] } +constraints: + zone_balance: + foreach: [zone, period] + expression: sum(p, by=zone_of) >= demand +``` + +`over:` is consumed, `into:` is produced, and each `per` dimension is joined on +and kept. So `sum(p, by=zone_of)` takes `p[generator, period]` to +`[zone, period]`, and `at(price, by=zone_of)` reads `price[zone, period]` back +at `[generator, period]`, which is the price of the zone this generator sat in +that period. Nothing changes at the call site. Every operator that takes a +lookup takes a conditioned one: `shift(by=)` and `position(by=)` group within +each `per` coordinate, and a `where` naming the lookup is read at them. + +Four rules follow, and the loader decides each of them before any data binds: + +- **Each `per` dimension is declared, and is neither `over` nor `into`.** A + dimension named twice is refused as well. +- **The operand carries every `per` dimension.** The map varies along them, so + there is no reading it at a coordinate that lacks them. +- **A `by=` list shares its `per` as it shares its `over`.** One grouping is one + join. +- **Two lookups compared in a `where` share it.** Otherwise no row carries both. + ### How the map is supplied The map is a source key like any other, under the lookup's own name. It carries @@ -132,6 +173,11 @@ on no bus. A null in the value column is refused, because a missing row already says the same thing. A key that matches no label of `over` is an error rather than a new member. +A conditioned map carries one further key column per `per` dimension, named +after it, and is single-valued per `(over, *per)`. A generator in two zones in +one period is refused, where a `0`/`1` membership parameter says it legally and +silently. + Values are never inferred from the parameters that use the target. If they were, a mistyped label would extend the label set instead of being rejected. diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 03769fce..c186315e 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -172,8 +172,8 @@ QUOTED ::= "'" chars "'" | '"' chars '"' | `name` (bare) | dimension | A load error. It would be true everywhere. Compare it against something instead | | `name OP value` | parameter | Element-wise, and a null compares false. The right-hand side is a literal, or a bare name read as a string label | | `name OP value` | dimension | A filter on the frame's own coordinate column | -| `name OP value` | lookup | A filter on the lookup's value, so the `over` dimension has to be in the frame. A null compares false | -| `name OP name` | two lookups | Legal only where both lookups are over the same dimension and into the same dimension. `from != to` excludes a self-loop | +| `name OP value` | lookup | A filter on the lookup's value, so the `over` dimension has to be in the frame, and so does every dimension it is [`per`](dimensions.md#maps-that-vary-along-a-second-dimension). A null compares false | +| `name OP name` | two lookups | Legal only where both lookups are over the same dimension, into the same dimension, and conditioned on the same ones. `from != to` excludes a self-loop | | `position(name) OP i` | dimension | Where the row sits along the dimension's own order. `0` is first, and a negative number counts from the end | | `position(name, by=lookup) OP i` | a dimension and a lookup over it | The same, counted within each group the lookup makes | | `AND` `OR` `NOT` | — | Case-insensitive. `NOT` binds tighter than `AND`, and `AND` tighter than `OR` | diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 7f3954cf..9f68193c 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -17,6 +17,7 @@ model can never depend on what a caller registered. A composition of them goes i | `sum(array, over=dim)` | `dim` collapses. `array` must carry `dim` | | `sum(array, by=lookup)` | The dimension that the lookup is over collapses onto the dimension it maps into | | `sum(array, by=[lookup, …])` | The same, onto every dimension that the lookups map into. All the lookups must be over the same dimension | +| `sum(array, by=conditioned)` | A lookup that declares `per:` is joined on those dimensions too. The array carries them, and the result keeps them | | `at(array, by=lookup)` | The dimension that the lookup maps into is replaced by the dimension it is over | | `shift(array, over=dim, offset=n)` | The value `n` positions earlier along `dim`. The vacated edge is **absent** | | `shift(array, over=dim, offset=n, edge='wrap')` | The value `n` positions earlier, counted cyclically, so nothing is vacated | @@ -77,6 +78,10 @@ outflow, with no adjacency matrix and no join written by hand. Give **at most one** of `over=` and `by=`. A lookup carries its own dimensions, so `by=` leaves `over=` nothing to add. +A lookup [conditioned on a second dimension](dimensions.md#maps-that-vary-along-a-second-dimension) +is joined on that dimension as well. The operand carries it, the sum keeps it, +and each group is one coordinate of it. + The lookup's values are the group labels, checked against the target dimension when the data binds. A group with no members contributes nothing, and a member whose lookup value is null belongs to no group. An empty group is a value rather @@ -96,6 +101,10 @@ once by every line that touches the bus, is `at(decision, by=line_bus)`. A fine label whose lookup value is null reads nothing, and its row is absent. That matches the null group in `sum(by=)`. +Through a lookup [conditioned on a second dimension](dimensions.md#maps-that-vary-along-a-second-dimension) +`at` reads the coarse value at the row's own coordinate of that dimension. That +is the price of the zone this generator sat in that period. + ## `sum_back` `sum_back(x, over=d, within=n)` is the sum of the last `n` positions along `d`, diff --git a/docs/reference/notation.md b/docs/reference/notation.md index b9cb558f..70ccfe54 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -49,6 +49,7 @@ lookups: zone_of: { over: bus, into: zone } area_of: { over: bus, into: zone } # a second map into the same set, to compare against season_of: { over: snapshot, into: season } + gen_zone: { over: generator, into: zone, per: [snapshot] } # a map conditioned on a second dimension, which it is read at and keeps parameters: p_max: { dims: [generator] } @@ -70,7 +71,7 @@ parameters: | Symbol | Meaning | |---|---| | $`\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},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}`$ | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | | $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}`$ | | $`\mathcal{Z}`$ | index $`z`$ — `zone` | | $`\mathcal{S}`$ | index $`s`$ — `season` | @@ -402,6 +403,35 @@ pulled_back_twice: \mathit{units}_{g} \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\, g \in \mathcal{G} ``` +#### `zonal` + +a grouping through a conditioned map: the condition reads the dim the map is per, and the row keeps it + +```yaml +zonal: + foreach: [snapshot, zone] + expression: sum(p, by=gen_zone) <= zone_cap +``` + +```math +\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = z} p_{t,g} \le \mathrm{zone\_cap}_{z} \qquad \forall\, t \in \mathcal{T},\ z \in \mathcal{Z} +``` + +#### `zonal_pullback` + +its adjoint, reading the slot the row's own snapshot puts the generator in + +```yaml +zonal_pullback: + foreach: [snapshot, generator] + where: "gen_zone == 'north' AND position(generator, by=gen_zone) == 0" + expression: p <= at(spill * zone_cap, by=gen_zone) +``` + +```math +p_{t,g} \le \mathit{spill}_{t} \cdot \mathrm{zone\_cap}_{\mathrm{gen\_zone}(g,\ t)} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = \text{'}\mathrm{north}\text{'} \wedge \mathrm{pos}_{\mathrm{gen\_zone}(g,\ t)}(g) = 0 +``` + #### `arithmetic` division, both unary signs, a sign beside a sign, floats with and without an exponent, bracketing diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 9d5dc8c3..27530f01 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -218,7 +218,7 @@ }, "LookupBlock": { "additionalProperties": false, - "description": "A named single-valued map out of one dimension ``into:`` another.\n\nIts values are labels of ``into``, which is what ``sum(by=)`` and\n``at(by=)`` land terms on::\n\n lookups:\n bus_of: {over: generator, into: bus}\n\nThe map itself is data, and arrives at bind time under the lookup's name.", + "description": "A named single-valued map out of one dimension ``into:`` another.\n\nIts values are labels of ``into``, which is what ``sum(by=)`` and\n``at(by=)`` land terms on. ``per:`` conditions the map on further\ndimensions, which every operator joins on and passes through::\n\n lookups:\n bus_of: {over: generator, into: bus}\n zone_of: {over: generator, into: zone, per: [period]}\n\nThe map itself is data, and arrives at bind time under the lookup's name,\nsingle-valued per ``(over, *per)``.", "properties": { "description": { "anyOf": [ @@ -239,6 +239,14 @@ "over": { "title": "Over", "type": "string" + }, + "per": { + "default": [], + "items": { + "type": "string" + }, + "title": "Per", + "type": "array" } }, "required": [ diff --git a/src/math_spec/_expression_parser.py b/src/math_spec/_expression_parser.py index 642a9f6a..fdb78b9b 100644 --- a/src/math_spec/_expression_parser.py +++ b/src/math_spec/_expression_parser.py @@ -120,11 +120,14 @@ class LookupNode: ``dimension`` is the one every lookup is over — what ``sum`` consumes and ``at`` produces — and ``into`` the targets, one per name in the order written; ``sum(x, by=[gen_bus, gen_tech])`` is one grouping, not two. + ``per`` is the dims every one of them is conditioned on, which the + operand must carry and the operator passes through. """ names: tuple[str, ...] dimension: str into: tuple[str, ...] + per: tuple[str, ...] = () @property def shown(self) -> str: diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 1939032c..e1729adc 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -161,6 +161,7 @@ def _sum_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, conte 'drop the sum, or fix the dim', ) ) + _check_conditioned(f'sum(by={by.shown})', by, inner, context) collides = sorted(set(by.into) & (inner - {by.dimension})) if collides: raise DimensionError( @@ -185,6 +186,7 @@ def _at_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spec, contex f'{sorted(inner)}). A pullback needs the coarse dims to read *from* — ' f'sum is the direction that produces them.' ) + _check_conditioned(f'at(by={by.shown})', by, inner, context) if by.dimension in inner - set(by.into): raise DimensionError( f'{context}: at(by={by.shown}) places terms onto ' @@ -228,9 +230,20 @@ def _translation_dims(node: FunctionCallNode, inner: frozenset[str], schema: Spe f"'{over.name}' carries it, so no coordinate has a neighbour inside a group — " f"partition by a lookup over '{over.name}'." ) + _check_conditioned(f'{node.name}(over={over.name}, by={partition.shown})', partition, inner, context) return inner +def _check_conditioned(call: str, by: LookupNode, inner: frozenset[str], context: str) -> None: + """A lookup conditioned ``per`` some dims is read at them, so the operand carries every one.""" + if missing := sorted(set(by.per) - inner): + raise DimensionError( + f'{context}: {call} reads a lookup conditioned per {missing}, which the expression ' + f'does not carry (dims {sorted(inner)}). The map varies along those dims, so the operand ' + f'has to be read at them — index it by them, or declare the lookup without them.' + ) + + #: The dim rule of each built-in, by name. _CALL_RULES: dict[str, Callable[[FunctionCallNode, frozenset[str], Spec, str], frozenset[str]]] = { 'sum': _sum_dims, diff --git a/src/math_spec/lowering.py b/src/math_spec/lowering.py index 4be53d60..eab67ca4 100644 --- a/src/math_spec/lowering.py +++ b/src/math_spec/lowering.py @@ -143,7 +143,9 @@ def lower_program(expanded: _ExpandedSpec) -> program.Program: dimensions = { dname: program.DimensionDeclaration( tuple( - program.LookupDeclaration(lname, lk.into) for lname, lk in expanded.lookups.items() if lk.over == dname + program.LookupDeclaration(lname, lk.into, tuple(lk.per)) + for lname, lk in expanded.lookups.items() + if lk.over == dname ), ddef.dtype, ) @@ -272,7 +274,9 @@ def sum(self, node: FunctionCallNode) -> program.ExpressionNode: assert isinstance(over_node, DimensionNode), 'resolution refuses an over= that is not a dimension' return program.Sum(operand, (over_node.name,)) assert isinstance(by_node, LookupNode), 'resolution refuses a by= that is not a lookup' - return program.GroupSum(operand, over=by_node.dimension, coordinate=by_node.names, into=by_node.into) + return program.GroupSum( + operand, over=by_node.dimension, coordinate=by_node.names, into=by_node.into, per=by_node.per + ) def at(self, node: FunctionCallNode) -> program.ExpressionNode: """``at(x, by=lookup)`` — the adjoint of :meth:`sum`'s ``by=`` form.""" @@ -283,6 +287,7 @@ def at(self, node: FunctionCallNode) -> program.ExpressionNode: over=by_node.dimension, coordinate=by_node.names, into=by_node.into, + per=by_node.per, ) def sum_back(self, node: FunctionCallNode) -> program.ExpressionNode: diff --git a/src/math_spec/model.py b/src/math_spec/model.py index d5246b97..b474b41b 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -157,18 +157,22 @@ class LookupBlock(_StrictBlock): """A named single-valued map out of one dimension ``into:`` another. Its values are labels of ``into``, which is what ``sum(by=)`` and - ``at(by=)`` land terms on:: + ``at(by=)`` land terms on. ``per:`` conditions the map on further + dimensions, which every operator joins on and passes through:: lookups: bus_of: {over: generator, into: bus} + zone_of: {over: generator, into: zone, per: [period]} - The map itself is data, and arrives at bind time under the lookup's name. + The map itself is data, and arrives at bind time under the lookup's name, + single-valued per ``(over, *per)``. """ _label: ClassVar[str] = 'a lookup declaration' over: str into: str + per: list[str] = [] description: str | None = None @@ -675,9 +679,9 @@ class Spec(_StrictBlock): piecewise: dict[str, PiecewiseBlock] = {} sos: dict[str, SosBlock] = {} - def lookups_of(self, dimension: str) -> dict[str, str]: - """The lookups over *dimension*: name -> the dim they map into.""" - return {n: lk.into for n, lk in self.lookups.items() if lk.over == dimension} + def lookups_of(self, dimension: str) -> dict[str, LookupBlock]: + """The lookups over *dimension*, by name.""" + return {n: lk for n, lk in self.lookups.items() if lk.over == dimension} @classmethod @override @@ -807,19 +811,32 @@ def _frame_dimensions(self) -> Iterator[str]: ) def _lookup_targets(self) -> Iterator[str]: - """A lookup is over a declared dimension and maps into a different declared one.""" + """A lookup is over a declared dimension, maps into a different declared one, and is conditioned on others still.""" for lname, lk in self.lookups.items(): if lk.over not in self.dimensions: yield (undeclared_dimension('Lookup', lname, lk.over)) - if lk.into is not None: - if lk.into not in self.dimensions: + if lk.into not in self.dimensions: + yield ( + f"Lookup '{lname}' targets undeclared dimension '{lk.into}'. " + f"Declare it under 'dimensions:' — the target is what the " + f'lookup values are checked against.' + ) + elif lk.into == lk.over: + yield (f"Lookup '{lname}' maps '{lk.over}' into itself. A lookup maps into a different dimension.") + for d in lk.per: + if d not in self.dimensions: + yield undeclared_dimension('Lookup', lname, d) + elif d in (lk.over, lk.into): yield ( - f"Lookup '{lname}' targets undeclared dimension '{lk.into}'. " - f"Declare it under 'dimensions:' — the target is what the " - f'lookup values are checked against.' + f"Lookup '{lname}' is conditioned per '{d}', which is the dimension it " + f"{'maps out of' if d == lk.over else 'maps into'}. 'per:' names the dimensions the map " + f'varies along besides those two.' ) - elif lk.into == lk.over: - yield (f"Lookup '{lname}' maps '{lk.over}' into itself. A lookup maps into a different dimension.") + yield from ( + f"Lookup '{lname}' names '{d}' twice under 'per:'. A map is conditioned on each dimension once." + for d, count in Counter(lk.per).items() + if count > 1 + ) def _bound_names(self) -> Iterator[str]: """A named bound is a numeric parameter.""" diff --git a/src/math_spec/program.py b/src/math_spec/program.py index 1ade8d87..5e200db3 100644 --- a/src/math_spec/program.py +++ b/src/math_spec/program.py @@ -268,13 +268,16 @@ class GroupSum(Expression): labels of the matching dim in ``into``; the result replaces ``over`` with all of them. The two tuples are the same length and their order pairs them: several coordinates are one grouping into a product of targets, - consumed in a single join. + consumed in a single join. ``per`` is the dims the coordinates are + conditioned on, shared by all of them: the join keys on ``(over, *per)`` + and the operand carries every one, so they survive in the result. """ operand: ExpressionNode over: str coordinate: tuple[str, ...] into: tuple[str, ...] + per: tuple[str, ...] = () @dataclass(frozen=True) @@ -283,13 +286,15 @@ class At(Expression): Same mapping table, walked the other way: ``GroupSum`` consumes ``over`` and produces ``into``, this consumes ``into`` and produces ``over``. The - join fans out, many ``over`` labels sharing one ``into`` tuple. + join fans out, many ``over`` labels sharing one ``into`` tuple — within + each ``per`` coordinate, which the operand carries and the result keeps. """ operand: ExpressionNode over: str coordinate: tuple[str, ...] into: tuple[str, ...] + per: tuple[str, ...] = () @dataclass(frozen=True) @@ -443,11 +448,14 @@ class LookupDeclaration(NamedTuple): Its values are labels of ``target``, checked for containment once the dim tables exist — which keeps a mistyped label from silently dropping its terms in the join that places them — and it is what ``sum(by=)`` lands - terms on. + terms on. ``per`` is the dimensions the map is conditioned on: it is + single-valued per ``(over, *per)``, and every operator joins on them and + passes them through. """ name: str target: str + per: tuple[str, ...] = () @dataclass(frozen=True) @@ -1057,36 +1065,44 @@ class DimensionPositionNode: """Compare where a row sits along a dimension against a position — ``position(snapshot) == 0``. Both sides are integers, negative counting from the end. With ``by`` the - position is counted within each group the lookup makes. + position is counted within each group the lookup makes, at each coordinate + of the dims ``per`` the lookup is conditioned on. """ name: str op: PredicateOperator position: int by: str | None = None + per: tuple[str, ...] = () @dataclass(frozen=True) class LookupComparisonNode: """Compare a lookup's values against a literal — ``period_of == 2030``. - ``over`` is the dimension the lookup maps out of. + ``over`` is the dimension the lookup maps out of, ``per`` the dims it is + conditioned on; the leaf is read at all of them. """ name: str over: str op: PredicateOperator value: float | str | datetime.date + per: tuple[str, ...] = () @dataclass(frozen=True) class LookupPairComparisonNode: - """Compare two lookups over one dimension — ``from != to``, row by row on that dimension's table.""" + """Compare two lookups over one dimension — ``from != to``, row by row on that dimension's table. + + Both are conditioned on the same ``per`` dims, or they share no row. + """ name: str other: str over: str op: PredicateOperator + per: tuple[str, ...] = () @dataclass(frozen=True) @@ -1095,6 +1111,7 @@ class LookupDefinedNode: name: str over: str + per: tuple[str, ...] = () @dataclass(frozen=True) @@ -1192,18 +1209,21 @@ def _atom_dims(atom: TypedPredicateNode) -> frozenset[str]: A parameter or variable leaf carries its own dims off the declaration; a comparison on a dimension is read through that dimension, and a lookup through the dimension it maps out of — the dim it leaves, not the one it - lands in. Separate from the union because the load-time frame check - reports per leaf. Closed by ``assert_never``: a predicate node added - without a reading is a type error here, at the one place that has to grow - a branch, rather than a wrong dim set at the first model to use it. + lands in — and the dims it is conditioned on. Separate from the union + because the load-time frame check reports per leaf. Closed by + ``assert_never``: a predicate node added without a reading is a type error + here, at the one place that has to grow a branch, rather than a wrong dim + set at the first model to use it. """ match atom: case ParameterComparisonNode() | ParameterDefinedNode() | VariableDefinedNode(): return frozenset(atom.dims) - case DimensionComparisonNode() | DimensionPositionNode(): + case DimensionComparisonNode(): return frozenset({atom.name}) + case DimensionPositionNode(): + return frozenset({atom.name, *atom.per}) case LookupComparisonNode() | LookupPairComparisonNode() | LookupDefinedNode(): - return frozenset({atom.over}) + return frozenset({atom.over, *atom.per}) case _: assert_never(atom) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 7dca8cee..fce7837d 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -103,7 +103,7 @@ def __init__( variables: Iterable[str], parameters: Iterable[str], dimensions: Iterable[str], - lookups: Mapping[str, tuple[str, str]], + lookups: Mapping[str, tuple[str, str, tuple[str, ...]]], dtypes: Mapping[str, DeclaredDtype], leaf_dims: Mapping[str, tuple[str, ...]], constraints: Iterable[str], @@ -118,8 +118,8 @@ def __init__( #: name -> declared dtype, for dimensions, parameters and lookups alike; #: what a where comparison checks its literal against. self.dtypes: dict[str, DeclaredDtype] = dict(dtypes) - #: lookup name -> ``(over, into)``. - self.lookups: dict[str, tuple[str, str]] = dict(lookups) + #: lookup name -> ``(over, into, per)``. + self.lookups: dict[str, tuple[str, str, tuple[str, ...]]] = dict(lookups) #: parameter or variable name -> the dims it is read through — #: parameters by their ``dims``, variables by their frame. Stamped onto #: each leaf a where names, the way a lookup leaf carries ``over``. @@ -135,7 +135,7 @@ def of(cls, schema: Spec) -> Namespace: schema.variables, schema.parameters, schema.dimensions, - {n: (lk.over, lk.into) for n, lk in schema.lookups.items()}, + {n: (lk.over, lk.into, tuple(lk.per)) for n, lk in schema.lookups.items()}, { **{p: pd.dtype for p, pd in schema.parameters.items()}, **{d: dd.dtype for d, dd in schema.dimensions.items()}, @@ -168,6 +168,10 @@ def into_of(self, lookup: str) -> str: """The dimension *lookup*'s values are labels of.""" return self.lookups[lookup][1] + def per_of(self, lookup: str) -> tuple[str, ...]: + """The dimensions *lookup* is conditioned on, as declared.""" + return self.lookups[lookup][2] + def unknown(self, name: str, context: str, *, allow_dims: bool, formals: Iterable[str] = ()) -> str: """The refusal for a *name* declared nowhere, listing what it could have been. @@ -565,7 +569,8 @@ def _lookup_ref(self, value: ArithmeticNode, operator: str, key: str) -> Arithme consulted: the names alone decide both the dim the operator consumes and the ones it produces. A bracketed list is one grouping through several maps at once rather than a composition of groupings, so its members must - share the dim they are over and must not target the same dim twice. + share the dim they are over and the dims they are conditioned on, and + must not target the same dim twice. """ names = names_in(value) if not names: @@ -588,6 +593,16 @@ def _lookup_ref(self, value: ArithmeticNode, operator: str, key: str) -> Arithme ) return value + pers = {ns.per_of(name) for name in names} + if len(pers) > 1: + self.errors.append( + f'{self.context}: {operator}({key}={shown(names)}) groups through lookups conditioned on ' + f'different dimensions ({", ".join(f"{n} per {list(ns.per_of(n))}" for n in names)}). ' + f'One grouping is one join, so every lookup in the list must share its per: — ' + f'group through them in turn instead, one call each.' + ) + return value + targets = tuple(ns.into_of(name) for name in names) repeated = sorted({t for t in targets if targets.count(t) > 1}) if repeated: @@ -598,7 +613,7 @@ def _lookup_ref(self, value: ArithmeticNode, operator: str, key: str) -> Arithme ) return value - return LookupNode(names, dimension=next(iter(over)), into=targets) + return LookupNode(names, dimension=next(iter(over)), into=targets, per=next(iter(pers))) def _not_a_lookup(self, name: str, operator: str, key: str) -> str | None: """Why *name* is not a lookup; ``None`` where it is one.""" @@ -606,7 +621,7 @@ def _not_a_lookup(self, name: str, operator: str, key: str) -> str | None: if name in ns.lookups: return None if name in ns.dimensions: - into_here = sorted(n for n, (_, into) in ns.lookups.items() if into == name) + into_here = sorted(n for n, (_, into, _) in ns.lookups.items() if into == name) hint = f" Lookups into '{name}': {into_here}" if into_here else f" No lookup maps into '{name}'." return ( f"{context}: {operator}({key}={name}): '{name}' is a dimension, and " @@ -660,7 +675,7 @@ def _where_name(self, node: UnresolvedNameNode) -> WhereNode | UnresolvedWhereNo f'Remove it, or compare it: where: "{node.name} > 0".' ) case 'lookup': - return LookupDefinedNode(node.name, ns.over_of(node.name)) + return LookupDefinedNode(node.name, ns.over_of(node.name), ns.per_of(node.name)) case 'variable': if node.name == self.self_variable: self.errors.append( @@ -700,7 +715,7 @@ def _position(self, node: UnresolvedPositionNode) -> DimensionPositionNode | Unr f"position within a group to name — group by a lookup over '{node.dimension}'." ) return node - return DimensionPositionNode(node.dimension, node.op, node.position, node.by) + return DimensionPositionNode(node.dimension, node.op, node.position, node.by, ns.per_of(node.by)) def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedWhereNode: """``name literal``, or the one structural form ``lookup lookup``.""" @@ -711,7 +726,7 @@ def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedW if (refusal := _lookup_pair_error(context, node, value, ns)) is not None: self.errors.append(refusal) return node - return LookupPairComparisonNode(node.name, value, ns.over_of(node.name), node.op) + return LookupPairComparisonNode(node.name, value, ns.over_of(node.name), node.op, ns.per_of(node.name)) self.errors.append(_declared_rhs_error(context, node, value, rhs_kind)) return node @@ -732,7 +747,7 @@ def _comparison(self, node: UnresolvedComparisonNode) -> WhereNode | UnresolvedW case 'dimension': return DimensionComparisonNode(node.name, node.op, value) case 'lookup': - return LookupComparisonNode(node.name, ns.over_of(node.name), node.op, value) + return LookupComparisonNode(node.name, ns.over_of(node.name), node.op, value, ns.per_of(node.name)) case 'variable': self.errors.append( f"{context}: where references variable '{node.name}'. A where " @@ -883,9 +898,10 @@ def _declared_rhs_error(context: str, node: UnresolvedComparisonNode, value: str def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str, ns: Namespace) -> str | None: """Why two lookups may not be compared, or ``None`` where they may. - They must map out of the same dimension, or no row carries both; and into - the same one, or no value of one is ever a value of the other. Both wrong - answers are silent, and a build's data library decides which one. + They must map out of the same dimension and be conditioned on the same + ones, or no row carries both; and into the same one, or no value of one is + ever a value of the other. Both wrong answers are silent, and a build's + data library decides which one. """ comparison = f"'{node.name} {node.op} {other}'" left_over, right_over = ns.over_of(node.name), ns.over_of(other) @@ -896,6 +912,14 @@ def _lookup_pair_error(context: str, node: UnresolvedComparisonNode, other: str, f'comparison has nothing to test. Two lookups may be compared only where they ' f'map out of the same dimension.' ) + left_per, right_per = ns.per_of(node.name), ns.per_of(other) + if left_per != right_per: + return ( + f'{context}: {comparison} compares lookups conditioned on different dimensions ' + f"('{node.name}' per {list(left_per)}, '{other}' per {list(right_per)}) — there is no row " + f'carrying both, so the comparison has nothing to test. Two lookups may be compared ' + f'only where they share their per:.' + ) left, right = ns.into_of(node.name), ns.into_of(other) if left != right: return ( diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 2df0d4a2..5e42a2fb 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -273,9 +273,14 @@ def _translation(self, step: _Step) -> str: self.noticed.grouped = True return self.format.superscript(operator, step.within) - def _lookup(self, name: str, index: str) -> str: - """A coordinate map applied to an index: ``bus(g)``.""" - return self.format.apply(self.format.upright(name), index) + def _lookup(self, name: str, index: str, per: Iterable[str] = (), ctx: _Context | None = None) -> str: + """A coordinate map applied to an index: ``bus(g)``, or ``zone(g, p)`` where it is conditioned per ``p``. + + The conditioning indices are read under *ctx*, as the row being + written reads them — pulled back or translated where the row is. + """ + indices = [index, *(ctx.subscript(d) if ctx is not None else self.symbols.index[d] for d in per)] + return self.format.apply(self.format.upright(name), self.format.joined(indices, '')) def _context(self, frame: Iterable[str] = ()) -> _Context: return _Context(self, bound=tuple(frame)) @@ -413,14 +418,14 @@ def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: by = node.kwargs['by'] assert isinstance(by, LookupNode) for name, into in zip(by.names, by.into, strict=True): - ctx = ctx.pulled_back(into, self._lookup(name, ctx.subscript(by.dimension))) + ctx = ctx.pulled_back(into, self._lookup(name, ctx.subscript(by.dimension), by.per, ctx)) return self._arithmetic(node.args[0], ctx) if (by := node.kwargs.get('by')) is not None: assert isinstance(by, LookupNode) dummy, inner = ctx.reducing(by.dimension) conditions = [ - f'{self._lookup(name, dummy)} {self._op("equal")} {ctx.subscript(into)}' + f'{self._lookup(name, dummy, by.per, ctx)} {self._op("equal")} {ctx.subscript(into)}' for name, into in zip(by.names, by.into, strict=True) ] domain = ( @@ -450,7 +455,7 @@ def _group(self, by: ArithmeticNode | None, dim: str) -> str: if by is None: return '' assert isinstance(by, LookupNode) - return self._lookup(by.names[0], self.symbols.index[dim]) + return self._lookup(by.names[0], self.symbols.index[dim], by.per) def _width(self, node: ArithmeticNode) -> str: """``sum_back``'s ``within=``: a number, or a parameter's own symbol. @@ -532,23 +537,23 @@ def _where(self, node: WhereNode, ctx: _Context) -> tuple[str, int]: ) if isinstance(node, DimensionPositionNode): - grouping = None if node.by is None else self._lookup(node.by, ctx.subscript(node.name)) + grouping = None if node.by is None else self._lookup(node.by, ctx.subscript(node.name), node.per, ctx) place = self._position(ctx.subscript(node.name), grouping) ordinal = self._ordinal(node.name, node.position, grouping) return f'{place} {self._op(_PREDICATES[node.op])} {ordinal}', comparison if isinstance(node, LookupComparisonNode): - applied = self._lookup(node.name, ctx.subscript(node.over)) + applied = self._lookup(node.name, ctx.subscript(node.over), node.per, ctx) return f'{applied} {self._op(_PREDICATES[node.op])} {self._literal(node.value)}', comparison if isinstance(node, LookupPairComparisonNode): index = ctx.subscript(node.over) - left = self._lookup(node.name, index) - right = self._lookup(node.other, index) + left = self._lookup(node.name, index, node.per, ctx) + right = self._lookup(node.other, index, node.per, ctx) return f'{left} {self._op(_PREDICATES[node.op])} {right}', comparison if isinstance(node, LookupDefinedNode): - applied = self._lookup(node.name, ctx.subscript(node.over)) + applied = self._lookup(node.name, ctx.subscript(node.over), node.per, ctx) return f'{applied} {self.format.prose(" is defined")}', comparison if isinstance(node, NotNode): @@ -824,6 +829,10 @@ def _over(self, dims: list[str]) -> str: product = self.format.joined([self.symbols.set[d] for d in dims], self._op('times')) return f' over {self.format.math(product)}' + def _domain(self, dim: str, per: Iterable[str]) -> str: + """A map's domain in the legend: the set it is over, times each set it is conditioned on.""" + return self.format.joined([self.symbols.set[d] for d in (dim, *per)], self._op('times')) + def _coords(self, dim: str, noticed: Noticed) -> str: """The dimension's carried structure: each lookup as the map it is (``bus_of: G ↦ B``). @@ -838,8 +847,8 @@ def _coords(self, dim: str, noticed: Noticed) -> str: if targeted: maps = self.format.joined( [ - f'{self.format.upright(c)}: {self.symbols.set[dim]} {self._op("maps_to")} {self.symbols.set[target]}' - for c, target in targeted.items() + f'{self.format.upright(c)}: {self._domain(dim, lk.per)} {self._op("maps_to")} {self.symbols.set[lk.into]}' + for c, lk in targeted.items() ], '', ) diff --git a/tests/test_dimensions.py b/tests/test_dimensions.py index c96fb1b7..a7f81c9b 100644 --- a/tests/test_dimensions.py +++ b/tests/test_dimensions.py @@ -30,15 +30,19 @@ 'snapshot': {'dtype': 'int'}, 'generator': {'dtype': 'str'}, 'bus': {'dtype': 'str'}, + 'zone': {'dtype': 'str'}, }, 'lookups': { 'gen_bus': {'over': 'generator', 'into': 'bus'}, 'snap_bus': {'over': 'snapshot', 'into': 'bus'}, + 'gen_zone': {'over': 'generator', 'into': 'zone', 'per': ['snapshot']}, }, 'parameters': { 'p_max': {'dims': ['generator']}, 'cost': {'dims': ['generator']}, 'load': {'dims': ['snapshot', 'bus']}, + 'zone_cap': {'dims': ['zone']}, + 'zone_load': {'dims': ['snapshot', 'zone']}, 'spinup': {'dims': ['generator'], 'dtype': 'int'}, 'horizon': {'dims': ['snapshot'], 'dtype': 'int'}, 'bus_lead': {'dims': ['bus'], 'dtype': 'int'}, @@ -100,6 +104,21 @@ def namespace() -> Namespace: id='a-by-makes-a-width-over-another-dim-readable-one-window-per-group', ), pytest.param('p + 1', {'snapshot', 'generator'}, id='a-scalar-broadcasts'), + pytest.param( + 'sum(p, by=gen_zone)', + {'snapshot', 'zone'}, + id='a-conditioned-lookup-consumes-over-produces-into-and-keeps-what-it-is-per', + ), + pytest.param( + 'at(zone_load, by=gen_zone)', + {'snapshot', 'generator'}, + id='its-pullback-keeps-what-it-is-per-too', + ), + pytest.param( + "shift(p, over=generator, offset=1, edge='wrap', by=gen_zone)", + {'snapshot', 'generator'}, + id='a-partition-conditioned-per-a-dim-the-operand-carries', + ), ], ) def test_dim_inference(expr, expected): @@ -187,6 +206,21 @@ def test_a_bare_name_reaches_the_variable_a_dual_the_same_named_constraint(): r"varies over \['bus'\], which that coordinate does not carry", id='a-named-offset-is-read-where-the-expression-has-a-coordinate', ), + pytest.param( + 'sum(cost, by=gen_zone)', + r"sum\(by=gen_zone\) reads a lookup conditioned per \['snapshot'\], which the expression does not carry", + id='a-grouped-sum-needs-the-dims-the-lookup-is-per', + ), + pytest.param( + 'at(zone_cap, by=gen_zone)', + r"at\(by=gen_zone\) reads a lookup conditioned per \['snapshot'\]", + id='a-pullback-needs-the-dims-the-lookup-is-per', + ), + pytest.param( + "shift(cost, over=generator, offset=1, edge='wrap', by=gen_zone)", + r"by=gen_zone\) reads a lookup conditioned per \['snapshot'\]", + id='a-partition-needs-the-dims-the-lookup-is-per', + ), ], ) def test_an_ill_dimensioned_expression_is_rejected(expr, match): @@ -328,6 +362,15 @@ def test_a_zero_step_vacates_nothing_and_needs_no_edge(self): pytest.param('snapshot == 0', {'snapshot'}, id='a-dimension-through-itself'), pytest.param('position(snapshot) == 0', {'snapshot'}, id='a-position-through-the-axis-it-counts'), pytest.param('snap_bus == "b1"', {'snapshot'}, id='a-lookup-through-the-dim-it-maps-out-of'), + pytest.param( + 'gen_zone == "z1"', {'generator', 'snapshot'}, id='a-conditioned-lookup-through-the-dims-it-is-per-as-well' + ), + pytest.param('gen_zone', {'generator', 'snapshot'}, id='a-bare-conditioned-lookup-the-same'), + pytest.param( + 'position(generator, by=gen_zone) == 0', + {'generator', 'snapshot'}, + id='a-position-within-a-conditioned-group-reads-the-dims-it-is-per', + ), pytest.param('p_max > 0 AND snapshot == 0', {'generator', 'snapshot'}, id='a-conjunction-reads-both-sides'), pytest.param('NOT p_max > 0', {'generator'}, id='a-negation-reads-what-it-negates'), pytest.param('False', set(), id='a-literal-reads-nothing'), diff --git a/tests/test_lowering.py b/tests/test_lowering.py index 3bab6500..21fe185f 100644 --- a/tests/test_lowering.py +++ b/tests/test_lowering.py @@ -465,6 +465,46 @@ def test_a_construct_lowers_to_its_node(shapes_schema, expression, expected): assert lowered == expected, 'the whole frozen node, so no field is asserted by omission' +def test_a_conditioned_lookup_lowers_with_the_dims_it_is_per(): + """`per:` reaches every node that reads the lookup, so a consumer joins on the right keys.""" + program = to_program( + { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {}, 'zone': {}}, + 'lookups': {'zone_of': {'over': 'generator', 'into': 'zone', 'per': ['snapshot']}}, + 'parameters': {'price': {'dims': ['snapshot', 'zone']}}, + 'variables': { + 'p': {'foreach': ['snapshot', 'generator'], 'where': "zone_of == 'A' AND zone_of"}, + 'first': {'foreach': ['snapshot', 'generator'], 'where': 'position(generator, by=zone_of) == 0'}, + }, + 'constraints': { + 'zonal': {'foreach': ['snapshot', 'zone'], 'expression': 'sum(p, by=zone_of) <= 1'}, + 'priced': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= at(price, by=zone_of)'}, + }, + } + ) + + assert program.dimension('generator').lookups == (LookupDeclaration('zone_of', 'zone', ('snapshot',)),), ( + 'the declaration carries its per dims' + ) + assert program.constraints['zonal'].lhs == GroupSum( + Variable('p'), over='generator', coordinate=('zone_of',), into=('zone',), per=('snapshot',) + ), 'a grouped sum names what it is per' + assert program.constraints['priced'].rhs == At( + Parameter('price'), over='generator', coordinate=('zone_of',), into=('zone',), per=('snapshot',) + ), 'and so does its adjoint' + p_where = program.variable('p').where + assert p_where is not None + assert [(type(a).__name__, a.per) for a in p_where.atoms] == [ + ('LookupComparisonNode', ('snapshot',)), + ('LookupDefinedNode', ('snapshot',)), + ], 'every where leaf reading the lookup carries its per dims' + first_where = program.variable('first').where + assert first_where is not None + assert first_where.dims == {'generator', 'snapshot'}, ( + 'a position within a conditioned group is read at the per dims' + ) + + def test_a_binary_variable_lowers_to_a_binary_domain(): program = to_program(schema_of(DISPATCH_YAML, **{'variables.p.domain': 'binary', 'variables.p.bounds': {}})) assert program.variable('p').domain == 'binary' diff --git a/tests/test_validation.py b/tests/test_validation.py index 0e33184a..5ed9513d 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -562,7 +562,7 @@ class TestRulesDecidedWithoutData: ), pytest.param( {'lookups.tag': {'over': 'g', 'dtype': 'str'}}, - ("unknown key 'dtype' in a lookup declaration. Valid keys: description, into, over.",), + ("unknown key 'dtype' in a lookup declaration. Valid keys: description, into, over, per.",), id='lookup-with-a-dtype-of-its-own', ), pytest.param({'lookups.tag': {'over': 'g'}}, ('lookups.tag.into: Field required',), id='lookup-no-into'), @@ -571,6 +571,24 @@ class TestRulesDecidedWithoutData: ), pytest.param({'lookups.lk.into': 'z'}, ("targets undeclared dimension 'z'",), id='lookup-into-undeclared'), pytest.param({'lookups.lk.into': 'g'}, ("maps 'g' into itself",), id='lookup-into-itself'), + pytest.param( + {'lookups.lk.per': ['z']}, ("references undeclared dimension 'z'",), id='lookup-per-undeclared' + ), + pytest.param( + {'lookups.lk.per': ['g']}, + ("conditioned per 'g', which is the dimension it maps out of",), + id='lookup-per-the-dim-it-is-over', + ), + pytest.param( + {'lookups.lk.per': ['h']}, + ("conditioned per 'h', which is the dimension it maps into",), + id='lookup-per-the-dim-it-targets', + ), + pytest.param( + {'dimensions.z': {}, 'lookups.lk.per': ['z', 'z']}, + ("names 'z' twice under 'per:'",), + id='lookup-per-a-dim-twice', + ), pytest.param( {'lookups.g': {'over': 'h', 'into': 'g'}}, ("Lookup 'g' collides with the dimension",), @@ -709,6 +727,24 @@ class TestRulesDecidedWithoutData: ("targets ['h'] more than once",), id='by-the-same-target-twice', ), + pytest.param( + { + 'dimensions.z': {}, + 'lookups.lz': {'over': 'g', 'into': 'z', 'per': ['h']}, + 'objective': {'expression': 'sum(sum(q, by=[lk, lz]))'}, + }, + ('groups through lookups conditioned on different dimensions',), + id='by-lookups-with-different-per', + ), + pytest.param( + { + 'dimensions.z': {}, + 'lookups.lz': {'over': 'g', 'into': 'h', 'per': ['z']}, + 'variables.q.where': 'lk != lz', + }, + ('compares lookups conditioned on different dimensions',), + id='where-two-lookups-with-different-per', + ), pytest.param( {'variables.p.where': 'c > flag'}, ('compares two parameters',), id='where-against-a-parameter' ), diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index 4b434197..7ae90623 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -10,7 +10,7 @@ \paragraph{Sets} \begin{description} \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}$ +\item[{$\mathcal{G}$}] index $g$ --- \texttt{generator} with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}$ \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} \item[{$\mathcal{S}$}] index $s$ --- \texttt{season} @@ -94,6 +94,8 @@ \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} \\ +\text{zonal} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = z} p_{t,g} & \le \mathrm{zone\_cap}_{z} && \forall\, t \in \mathcal{T},\ z \in \mathcal{Z} \\ +\text{zonal\_pullback} && p_{t,g} & \le \mathit{spill}_{t} \cdot \mathrm{zone\_cap}_{\mathrm{gen\_zone}(g,\ t)} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = \text{'}\mathrm{north}\text{'} \wedge \mathrm{pos}_{\mathrm{gen\_zone}(g,\ t)}(g) = 0 \\ \text{arithmetic} && \sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathrm{cost}_{g} + 10^{-5} \cdot p_{t,g} + 2.5 \times 10^{-7} \cdot \mathrm{cost}_{g} + 0.5 \cdot p_{t,g} \right) & \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot \left( -3 \right) && \forall\, t \in \mathcal{T} \\ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathrm{budget} && \forall\, g \in \mathcal{G} \,:\, \mathrm{cost}_{g} \text{ is defined} \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index fa7ee0a5..23f2dd7a 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -7,7 +7,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 | Symbol | Meaning | |---|---| | $`\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},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}`$ | +| $`\mathcal{G}`$ | index $`g`$ — `generator` with $`\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\ \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E},\ \mathrm{gen\_zone}: \mathcal{G} \times \mathcal{T} \to \mathcal{Z}`$ | | $`\mathcal{B}`$ | index $`b`$ — `bus` with $`\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\ \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}`$ | | $`\mathcal{Z}`$ | index $`z`$ — `zone` | | $`\mathcal{S}`$ | index $`s`$ — `season` | @@ -184,6 +184,18 @@ p_{t,g} \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} \qquad \forall\, t \mathit{units}_{g} \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\, g \in \mathcal{G} ``` +**`zonal`** + +```math +\sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = z} p_{t,g} \le \mathrm{zone\_cap}_{z} \qquad \forall\, t \in \mathcal{T},\ z \in \mathcal{Z} +``` + +**`zonal_pullback`** + +```math +p_{t,g} \le \mathit{spill}_{t} \cdot \mathrm{zone\_cap}_{\mathrm{gen\_zone}(g,\ t)} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{gen\_zone}(g,\ t) = \text{'}\mathrm{north}\text{'} \wedge \mathrm{pos}_{\mathrm{gen\_zone}(g,\ t)}(g) = 0 +``` + **`arithmetic`** ```math diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index f093df2b..acd97289 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -26,6 +26,7 @@ lookups: zone_of: { over: bus, into: zone } area_of: { over: bus, into: zone } # a second map into the same set, to compare against season_of: { over: snapshot, into: season } + gen_zone: { over: generator, into: zone, per: [snapshot] } # a map conditioned on a second dimension, which it is read at and keeps parameters: p_max: { dims: [generator] } @@ -152,6 +153,13 @@ constraints: pulled_back_twice: # its adjoint, reading one slot through a pair of labels foreach: [generator] expression: units <= at(tech_cap, by=[gen_bus, gen_tech]) + zonal: # a grouping through a conditioned map: the condition reads the dim the map is per, and the row keeps it + foreach: [snapshot, zone] + expression: sum(p, by=gen_zone) <= zone_cap + zonal_pullback: # its adjoint, reading the slot the row's own snapshot puts the generator in + foreach: [snapshot, generator] + where: "gen_zone == 'north' AND position(generator, by=gen_zone) == 0" + expression: p <= at(spill * zone_cap, by=gen_zone) arithmetic: # division, both unary signs, a sign beside a sign, floats with and without an exponent, bracketing foreach: [snapshot] expression: >- diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 7f286e31..98d91617 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -5,7 +5,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 == Sets / $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)$ +/ $cal(G)$: index $g$ --- `generator` with $upright("gen_bus"): cal(G) arrow.r cal(B), upright("gen_tech"): cal(G) arrow.r cal(E), upright("gen_zone"): cal(G) times cal(T) arrow.r cal(Z)$ / $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` / $cal(S)$: index $s$ --- `season` @@ -81,6 +81,8 @@ $ upright("budgeted") & italic("spend")_(t) & <= upright("budget") & forall t in 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) \ + upright("zonal") & sum_(g in cal(G) colon upright("gen_zone")(g, t) = z) p_(t,g) & <= upright("zone_cap")_(z) & forall t in cal(T), z in cal(Z) \ + upright("zonal_pullback") & p_(t,g) & <= italic("spill")_(t) dot upright("zone_cap")_(upright("gen_zone")(g, t)) & forall t in cal(T), g in cal(G) colon upright("gen_zone")(g, t) = upright("'north'") and upright("pos")_(upright("gen_zone")(g, t))(g) = 0 \ upright("arithmetic") & sum_(g in cal(G)) (frac(p_(t,g), 2) - upright("cost")_(g) + 10^(-5) dot p_(t,g) + 2.5 times 10^(-7) dot upright("cost")_(g) + 0.5 dot p_(t,g)) & >= -(sum_(g in cal(G)) p_(t,g)) dot (-3) & forall t in cal(T) \ upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") \ upright("scalar") & italic("units")_(g) & <= upright("budget") & forall g in cal(G) colon upright("cost")_(g) upright(" is defined") \