diff --git a/.prettierignore b/.prettierignore index 56297914..d2433577 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,6 +21,7 @@ CHANGELOG.md # Same rule as CHANGELOG.md above: the generator wins where nobody edits by # hand. `index.md` is not listed — it carries no generated block. docs/examples/dispatch.md +docs/examples/commitment.md docs/examples/operators.md # `tools/notation.py` writes this page's block, and it is the same trap: the diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md new file mode 100644 index 00000000..f52e77da --- /dev/null +++ b/docs/examples/commitment.md @@ -0,0 +1,169 @@ + + +# Unit commitment + +A dispatch model with a commitment decision and a start-up ramp — the +formulation [`cases:`](../reference/language/expressions.md#cases--one-quantity-a-value-per-region) +exists for. + +Read `previous_status` and then `ramp_up`. The cases are proved to +**partition** `foreach` before any data binds, which is what makes the quantity +a quantity: exactly one arm applies at every coordinate, so `ramp_up` can use +it the way it uses a parameter. A gap or an overlap is a load error naming a +witness for it. + +It prints the way a paper writes it: `ramp_up` names the quantity, and the +block itself prints once below, under **Definitions**. + + +```yaml +description: >- + Unit commitment with a start-up ramp, the formulation `cases:` exists for. + The state a unit carries into a snapshot has three regimes — a unit that is + never off, the first snapshot, and every later one — and writing them at the + constraint would fork `ramp_up` three ways. Named once, the inequality is + written once. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { values: [nuclear, gas, oil], description: generating units } + +parameters: + committable: { dims: [generator], dtype: bool, description: whether the unit may be switched off } + status_initial: { dims: [generator], description: whether the unit was running before the horizon } + p_max: { dims: [generator], description: installed capacity } + p_min: { dims: [generator], description: output floor while running } + ramp_limit: { dims: [generator], description: how far output may move between snapshots while running } + start_up_limit: { dims: [generator], description: how far it may move in the snapshot it starts in } + load: { dims: [snapshot], description: demand to be met } + cost: { dims: [generator], description: marginal cost } + +variables: + p: + description: output of a generator in a snapshot + foreach: [snapshot, generator] + bounds: { lower: 0, upper: p_max } + status: + description: whether the unit is running in a snapshot + foreach: [snapshot, generator] + domain: binary + +expressions: + previous_status: + description: the commitment state a unit carries into a snapshot + foreach: [snapshot, generator] + cases: + always_on: + when: "not committable" + expression: 1 + boundary: + when: "committable and position(snapshot) == 0" + expression: status_initial + interior: + when: "committable and position(snapshot) > 0" + expression: shift(status, over=snapshot, offset=1) + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + upper: + description: a unit that is not running produces nothing + foreach: [snapshot, generator] + expression: p <= status * p_max + lower: + description: and one that is running produces at least its floor + foreach: [snapshot, generator] + expression: p >= status * p_min + ramp_up: + description: >- + one inequality for both regimes — a unit already running is held to + `ramp_limit`, a unit starting up to `start_up_limit`. + foreach: [snapshot, generator] + expression: >- + p - shift(p, over=snapshot, offset=1, edge=0) + <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) + +objective: + sense: minimize + expression: sum(p * cost) +``` + +Unit commitment with a start-up ramp, the formulation `cases:` exists for. The state a unit carries into a snapshot has three regimes — a unit that is never off, the first snapshot, and every later one — and writing them at the constraint would fork `ramp_up` three ways. Named once, the inequality is written once. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $\mathcal{T}$ | index $t$ — `snapshot` — dispatch periods | +| $\mathcal{G}$ | index $g$ — `generator` — generating units | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $\mathrm{committable}$ | `committable` over $\mathcal{G}$ — whether the unit may be switched off | +| $\mathrm{status}^{\mathrm{initial}}$ | `status_initial` over $\mathcal{G}$ — whether the unit was running before the horizon | +| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | +| $\mathrm{p}^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ — output floor while running | +| $\mathrm{ramp\_limit}$ | `ramp_limit` over $\mathcal{G}$ — how far output may move between snapshots while running | +| $\mathrm{start\_up\_limit}$ | `start_up_limit` over $\mathcal{G}$ — how far it may move in the snapshot it starts in | +| $\mathrm{load}$ | `load` over $\mathcal{T}$ — demand to be met | +| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot | +| $\mathit{status}$ | `status` over $\mathcal{T} \times \mathcal{G}$ — whether the unit is running in a snapshot | + +Upright is what the model is given — a parameter such as $\mathrm{committable}$, a coordinate map, a label — and italic is what the solver chooses, such as $p$. An index is italic too, being what a quantifier chooses, and a set is script. + +$t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped. + +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, not the order labels sort in — counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. + +#### Objective + +$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}$$ + +#### Subject to + +**`power_balance`** + +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathrm{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ + +**`upper`** + +$$p_{t,g} \le \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`lower`** + +$$p_{t,g} \ge \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{min}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`ramp_up`** + +$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathrm{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathrm{start\_up\_limit}_{g} \cdot \left( 1 - \mathit{previous\_status}_{t,g} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Definitions + +**`previous_status`** + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathrm{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Variable domains + +**`p`** + +$$0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`status`** + +$$\mathit{status}_{t,g} \in \{0, 1\} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/index.md b/docs/examples/index.md index 64fa49fc..7fc6c786 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -16,6 +16,8 @@ printing different math — fails CI rather than going stale here. - [Least-cost dispatch](dispatch.md) — the smallest model that is a model: a balance, a bound, and a cost to minimise. +- [Unit commitment](commitment.md) — a start-up ramp, and the quantity defined + by region that lets one inequality cover both regimes. - [One construct per model](operators.md) — the operator probes: the smallest file that declares each built-in, beside the equation it renders. diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index a7111839..7ed45604 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -337,6 +337,84 @@ anything consumes the model, so a reference costs nothing at build time. It is lowered only when it is _read_, so a model with fifty named expressions that reads none pays for none. +### `cases:` — one quantity, a value per region + +Some quantities are one concept with a different value in each regime: the +level a unit carries into a snapshot, the capacity a limit is measured against. +Writing that at the constraint multiplies — three independent regimes become +eight near-identical constraints, and the equation is written eight times. +Writing it here adds: + +```yaml +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } +parameters: + committable: { dims: [generator], dtype: bool } + status_initial: { dims: [generator] } +variables: + status: { foreach: [snapshot, generator] } +expressions: + previous_status: + description: the commitment state a unit carries into a snapshot + foreach: [snapshot, generator] + cases: + always_on: { when: "not committable", expression: 1 } + boundary: + when: "committable and position(snapshot) == 0" + expression: status_initial + interior: + when: "committable and position(snapshot) > 0" + expression: shift(status, over=snapshot, offset=1) +constraints: + no_restart: + foreach: [snapshot, generator] + expression: status - previous_status <= 1 +``` + +**`when:`, not `where:`.** A case says which value a coordinate takes. It +creates no absence and deletes no row, which is what `where` means on every +other block ([absence](absence.md)) — and a cased expression has no `where` of +its own, so the word would be free to mislead. + +**The cases must partition the `foreach`**, and it is a load error when they do +not — checked before any data binds, with the overlap or the gap named: + +> the cases do not partition `['generator', 'snapshot']` — no case claims the +> value where `committable` is true, the position of `snapshot` is 1 + +Two obligations sit behind that. **Disjoint**, because two values at one +coordinate is not a quantity. **Total**, because a gap would leave the +expression undefined there, and absence +[spreads](absence.md#how-absence-travels) — every constraint referencing it +would quietly lose rows it never masked, which is the one thing a mask on a +constraint is supposed to tell you. Totality is what keeps a constraint's rows +readable at the constraint. + +Being total is not free: with no mask to narrow the frame, the cases have to +say what an absent parameter or an unnamed label gets. `not capacity` and +`not (kind == 'battery' or kind == 'h2')` are cases like any other. + +**`foreach:` is required here and refused elsewhere.** An uncased expression's +dims fall out of its body; a cased one's cannot, because no single case gives +them — `always_on` above is a scalar while its `when` is not. Each case's value +and each `when` must sit **inside** that frame; neither may widen it. + +[`examples/commitment.yaml`](../../examples/commitment.md) is the whole model +this comes from, beside the math it prints. + +**A reference names the quantity; the block prints once.** A cased expression +is the one kind that does not read well inlined — three arms are three rows +tall, so whatever follows in the equation sits beside the middle one. So a use +prints the symbol, + +$$\mathit{status}_{t,g} - \mathit{previous\_status}_{t,g} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +and the block prints under **Definitions**, which is where a paper states a +quantity defined by region: + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ## Macros A **parameterised** template. It has no dims until it is called, and each call diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 36df7332..8363f894 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -428,6 +428,30 @@ efficiency: $$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +#### `started` + +a named expression with cases, substituted where its name stands + +```yaml +started: + foreach: [snapshot, generator] + expression: slack >= on * startup_cost +``` + +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### `committed` + +the same, for the cased expression the solver decides + +```yaml +committed: + foreach: [snapshot, generator] + expression: committed_power <= p_max +``` + +$$\mathit{committed\_power}_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### `always` a mask that is only the constant true, which the language says is no mask at all — so none prints @@ -467,6 +491,46 @@ never: $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +### Definitions + +A named expression is substituted where its name is used, so it normally prints nothing under its own name. A cased one is the exception: its value is defined by region, which is a definition of its own, and the equations using it name it rather than repeating the block. + +#### `startup_cost` + +a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate + +```yaml +startup_cost: + foreach: [snapshot, generator] + cases: + opening: + when: "position(snapshot) == 0" + expression: cost * p_max + later: + when: "position(snapshot) != 0" + expression: cost +``` + +$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### `committed_power` + +the other side of the convention: an arm reaches a variable, so the quantity is one the solver decides + +```yaml +committed_power: + foreach: [snapshot, generator] + cases: + running: + when: "on" + expression: p + idle: + when: "NOT on" + expression: 0 +``` + +$$\mathit{committed\_power}_{t,g} = \begin{cases} p_{t,g} & \text{if } \mathit{on}_{t,g} \text{ exists} \cr 0 & \text{if } \neg \mathit{on}_{t,g} \text{ exists} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ### Variable domains #### `p` diff --git a/examples/commitment.yaml b/examples/commitment.yaml new file mode 100644 index 00000000..ecf9e2c3 --- /dev/null +++ b/examples/commitment.yaml @@ -0,0 +1,74 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + Unit commitment with a start-up ramp, the formulation `cases:` exists for. + The state a unit carries into a snapshot has three regimes — a unit that is + never off, the first snapshot, and every later one — and writing them at the + constraint would fork `ramp_up` three ways. Named once, the inequality is + written once. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { values: [nuclear, gas, oil], description: generating units } + +parameters: + committable: { dims: [generator], dtype: bool, description: whether the unit may be switched off } + status_initial: { dims: [generator], description: whether the unit was running before the horizon } + p_max: { dims: [generator], description: installed capacity } + p_min: { dims: [generator], description: output floor while running } + ramp_limit: { dims: [generator], description: how far output may move between snapshots while running } + start_up_limit: { dims: [generator], description: how far it may move in the snapshot it starts in } + load: { dims: [snapshot], description: demand to be met } + cost: { dims: [generator], description: marginal cost } + +variables: + p: + description: output of a generator in a snapshot + foreach: [snapshot, generator] + bounds: { lower: 0, upper: p_max } + status: + description: whether the unit is running in a snapshot + foreach: [snapshot, generator] + domain: binary + +expressions: + previous_status: + description: the commitment state a unit carries into a snapshot + foreach: [snapshot, generator] + cases: + always_on: + when: "not committable" + expression: 1 + boundary: + when: "committable and position(snapshot) == 0" + expression: status_initial + interior: + when: "committable and position(snapshot) > 0" + expression: shift(status, over=snapshot, offset=1) + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + upper: + description: a unit that is not running produces nothing + foreach: [snapshot, generator] + expression: p <= status * p_max + lower: + description: and one that is running produces at least its floor + foreach: [snapshot, generator] + expression: p >= status * p_min + ramp_up: + description: >- + one inequality for both regimes — a unit already running is held to + `ramp_limit`, a unit starting up to `start_up_limit`. + foreach: [snapshot, generator] + expression: >- + p - shift(p, over=snapshot, offset=1, edge=0) + <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) + +objective: + sense: minimize + expression: sum(p * cost) diff --git a/mkdocs.yml b/mkdocs.yml index 9a98cfbd..c434d09d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,7 @@ nav: - Examples: - examples/index.md - Least-cost dispatch: examples/dispatch.md + - Unit commitment: examples/commitment.md - One construct per model: examples/operators.md - Reference: - Language: diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index b97daa98..4d7c1109 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -127,6 +127,14 @@ "additionalProperties": false, "description": "A named quantity: one arithmetic expression, readable after a solve.\n\nWritten in YAML as a bare string, or as a mapping once it carries a\n``description:`` \u2014 and serialised back to whichever form it was written in,\nso a round trip through :meth:`Model.to_yaml` reproduces the file::\n\n expressions:\n total_generation: sum(p, over=generator)\n emissions:\n expression: sum(p * rate, over=generator)\n description: CO2 released, the quantity the cap bounds\n\nThe description matters more here than anywhere else: a named expression is\nexpanded away before the typeset walk, so its whole surface is\n``result.expression(name)`` after a solve \u2014 a name arriving in a summary\nwith nothing else to say what it counts.", "properties": { + "cases": { + "additionalProperties": { + "$ref": "#/$defs/ExpressionCase" + }, + "default": {}, + "title": "Cases", + "type": "object" + }, "description": { "anyOf": [ { @@ -140,13 +148,36 @@ "title": "Description" }, "expression": { - "title": "Expression", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expression" + }, + "foreach": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Foreach" } }, - "required": [ - "expression" - ], "title": "ExpressionBlock", "type": "object" }, @@ -155,6 +186,45 @@ } ] }, + "ExpressionCase": { + "additionalProperties": false, + "description": "One case of a named expression: the value, and where it is the value.\n\n``when`` rather than ``where``: a case selects which value a coordinate\ntakes, and creates no absence and deletes no row \u2014 which is what ``where``\nmeans on every other block (:doc:`absence `).", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "title": "Expression" + }, + "when": { + "title": "When", + "type": "string" + } + }, + "required": [ + "when", + "expression" + ], + "title": "ExpressionCase", + "type": "object" + }, "LookupBlock": { "additionalProperties": false, "description": "A named single-valued map out of a dimension (the declaration rules).\n\nTwo kinds, told apart by which field is set:\n\n- ``into:`` names the dimension the values are labels of \u2014 the *groupable*\n kind, what ``sum(by=)`` lands terms on and ``at(by=)`` reads through,\n checked for containment once data is bound rather than joined blind::\n\n lookups:\n bus_of: {over: generator, into: bus}\n send: {over: line, into: bus}\n\n- ``dtype:`` declares an inline label space \u2014 the *selection-only* kind,\n owning its values and targeting nothing, so no axis exists for terms to\n land on. Grouping into one is refused with the promotion rewrite\n (:func:`math_spec.resolution._ungroupable`)::\n\n lookups:\n period: {over: snapshot, dtype: int}\n\n``values:`` gives the map in the file \u2014 ``{label of over: value}`` \u2014 for a\nrelation small enough to read, the way a dimension's own ``values:`` does.\nA label it omits is unmapped, which is the partial case a lookup already\nallows. Without it the map is supplied at bind time under the lookup's own\nsource key, as a ``(over, label space)`` relation of the rows it has (the\ndata-binding rules). One of the two, and never neither.", diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index ca1b4f62..c3346bdb 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -60,6 +60,8 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CaseArm, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -121,6 +123,8 @@ 'BinaryOperatorNode', 'BooleanLiteralNode', 'Buildable', + 'CaseArm', + 'CasesNode', 'ComparisonNode', 'DimensionComparisonNode', 'DimensionError', diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 7492c905..758af145 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -44,6 +44,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -200,4 +201,10 @@ def _walk(node: ArithmeticNode, sign: Sign, signs: dict[str, Sign]) -> None: _walk(node.left, left, signs) _walk(node.right, right, signs) return + if isinstance(node, CasesNode): + # A selection, not a sum: whichever arm applies stands where the whole + # value stands, so each carries the same sign. + for arm in node.arms: + _walk(arm.value, sign, signs) + return assert_never(node) diff --git a/src/math_spec/degree.py b/src/math_spec/degree.py index d9ff54ff..21faa5af 100644 --- a/src/math_spec/degree.py +++ b/src/math_spec/degree.py @@ -52,6 +52,7 @@ from math_spec.errors import LanguageError from math_spec.expression_parser import ( BinaryOperatorNode, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -106,7 +107,7 @@ def carries_variable(node: ExpressionNode) -> bool: f'through resolution.expression_of() first (docs/about/architecture.md hard rule 1).' ) raise AssertionError(msg) - if isinstance(node, (UnaryOperatorNode, BinaryOperatorNode, ComparisonNode, FunctionCallNode)): + if isinstance(node, (UnaryOperatorNode, BinaryOperatorNode, ComparisonNode, FunctionCallNode, CasesNode)): return any(carries_variable(c) for c in children(node)) assert_never(node) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 01f3e53f..02610f4c 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -48,6 +48,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -137,6 +138,12 @@ def _dims( if isinstance(node, FunctionCallNode): return _dims_call(node, schema, context) + if isinstance(node, CasesNode): + # The declared frame, not the union of the arms: the cases partition + # `foreach`, so that is the shape of the quantity — an arm narrower + # than it broadcasts, exactly as a parameter with fewer dims does. + return frozenset(node.foreach) + assert_never(node) @@ -277,6 +284,22 @@ def check_schema(schema: Model) -> None: f'{sorted(frame)}.' ) + for ename, block in schema.expressions.items(): + if not block.cases: + continue + # `foreach` naming a declared dimension is `Model._names_are_sound`'s, + # with every other block's — this is only about what the cases carry. + frame = frozenset(block.foreach or []) + for case_name, case in block.cases.items(): + context = f"Named expression '{ename}', case '{case_name}'" + _check_where_dims(where_of(case.when, ns, context), schema, frame, context) + got = dims_of(expression_of(case.expression, schema, ns, context), schema, context) + if not got <= frame: + raise DimensionError( + f'{context}: the value carries dims {sorted(got - frame)} outside the foreach ' + f'{sorted(frame)}. A case is a value within the frame — it cannot widen it.' + ) + for cname, cdef in schema.constraints.items(): frame = frozenset(cdef.foreach) _check_where_dims(where_of(cdef.where, ns, f"Constraint '{cname}'"), schema, frame, f"Constraint '{cname}'") diff --git a/src/math_spec/expansion.py b/src/math_spec/expansion.py index d4f6cda9..7094f3a8 100644 --- a/src/math_spec/expansion.py +++ b/src/math_spec/expansion.py @@ -49,6 +49,8 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CaseArm, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -64,11 +66,12 @@ VariableNode, parse_expression, ) +from math_spec.where_parser import parse_where if TYPE_CHECKING: from collections.abc import Callable - from math_spec.model import MacroBlock, Model + from math_spec.model import ExpressionBlock, MacroBlock, Model #: Backstop against pathological nesting the cycle check cannot see. _MAX_DEPTH = 50 @@ -146,6 +149,11 @@ def _descend(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arithmeti [recurse(a) for a in node.args], {k: recurse(v) for k, v in node.kwargs.items()}, ) + if isinstance(node, CasesNode): + # The values only: a `when` is a mask, checked where the cases are + # declared, and neither walk has anything to say about one. + arms = tuple(CaseArm(arm.label, arm.when, recurse(arm.value)) for arm in node.arms) + return CasesNode(node.name, node.foreach, arms) assert_never(node) @@ -179,16 +187,36 @@ def _cycle(name: str, kind: str) -> None: def _parse_named(name: str, schema: Model, context: str) -> ArithmeticNode: - body = parse_expression(schema.expressions[name].expression) + block = schema.expressions[name] + if block.cases: + return _parse_cased(name, block, context) + assert block.expression is not None + body = parse_expression(block.expression) if isinstance(body, ComparisonNode): - msg = ( - f"{context}: named expression '{name}' must not contain a " - f'comparison operator. Got: {schema.expressions[name].expression!r}' - ) + msg = f"{context}: named expression '{name}' must not contain a comparison operator. Got: {block.expression!r}" raise SchemaError(msg) return body +def _parse_cased(name: str, block: ExpressionBlock, context: str) -> CasesNode: + """A cased expression, as the node that stands where its name was. + + The arms carry unresolved ``when`` masks: expansion runs before resolution, + so :mod:`math_spec.resolution` types them along with everything else. + """ + arms = [] + for label, case in block.cases.items(): + body = parse_expression(case.expression) + if isinstance(body, ComparisonNode): + msg = ( + f"{context}: named expression '{name}', case '{label}' must not contain a " + f'comparison operator. Got: {case.expression!r}' + ) + raise SchemaError(msg) + arms.append(CaseArm(label, parse_where(case.when), body)) + return CasesNode(name, tuple(block.foreach or ()), tuple(arms)) + + def _expand_macro( call: FunctionCallNode, schema: Model, diff --git a/src/math_spec/expression_parser.py b/src/math_spec/expression_parser.py index a57f8fb1..d1605e1c 100644 --- a/src/math_spec/expression_parser.py +++ b/src/math_spec/expression_parser.py @@ -20,12 +20,15 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Literal, cast +from typing import TYPE_CHECKING, Any, Literal, cast import pyparsing as pp from math_spec.errors import SchemaError +if TYPE_CHECKING: + from math_spec.where_parser import WhereNode + ComparisonOperator = Literal['<=', '>=', '=='] # --------------------------------------------------------------------------- @@ -170,6 +173,36 @@ class FunctionCallNode: kwargs: dict[str, ArithmeticNode] = field(default_factory=dict) +@dataclass +class CaseArm: + """One region of a :class:`CasesNode`: where it applies, and the value there.""" + + label: str + when: WhereNode + value: ArithmeticNode + + +@dataclass +class CasesNode: + """A value defined by region — a named expression's ``cases:``, inlined. + + Built by :mod:`math_spec.expansion` where a reference to a cased expression + stood; there is no grammar for it, because a file writes the cases on the + declaration rather than at the use site. + + The arms **partition** ``foreach`` — checked at load + (:mod:`math_spec.partition`) — so exactly one applies at every coordinate + and the value is a value rather than a choice. That is what lets this be an + ordinary arithmetic node: a consumer selects per coordinate, the way a + ``where`` already filters, and nothing about the shape of the plan depends + on data. + """ + + name: str + foreach: tuple[str, ...] + arms: tuple[CaseArm, ...] + + ArithmeticNode = ( NumberNode | NameNode @@ -183,6 +216,7 @@ class FunctionCallNode: | UnaryOperatorNode | BinaryOperatorNode | FunctionCallNode + | CasesNode ) @@ -213,6 +247,8 @@ def children(node: ExpressionNode) -> tuple[ArithmeticNode, ...]: return (node.left, node.right) if isinstance(node, FunctionCallNode): return (*node.args, *node.kwargs.values()) + if isinstance(node, CasesNode): + return tuple(arm.value for arm in node.arms) return () diff --git a/src/math_spec/model.py b/src/math_spec/model.py index a0ff3b9f..7d7416be 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -17,10 +17,11 @@ import math from importlib import metadata -from typing import TYPE_CHECKING, Any, ClassVar, Literal, Self, get_args, override +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Self, get_args, override from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, PrivateAttr, ValidationError, @@ -314,6 +315,37 @@ def _check_formals(self) -> MacroBlock: return self +def _number_is_an_expression(value: Any) -> Any: + """``expression: 0`` is how a case says zero. + + YAML reads an unquoted number as a number, and a constant is the most + common case body there is — quoting it to satisfy the annotation is a + papercut on the ordinary spelling. Booleans are left to fail: ``true`` is + not arithmetic, and an error naming the type reads better than one naming + ``'True'``. + """ + return str(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else value + + +#: An expression string, or a number written as one. +Expression = Annotated[str, BeforeValidator(_number_is_an_expression, json_schema_input_type=str | float)] + + +class ExpressionCase(_StrictBlock): + """One case of a named expression: the value, and where it is the value. + + ``when`` rather than ``where``: a case selects which value a coordinate + takes, and creates no absence and deletes no row — which is what ``where`` + means on every other block (:doc:`absence `). + """ + + _label: ClassVar[str] = 'an expression case' + + when: str + expression: Expression + description: str | None = None + + class ExpressionBlock(_StrictBlock): """A named quantity: one arithmetic expression, readable after a solve. @@ -335,7 +367,16 @@ class ExpressionBlock(_StrictBlock): _label: ClassVar[str] = 'a named expression' - expression: str + expression: Expression | None = None + #: The frame the cases partition. Required with ``cases:`` and refused + #: without: an uncased expression's dims fall out of its body, but no one + #: case's body gives a cased one its shape — a case may be a scalar where + #: its ``when`` is not. + foreach: list[str] | None = None + #: The regions this quantity is defined by, keyed by the name the LaTeX + #: prints. They must **partition** ``foreach``: one value per coordinate, + #: and a value at every one. + cases: dict[str, ExpressionCase] = {} description: str | None = None @model_validator(mode='before') @@ -343,6 +384,34 @@ class ExpressionBlock(_StrictBlock): def _from_string(cls, data: Any) -> Any: return {'expression': data} if isinstance(data, str) else data + @property + def referenced_dims(self) -> list[str]: + """``foreach`` where there is one — only a cased expression declares it.""" + return self.foreach or [] + + @model_validator(mode='after') + def _one_form_or_the_other(self) -> Self: + if bool(self.cases) == (self.expression is not None): + got = 'both' if self.cases else 'neither' + msg = ( + f'a named expression is one `expression:` or a set of `cases:`, and this has {got}. ' + f'Cases are for a quantity whose value varies by region; one expression is everything else.' + ) + raise ValueError(msg) + if self.cases and self.foreach is None: + msg = ( + '`cases:` needs a `foreach:` — it is the frame the cases partition, and no one ' + "case's body gives it, since a case may be a scalar where its `when` is not." + ) + raise ValueError(msg) + if self.foreach is not None and not self.cases: + msg = ( + '`foreach:` is only for a named expression with `cases:`. Without them the dims fall ' + 'out of the body, and declaring a second answer is a second thing to keep true.' + ) + raise ValueError(msg) + return self + @classmethod @override def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: @@ -350,8 +419,15 @@ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonS return _also_written_as(core_schema, handler, {'type': 'string'}) @model_serializer - def _as_written(self) -> str | dict[str, str]: + def _as_written(self) -> str | dict[str, Any]: + if self.cases: + written: dict[str, Any] = {'foreach': list(self.foreach or [])} + if self.description is not None: + written['description'] = self.description + written['cases'] = {name: case.model_dump() for name, case in self.cases.items()} + return written if self.description is None: + assert self.expression is not None return self.expression return {'expression': self.expression, 'description': self.description} @@ -847,6 +923,7 @@ def _validate_references(self) -> Model: ('Parameter', self.parameters), ('Variable', self.variables), ('Constraint', self.constraints), + ('Named expression', self.expressions), ) for name, item in group.items() for d in item.referenced_dims diff --git a/src/math_spec/partition.py b/src/math_spec/partition.py index 9f53ecde..b1b4bccf 100644 --- a/src/math_spec/partition.py +++ b/src/math_spec/partition.py @@ -45,8 +45,9 @@ conservative — this refuses case sets that would have been fine, and admits none that would not. -Not wired into the schema: ``cases:`` is not a key ``expressions:`` accepts -yet (energy-models/math-spec#2). This is the decision procedure only. +Run at load by :func:`math_spec.validation.validate_expressions`, once per +cased expression, so a case set that is not a partition is a load error rather +than a build-time surprise. """ from __future__ import annotations diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 9169a6d5..2e67ea3c 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -30,6 +30,8 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CaseArm, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -338,6 +340,17 @@ def _resolve_arith(node: ArithmeticNode, ns: Namespace, context: str, errors: li ) return node + if isinstance(node, CasesNode): + arms = tuple( + CaseArm( + arm.label, + _resolve_where(arm.when, ns, f"{context}, case '{arm.label}'", errors), + _resolve_arith(arm.value, ns, f"{context}, case '{arm.label}'", errors), + ) + for arm in node.arms + ) + return CasesNode(node.name, node.foreach, arms) + assert_never(node) diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index 0e4b7d68..19fa2687 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -39,12 +39,14 @@ from __future__ import annotations +from dataclasses import is_dataclass from typing import TYPE_CHECKING, Any -from math_spec import Namespace, expand_piecewise, load_model +from math_spec import Namespace, expand_piecewise, expression_of, load_model +from math_spec.expression_parser import VariableNode from math_spec.typeset.latex import LatexFormat from math_spec.typeset.markdown import MarkdownFormat -from math_spec.typeset.symbols import Symbols, SymbolTable +from math_spec.typeset.symbols import Symbols, SymbolTable, printed_expressions from math_spec.typeset.typst import TypstFormat from math_spec.typeset.walk import Walk @@ -52,7 +54,7 @@ from collections.abc import Mapping from pathlib import Path - from math_spec import Model + from math_spec import Buildable, Model from math_spec.typeset.format import Format __all__ = ['FORMATS', 'SymbolTable', 'to_latex', 'to_markdown', 'to_typst', 'typeset'] @@ -100,12 +102,20 @@ def typeset( if symbols is None: symbols = SymbolTable(fmt.notation) table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) - walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) - + namespace = Namespace.of(schema) + chosen = _reaching_a_variable(schema, namespace) + walk = Walk(schema, namespace, Symbols(schema, fmt, table.checked_against(schema), chosen), fmt) + + # `definitions()` prints what the other sections reached, so it runs after + # them — a statement apart rather than a call inside the list, where the + # order it depends on would be invisible. + objective, constraints, variables = walk.objective(), walk.constraints(), walk.variables() + definitions = walk.definitions() sections = [ - ('Objective', walk.objective()), - ('Subject to', walk.constraints()), - ('Variable domains', walk.variables()), + ('Objective', objective), + ('Subject to', constraints), + ('Definitions', definitions), + ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] @@ -118,6 +128,39 @@ def typeset( return fmt.document([*blocks, *rendered], standalone=standalone) +def _reaching_a_variable(schema: Buildable, namespace: Namespace) -> frozenset[str]: + """The cased expressions that print italic, because the solver decides them. + + Upright is what the model is given and italic is what it chooses, and a + cased expression is on whichever side its arms put it: one whose every arm + is data is data, however many regions it is written in. An arm may name + another cased expression, which resolves to the arms of that one, so the + walk over the resolved node answers for the whole chain. + """ + reaching = set() + for name in printed_expressions(schema): + for label, case in schema.expressions[name].cases.items(): + node = expression_of(case.expression, schema, namespace, f"expression '{name}', case '{label}'") + if _touches_a_variable(node): + reaching.add(name) + break + return frozenset(reaching) + + +def _touches_a_variable(node: object) -> bool: + """Whether *node*'s tree holds a variable anywhere, arms of a case included.""" + if isinstance(node, VariableNode): + return True + if not is_dataclass(node): + return False + children = [ + child + for value in vars(node).values() + for child in (value.values() if isinstance(value, dict) else value if isinstance(value, list) else [value]) + ] + return any(_touches_a_variable(child) for child in children) + + def to_latex(model: str | Path | dict[str, Any] | Model, **options: Any) -> str: """Render *model* as LaTeX (amsmath ``align``). See :func:`typeset`.""" return typeset(model, FORMATS['latex'], **options) diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 48358e53..baae59ed 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -202,6 +202,16 @@ def fraction(self, numerator: str, denominator: str) -> str: ... def summation(self, domain: str, body: str) -> str: ... + def cases(self, arms: list[tuple[str, str]]) -> str: + """A value defined by region: ``(value, condition)`` per arm. + + The arms partition the frame, so every one carries a condition and + there is no otherwise-arm to print last. They arrive in the order the + file declares them and print in it — nothing depends on the order, but + a reader comparing the page to the file does. + """ + ... + def apply(self, function: str, argument: str) -> str: """A coordinate map applied to an index: ``bus(g)``.""" ... diff --git a/src/math_spec/typeset/latex.py b/src/math_spec/typeset/latex.py index e523df4d..432bf063 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -42,6 +42,22 @@ def _escape(text: str) -> str: return ''.join(_ESCAPES.get(c, c) for c in text) +#: The row separator inside a `cases` block. Markdown passes its own — see +#: :meth:`~math_spec.typeset.markdown.MarkdownFormat.cases`. +CASES_ROW = r' \\ ' + + +def cases_block(arms: list[tuple[str, str]], separator: str) -> str: + r"""A `cases` environment, with *separator* between the rows. + + The separator is a parameter rather than a fixed ``\\`` because Markdown + needs a different one and rewriting this string afterwards would go quiet + the moment the spelling here changed. + """ + rows = separator.join(rf'{value} & \text{{if }} {condition}' for value, condition in arms) + return rf'\begin{{cases}} {rows} \end{{cases}}' + + class LatexFormat: """See :class:`math_spec.typeset.format.Format`.""" @@ -128,6 +144,9 @@ def cardinality(self, inner: str) -> str: def fraction(self, numerator: str, denominator: str) -> str: return rf'\frac{{{numerator}}}{{{denominator}}}' + def cases(self, arms: list[tuple[str, str]]) -> str: + return cases_block(arms, CASES_ROW) + def summation(self, domain: str, body: str) -> str: return rf'\sum_{{{domain}}} {body}' diff --git a/src/math_spec/typeset/markdown.py b/src/math_spec/typeset/markdown.py index 0a9924f0..ae52ef77 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, ClassVar -from math_spec.typeset.latex import LatexFormat +from math_spec.typeset.latex import LatexFormat, cases_block if TYPE_CHECKING: from collections.abc import Mapping @@ -92,6 +92,15 @@ def fraction(self, numerator: str, denominator: str) -> str: def summation(self, domain: str, body: str) -> str: return _LATEX.summation(domain, body) + def cases(self, arms: list[tuple[str, str]]) -> str: + r"""LaTeX's block, with TeX's own row primitive in place of ``\\``. + + Markdown's escape pass eats one of the two backslashes, so MathJax + would receive a single one and never break the row. ``\\cr`` is what + ``\\`` expands to anyway, and carries no punctuation to escape. + """ + return cases_block(arms, r' \cr ') + def apply(self, function: str, argument: str) -> str: return _LATEX.apply(function, argument) diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 48ba994a..3af647ca 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -106,6 +106,17 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv return _word(name, fmt, given=given) +def printed_expressions(schema: Buildable) -> frozenset[str]: + """The named expressions that reach the page under their own name. + + A named expression is substituted where it is used, so it normally prints + nothing a symbol could stand for. A **cased** one is the exception: its + value is defined by region, which reads as a definition of its own and is + referred to by name from the equations that use it. + """ + return frozenset(name for name, block in schema.expressions.items() if block.cases) + + class Symbols: r"""How every declared name prints: overrides first, derivation for the rest. @@ -125,16 +136,24 @@ class Symbols: SchemaError: If *table* is written in a notation *fmt* does not read. """ - def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: + def __init__( + self, schema: Buildable, fmt: Format, table: SymbolTable, chosen: frozenset[str] = frozenset() + ) -> None: if table.notation != fmt.notation: msg = ( f'symbol table: written in {table.notation}, but this is a {fmt.notation} render ' f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) + printed = printed_expressions(schema) # quantities only — see `_derive_name_symbol` for why an axis is not a - # head a qualifier may hang off - declared = frozenset({*schema.parameters, *schema.variables}) + # head a qualifier may hang off. A cased expression is one of them: it + # is a quantity the file names, which is why it prints at all. + declared = frozenset({*schema.parameters, *schema.variables, *printed}) + # *chosen* is the cased expressions that reach a variable, which + # `typeset` works out because it has the namespace to resolve an arm + # with. Everything else the file names is given: a parameter, and a + # cased expression whose every arm is one. #: Names whose symbol came from the table rather than the derivation. #: The convention note quotes only the others: a table is printed @@ -146,8 +165,8 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: self.name: dict[str, str] = { name: table.names[name] if name in table.names - else _derive_name_symbol(name, declared, fmt, given=name in schema.parameters) - for name in (*schema.parameters, *schema.variables) + else _derive_name_symbol(name, declared, fmt, given=name not in schema.variables and name not in chosen) + for name in (*schema.parameters, *schema.variables, *printed) } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -265,7 +284,7 @@ def load(cls, source: str | Path | Mapping[str, Any]) -> SymbolTable: def checked_against(self, schema: Buildable) -> SymbolTable: """Reject entries naming nothing in *schema*, with the near miss.""" dims = set(schema.dimensions) - everything = dims | set(schema.parameters) | set(schema.variables) + everything = dims | set(schema.parameters) | set(schema.variables) | printed_expressions(schema) errors = [ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims), *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything), diff --git a/src/math_spec/typeset/typst.py b/src/math_spec/typeset/typst.py index a7a87b4b..0f794a7f 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -138,6 +138,10 @@ def cardinality(self, inner: str) -> str: def fraction(self, numerator: str, denominator: str) -> str: return f'frac({numerator}, {denominator})' + def cases(self, arms: list[tuple[str, str]]) -> str: + rows = ', '.join(f'{value} & upright("if ") {condition}' for value, condition in arms) + return f'cases({rows})' + def summation(self, domain: str, body: str) -> str: return f'sum_({domain}) {body}' diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index e71f164e..3db6c166 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -24,6 +24,7 @@ ArithmeticNode, BinaryOperatorNode, BooleanLiteralNode, + CasesNode, ComparisonNode, DimensionComparisonNode, DimensionNode, @@ -229,6 +230,9 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.grouped = False self.positions: set[str] = set() self.numeric_coordinates: set[str] = set() + #: Cased expressions met while rendering, in first-use order. Each one + #: prints once, as a definition of its own; see :meth:`definitions`. + self.defined: dict[str, CasesNode] = {} def op(self, name: str) -> str: return self.format.operators[name] @@ -302,6 +306,13 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: if isinstance(node, FunctionCallNode): return self._call(node, ctx) + if isinstance(node, CasesNode): + # The symbol, not the cases: the block prints once as a definition + # of its own, and a use of it reads like any other quantity. See + # :meth:`definitions` for why. + self.defined.setdefault(node.name, node) + return ctx.indexed(self.symbols.name[node.name], list(node.foreach)), _ATOM + if isinstance(node, (NameNode, NameListNode, KeywordNode, DimensionNode, LookupNode, EdgeNode)): msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.' raise AssertionError(msg) @@ -627,6 +638,40 @@ def constraints(self) -> list[Line]: ) return lines + def definitions(self) -> list[Line]: + """One line per cased expression the equations used, defining it. + + Inlining a cases block where its name stood is what the AST does, and + it is the wrong thing to print: a three-arm block is three rows tall, + so whatever follows it in the equation sits beside its middle arm and + reads as part of that arm's condition. Worse, a quantity written once + in the file would be written once per use on the page — the opposite of + what naming it was for. + + So a use prints the symbol and the block prints here, which is how a + paper states a quantity defined by region. Run this **after** the + sections that use it: what lands here is what they reached, and an arm + may itself name another cased expression, so the loop runs to a + fixpoint rather than over one pass. + """ + lines: list[Line] = [] + done: set[str] = set() + while pending := [name for name in self.defined if name not in done]: + for name in pending: + done.add(name) + node = self.defined[name] + ctx = self.context(ceiling=2) + arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] + lines.append( + Line( + label=name, + left=ctx.indexed(self.symbols.name[name], list(node.foreach)), + right=f'{self.op("equal")} {self.format.cases(arms)}', + condition=self.quantifier(list(node.foreach), ''), + ) + ) + return lines + def variables(self) -> list[Line]: """One line per variable, and one more for a set the variable carries. diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 834359ce..d59a63bc 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -35,6 +35,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -48,10 +49,11 @@ UnaryOperatorNode, VariableNode, ) -from math_spec.model import Model +from math_spec.model import ExpressionBlock, Model from math_spec.operators import BUILTINS, unknown_operator_message +from math_spec.partition import Case, check_partition from math_spec.resolution import Namespace, resolve_expression, resolve_where -from math_spec.where_parser import parse_where +from math_spec.where_parser import WhereNode, parse_where if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -135,7 +137,12 @@ def validate_expressions(schema: Model) -> None: _check_template_names(body_ast, macro.template, context, ns, formals, errors) for ename, block in schema.expressions.items(): - _check_expression(block.expression, schema, ns, f"Named expression '{ename}'", errors, comparison=False) + context = f"Named expression '{ename}'" + if block.cases: + _check_cases(ename, block, schema, ns, errors) + else: + assert block.expression is not None + _check_expression(block.expression, schema, ns, context, errors, comparison=False) for vname, vdef in schema.variables.items(): _check_where(vdef.where, ns, f"Variable '{vname}'", errors) @@ -261,6 +268,41 @@ def _parse_expand( return None +def _check_cases(name: str, block: ExpressionBlock, schema: Model, ns: Namespace, errors: list[str]) -> None: + """Every case resolves, sits inside the frame, and together they partition it. + + The partition is the whole claim: one value per coordinate makes this *one* + quantity, and a value at every coordinate makes it total — which is what + lets a constraint referencing it keep a row set readable at the constraint + (:mod:`math_spec.partition`). + """ + masks: list[Case] = [] + for case_name, case in block.cases.items(): + context = f"Named expression '{name}', case '{case_name}'" + _check_expression(case.expression, schema, ns, context, errors, comparison=False) + mask = _resolved_when(case.when, ns, context, errors) + if mask is not None: + masks.append(Case(case_name, mask)) + + if len(masks) != len(block.cases): + return # a `when` did not resolve, so the partition would misreport + verdict = check_partition(masks, schema) + if not verdict.ok: + frame = sorted(block.foreach or []) + errors.append(f"Named expression '{name}': the cases do not partition {frame} — {verdict.message()}") + + +def _resolved_when(text: str, ns: Namespace, context: str, errors: list[str]) -> WhereNode | None: + before = len(errors) + try: + node = parse_where(text) + except ValueError as e: + errors.append(f'{context}: {e}') + return None + resolved = resolve_where(node, ns, context, errors) + return None if len(errors) > before else resolved + + def _check_expression( expression: str, schema: Model, @@ -393,4 +435,11 @@ def _check_template_names( _check_template_names(value, template, context, ns, formals, errors) return + if isinstance(node, CasesNode): + # A template may name a cased expression, and expansion puts its arms + # here. The `when` masks are the declaration's, checked there. + for arm in node.arms: + _check_template_names(arm.value, template, context, ns, formals, errors) + return + assert_never(node) diff --git a/tests/test_validation.py b/tests/test_validation.py index b3393d08..2edf156c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -7,12 +7,16 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING +import re +from typing import TYPE_CHECKING, Any, ClassVar import pytest -from math_spec.errors import LanguageError -from math_spec.resolution import Namespace, where_of +from math_spec._yaml import parse_yaml +from math_spec.dimensions import DimensionError +from math_spec.errors import LanguageError, SchemaError +from math_spec.expression_parser import BinaryOperatorNode, CasesNode, ComparisonNode, FunctionCallNode +from math_spec.resolution import Namespace, expression_of, where_of from math_spec.validation import load_model, validate_expressions from math_spec.where_parser import DimensionPositionNode @@ -355,3 +359,168 @@ def test_it_refuses(self, mask: str, fragments: list[str]): where_of(mask, Namespace.of(schema), 'the mask') for fragment in fragments: assert fragment in str(excinfo.value) + + +class TestExpressionCases: + """`cases:` on a named expression — the declaration and what it must prove. + + The partition itself is `tests/test_partition.py`; these are the rules the + block carries: which forms load, which dims are legal, and that a case set + that is not a partition is a load error rather than a build-time surprise. + """ + + @staticmethod + def _schema(**cases: dict[str, str]) -> dict[str, Any]: + return { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {'dtype': 'str'}}, + 'parameters': { + 'committable': {'dims': ['generator'], 'dtype': 'bool'}, + 'status_initial': {'dims': ['generator']}, + 'load': {'dims': ['snapshot']}, + }, + 'variables': {'status': {'foreach': ['snapshot', 'generator']}}, + 'expressions': {'previous_status': {'foreach': ['snapshot', 'generator'], 'cases': cases}}, + } + + #: The quantity #2 factors a PyPSA ramp limit into: three regimes, one of + #: them a scalar, so no single case gives the frame. + PREVIOUS_STATUS: ClassVar[dict[str, dict[str, str]]] = { + 'always_on': {'when': 'not committable', 'expression': '1'}, + 'boundary': {'when': 'committable and position(snapshot) == 0', 'expression': 'status_initial'}, + 'interior': { + 'when': 'committable and position(snapshot) > 0', + 'expression': 'shift(status, over=snapshot, offset=1)', + }, + } + + def test_a_partition_loads(self): + validate_expressions(load_model(self._schema(**self.PREVIOUS_STATUS))) + + def test_it_round_trips(self): + """The written form survives `to_yaml`, cases and all.""" + schema = load_model(self._schema(**self.PREVIOUS_STATUS)) + again = load_model(parse_yaml(schema.to_yaml(), 'round trip')) + assert again.expressions['previous_status'].cases.keys() == self.PREVIOUS_STATUS.keys() + assert again.expressions['previous_status'].foreach == ['snapshot', 'generator'] + assert again.expressions['previous_status'].cases['always_on'].when == 'not committable' + + def test_a_constant_case_may_be_written_as_a_number(self): + """`expression: 1` is the ordinary spelling, and YAML reads it as an int. + + A constant is the most common case body there is, so quoting it to + satisfy the annotation would be a papercut on the ordinary file. A + boolean still fails: `true` is not arithmetic. + """ + cases = dict(self.PREVIOUS_STATUS) + cases['always_on'] = {'when': 'not committable', 'expression': 1} + model = load_model(self._schema(**cases)) + validate_expressions(model) + assert model.expressions['previous_status'].cases['always_on'].expression == '1' + + cases['always_on'] = {'when': 'not committable', 'expression': True} + with pytest.raises(SchemaError, match='valid string'): + load_model(self._schema(**cases)) + + def test_a_gap_is_a_load_error(self): + cases = {k: v for k, v in self.PREVIOUS_STATUS.items() if k != 'interior'} + with pytest.raises(SchemaError, match='do not partition'): + validate_expressions(load_model(self._schema(**cases))) + + def test_an_overlap_is_a_load_error(self): + cases = dict(self.PREVIOUS_STATUS) + cases['boundary'] = {'when': 'position(snapshot) == 0', 'expression': 'status_initial'} + with pytest.raises(SchemaError, match='do not partition'): + validate_expressions(load_model(self._schema(**cases))) + + @pytest.mark.parametrize( + ('block', 'fragment'), + [ + ({'expression': 'load', 'cases': {'a': {'when': 'True', 'expression': 'load'}}}, 'has both'), + ({'foreach': ['snapshot'], 'description': 'no value at all'}, 'has neither'), + ({'cases': {'a': {'when': 'True', 'expression': 'load'}}}, '`cases:` needs a `foreach:`'), + ({'foreach': ['snapshot'], 'expression': 'load'}, '`foreach:` is only for'), + ], + ids=['both forms', 'neither form', 'cases without foreach', 'foreach without cases'], + ) + def test_the_two_forms_do_not_mix(self, block: dict[str, Any], fragment: str): + schema = self._schema(**self.PREVIOUS_STATUS) + schema['expressions'] = {'x': block} + with pytest.raises(SchemaError, match=re.escape(fragment)): + load_model(schema) + + def test_the_frame_must_name_dimensions(self): + """Refused by the same check, in the same words, as every other block's frame.""" + schema = self._schema(**self.PREVIOUS_STATUS) + schema['expressions']['previous_status']['foreach'] = ['snapshot', 'nope'] + with pytest.raises(SchemaError, match="references undeclared dimension 'nope'"): + load_model(schema) + + def test_a_case_may_not_widen_the_frame(self): + """A case is a value *within* the frame — the `when` cannot reach outside it. + + Reported by the same check, in the same words, that holds a variable's + or a constraint's mask to its frame: a `when` is a mask like any other. + """ + schema = self._schema(**self.PREVIOUS_STATUS) + schema['expressions']['previous_status']['foreach'] = ['generator'] + with pytest.raises(DimensionError, match="not in the frame \\['generator'\\]"): + validate_expressions(load_model(schema)) + + def test_a_constraint_names_it_and_gets_the_cases(self): + """What the feature is for: the inequality is written once, the value by region. + + The name expands to a node carrying every arm rather than to one body, + which is what a reference to a quantity with a value per region has to + mean. The partition proved on the declaration is what makes that node + a value: exactly one arm applies at each coordinate. + """ + schema = self._schema(**self.PREVIOUS_STATUS) + schema['constraints'] = {'c': {'foreach': ['snapshot', 'generator'], 'expression': 'status <= previous_status'}} + model = load_model(schema) + validate_expressions(model) + node = expression_of(model.constraints['c'].expression, model, Namespace.of(model), "Constraint 'c'") + assert isinstance(node, ComparisonNode) + assert isinstance(node.right, CasesNode) + assert [arm.label for arm in node.right.arms] == list(self.PREVIOUS_STATUS) + + def test_it_carries_the_frame_it_declares(self): + """The declared `foreach`, not the union of the arms. + + `always_on` is a scalar and `boundary` a column, so a quantity taking + its shape from the arms would fit a constraint over `snapshot` alone. + It does not: the cases partition the frame, so the frame is the shape. + """ + schema = self._schema(**self.PREVIOUS_STATUS) + schema['constraints'] = {'c': {'foreach': ['snapshot'], 'expression': 'load <= previous_status'}} + with pytest.raises(DimensionError, match='generator'): + validate_expressions(load_model(schema)) + + def test_a_macro_may_name_one(self): + """A template reaching a cased expression carries its arms to the call site.""" + schema = self._schema(**self.PREVIOUS_STATUS) + schema['macros'] = {'step': {'args': ['now'], 'template': 'now - previous_status'}} + schema['constraints'] = {'c': {'foreach': ['snapshot', 'generator'], 'expression': 'step(status) <= 1'}} + model = load_model(schema) + validate_expressions(model) + node = expression_of(model.constraints['c'].expression, model, Namespace.of(model), "Constraint 'c'") + assert isinstance(node, ComparisonNode) + assert isinstance(node.left, BinaryOperatorNode) + assert isinstance(node.left.right, CasesNode) + + def test_an_arm_may_name_another_expression(self): + """Substitution runs through the arms, so a case body is a body like any other.""" + schema = self._schema(**self.PREVIOUS_STATUS) + schema['expressions']['carried_over'] = {'expression': 'shift(status, over=snapshot, offset=1)'} + schema['expressions']['previous_status']['cases']['interior'] = { + 'when': 'committable and position(snapshot) > 0', + 'expression': 'carried_over', + } + schema['constraints'] = {'c': {'foreach': ['snapshot', 'generator'], 'expression': 'status <= previous_status'}} + model = load_model(schema) + validate_expressions(model) + node = expression_of(model.constraints['c'].expression, model, Namespace.of(model), "Constraint 'c'") + assert isinstance(node, ComparisonNode) + assert isinstance(node.right, CasesNode) + interior = next(arm for arm in node.right.arms if arm.label == 'interior') + assert isinstance(interior.value, FunctionCallNode) + assert interior.value.name == 'shift' diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index e4840a01..4049aeb5 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -92,11 +92,19 @@ \text{last} && \mathit{on}_{t,g} & = 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ \text{efficiency} && p_{t,g} & \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \mathrm{startup\_cost}_{t,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{committed} && \mathit{committed\_power}_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \wedge \mathit{spill}_{t} \text{ exists} \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \end{align} +\paragraph{Definitions} +\begin{align} +\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{committed\_power} && \mathit{committed\_power}_{t,g} & = \begin{cases} p_{t,g} & \text{if } \mathit{on}_{t,g} \text{ exists} \\ 0 & \text{if } \neg \mathit{on}_{t,g} \text{ exists} \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\end{align} + \paragraph{Variable domains} \begin{align} \text{p} && \mathrm{p}^{\mathrm{min}}_{g} \le p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{p}^{\mathrm{max}}_{g} > 0 \wedge \neg \mathrm{is\_flexible}_{g} \vee \mathrm{p}^{\mathrm{min}}_{g} > 0 \right) \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index 746cea9d..8a6e46ec 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -158,6 +158,14 @@ $$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mat $$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +**`started`** + +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`committed`** + +$$\mathit{committed\_power}_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + **`always`** $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T}$$ @@ -170,6 +178,16 @@ $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +#### Definitions + +**`startup_cost`** + +$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`committed_power`** + +$$\mathit{committed\_power}_{t,g} = \begin{cases} p_{t,g} & \text{if } \mathit{on}_{t,g} \text{ exists} \cr 0 & \text{if } \neg \mathit{on}_{t,g} \text{ exists} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### Variable domains **`p`** diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 35113da2..805ba29c 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -85,6 +85,26 @@ sos: over: generator type: 2 +expressions: + startup_cost: # a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate + foreach: [snapshot, generator] + cases: + opening: + when: "position(snapshot) == 0" + expression: cost * p_max + later: + when: "position(snapshot) != 0" + expression: cost + committed_power: # the other side of the convention: an arm reaches a variable, so the quantity is one the solver decides + foreach: [snapshot, generator] + cases: + running: + when: "on" + expression: p + idle: + when: "NOT on" + expression: 0 + constraints: balance: # sum over a lookup foreach: [snapshot, bus] @@ -165,6 +185,12 @@ constraints: efficiency: # a Greek-named parameter, which is given — so the convention wins and it prints as the word foreach: [snapshot, generator] expression: p <= eta * p_max + started: # a named expression with cases, substituted where its name stands + foreach: [snapshot, generator] + expression: slack >= on * startup_cost + committed: # the same, for the cased expression the solver decides + foreach: [snapshot, generator] + expression: committed_power <= p_max always: # a mask that is only the constant true, which the language says is no mask at all — so none prints foreach: [snapshot] where: "true" diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index d5336a96..1fb626a1 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -81,10 +81,17 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("last") & italic("on")_(t,g) & = 0 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = abs(cal(T)) - 1 or upright("pos")_(upright("season_of")(t))(t) = abs(cal(T)_(upright("season_of")(t))) - 1) \ upright("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ upright("efficiency") & p_(t,g) & <= upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ + upright("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot upright("startup_cost")_(t,g) & forall t in cal(T), g in cal(G) \ + upright("committed") & italic("committed_power")_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top and italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ +== Definitions +#set math.equation(numbering: "(1)") +$ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) dot upright("p")^(upright("max"))_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) & upright("if ") upright("pos")(t) != 0) & forall t in cal(T), g in cal(G) \ + upright("committed_power") & italic("committed_power")_(t,g) & = cases(p_(t,g) & upright("if ") italic("on")_(t,g) upright(" exists"), 0 & upright("if ") not italic("on")_(t,g) upright(" exists")) & forall t in cal(T), g in cal(G) $ + == Variable domains #set math.equation(numbering: "(1)") $ upright("p") & upright("p")^(upright("min"))_(g) <= p_(t,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon (upright("p")^(upright("max"))_(g) > 0 and not upright("is_flexible")_(g) or upright("p")^(upright("min"))_(g) > 0) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index adf0aa46..3814a02f 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -32,7 +32,16 @@ from math_spec.operators import BUILTIN_NAMES from math_spec.piecewise import expand_piecewise from math_spec.resolution import Namespace, expression_of, where_of -from math_spec.typeset import FORMATS, SymbolTable, to_latex, to_markdown, to_typst, typeset, walk +from math_spec.typeset import ( + FORMATS, + SymbolTable, + _reaching_a_variable, + to_latex, + to_markdown, + to_typst, + typeset, + walk, +) from math_spec.typeset.format import OPERATOR_NAMES from math_spec.typeset.symbols import Symbols, _derive_name_symbol from math_spec.validation import load_model @@ -470,7 +479,10 @@ def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(fmt: F @EVERY_FORMAT def test_macros_and_named_expressions_are_expanded_away(fmt: Format): - """What prints is the math a backend builds, not the sugar it was spelled with.""" + """What prints is the math a backend builds, not the sugar it was spelled with. + + A cased expression is the one exception, and the test below it says why. + """ model = override( DISPATCH, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.power_balance.expression': 'supply == load'}, @@ -478,6 +490,102 @@ def test_macros_and_named_expressions_are_expanded_away(fmt: Format): assert 'supply' not in typeset(model, fmt, legend=False) +#: The dispatch model, with a quantity defined by region and a constraint using +#: it. `first` is a column and `later` a scalar, so the arms alone would not +#: give the quantity its shape — the declared `foreach` does. +CASED = override( + DISPATCH, + **{ + 'expressions.headroom': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}, + }, +) + + +@EVERY_FORMAT +def test_a_cased_expression_is_the_exception_that_keeps_its_name(fmt: Format): + """It prints once, as a definition, and its uses name it. + + The other way round — the block inlined at each use — is what the AST does + and the wrong thing to print twice over: a quantity written once in the + file would be written once per use on the page, and a block three arms tall + puts whatever follows it beside its middle arm. + """ + rendered = typeset(CASED, fmt, legend=False) + # upright: every arm of this one is a parameter, so the quantity is given. + # Counted indexed, because Typst spells a row label and an upright symbol + # the same way and only the symbol carries the dims. + indexed = fmt.subscript(fmt.upright('headroom'), ['t', 'g']) + assert rendered.count(indexed) == 2, 'one use and one definition, no more' + assert _section(rendered, fmt) == ['Objective', 'Subject to', 'Definitions', 'Variable domains'] + + +@EVERY_FORMAT +def test_a_definition_is_printed_only_where_something_reached_it(fmt: Format): + """An expression nobody names is sugar nobody unwrapped — it prints nothing.""" + unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + rendered = typeset(unused, fmt, legend=False) + assert 'headroom' not in rendered + assert 'Definitions' not in rendered + + +@EVERY_FORMAT +def test_a_case_is_given_when_its_values_are_however_its_regions_are_chosen(fmt: Format): + """A `when` mentioning a variable does not make the quantity one. + + The two halves of a case say different things: the value decides *what the + quantity is*, and the mask decides *which region applies*. A variable in a + mask is asking whether the variable exists at a coordinate, which the model + settles when it is built and not something a solver returns — so a cased + expression whose every arm is a parameter is data, whatever its regions are + cut by. Only a value reaching a variable moves it. + """ + masked = override( + CASED, + **{ + 'expressions.headroom.cases': { + 'running': {'when': 'p', 'expression': 'p_max'}, + 'idle': {'when': 'NOT p', 'expression': 0}, + } + }, + ) + rendered = typeset(masked, fmt, legend=False) + assert fmt.upright('headroom') in rendered, 'every arm is a parameter, so the quantity is given' + assert fmt.italic('headroom') not in rendered + + +@EVERY_FORMAT +def test_a_definition_naming_another_one_prints_both(fmt: Format): + """The arms are walked too, so the collection runs to a fixpoint.""" + nested = override( + CASED, + **{ + 'expressions.opening_cost': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'headroom * cost'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare.expression': 'p <= opening_cost', + }, + ) + rendered = typeset(nested, fmt, legend=False) + assert fmt.upright('headroom') in rendered, 'the inner definition was reached through an arm' + assert rendered.count(fmt.subscript(fmt.upright('opening_cost'), ['t', 'g'])) == 2 + + +def _section(rendered: str, fmt: Format) -> list[str]: + """The section titles *fmt* printed, in order.""" + return [title for title in ('Objective', 'Subject to', 'Definitions', 'Variable domains') if title in rendered] + + @EVERY_FORMAT def test_an_invalid_model_fails_the_same_way_check_does(fmt: Format): broken = override(DISPATCH, **{'objective.expression': 'p * nonexistent'}) @@ -580,11 +688,15 @@ def test_nothing_the_model_is_given_prints_italic(): lands in one of these two nets. """ schema = expand_piecewise(load_model(golden.MODEL)) + # a cased expression is on whichever side its arms put it, which is what + # `_reaching_a_variable` decides — one whose every arm is data is data + chosen = set(schema.variables) | _reaching_a_variable(schema, Namespace.of(schema)) italic = {m.replace(r'\_', '_') for m in re.findall(r'\\mathit\{([^}]*)\}', to_latex(golden.MODEL))} - assert italic <= set(schema.variables), ( - f'{sorted(italic - set(schema.variables))} print italic and are not variables — ' + assert italic <= chosen, ( + f'{sorted(italic - chosen)} print italic and are not quantities the solver decides — ' f'upright is what the model is given' ) + assert 'startup_cost' not in italic, 'a cased expression whose every arm is a parameter is given' symbols = Symbols(schema, LATEX, SymbolTable('latex')) given = {name: symbols.name[name] for name in schema.parameters} @@ -1125,6 +1237,21 @@ def test_the_table_overrides_and_the_rest_is_still_derived(): assert r'u \in \mathcal{U}' in tex +def test_the_table_may_rename_a_cased_expression_but_not_a_plain_one(): + """It names what prints, and a cased expression is the only expression that does. + + An entry that never applies is the failure mode the table is strict about: + a reader writes a spelling, sees the old symbol, and has nothing to tell + them why. + """ + tex = to_latex(CASED, symbols={'notation': 'latex', 'names': {'headroom': r'\bar h'}}, legend=False) + assert r'\bar h_{t,g}' in tex + + plain = override(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) + with pytest.raises(SchemaError, match='is not declared by the model'): + to_latex(plain, symbols={'notation': 'latex', 'names': {'supply': 's'}}, legend=False) + + DESCRIBED = override( DISPATCH, **{ diff --git a/tools/gallery.py b/tools/gallery.py index f2471f85..01ac2713 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -40,6 +40,7 @@ #: fragments is what the reference pages already are. MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', + 'commitment.md': ROOT / 'examples' / 'commitment.yaml', } #: The probe page shows every model under `examples/operators/`, keyed by the diff --git a/tools/notation.py b/tools/notation.py index 7f4b3892..7b69fabc 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -66,6 +66,7 @@ SECTIONS = { 'objective': 'The objective', 'constraints': 'Constraints', + 'expressions': 'Definitions', 'variables': 'Variable domains', 'piecewise': 'Curves, as what they expand to', 'sos': 'Sets carried to the solver', @@ -199,6 +200,11 @@ def preamble(text: str) -> str: #: What each section says about itself, where the section needs saying. NOTES = { + 'expressions': ( + 'A named expression is substituted where its name is used, so it normally prints nothing under its own ' + 'name. A cased one is the exception: its value is defined by region, which is a definition of its own, and ' + 'the equations using it name it rather than repeating the block.' + ), 'piecewise': ( 'A curve is sugar: what prints is the formulation it expands to, which is the math the solver ' 'receives. One row per `method:`, each from the model named under it, so the symbols in this '