From c5268326a6845762f6835b9bdf5d1afeee7260ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:01:17 +0000 Subject: [PATCH 1/9] feat: cases on a named expression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expressions:` takes `cases:` — one quantity whose value varies by region — with `foreach:` naming the frame they partition: expressions: previous_status: 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)} Three independent regimes multiply into eight constraint cases and add into seven expression cases, and the inequality using them is written once rather than eight times. The cases must partition the frame, and it is a load error when they do not — decided before any data binds, with the overlap or the gap named and a witness for it. Disjoint, because two values at one coordinate is not a quantity; total, because a gap would leave the expression undefined and absence spreads, so every constraint referencing it would lose rows it never masked. Totality is what keeps a constraint's rows readable at the constraint. `when:` rather than `where:`: a case selects a value, creating no absence and deleting no row, which is what `where` means on every other block. `foreach:` is required with cases and refused without. An uncased expression's dims fall out of its body; a cased one's cannot, since a case may be a scalar where its `when` is not. Each `when` is held to the frame by the same check, in the same words, that holds a variable's or a constraint's mask to its. Referencing a cased expression is a load error naming why: substitution puts one body where the name stood, and what a reference should expand to — a core node carrying the cases, or a constraint whose rows are built per region — is the open half of #2. Checked, not guessed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/reference/language/expressions.md | 64 +++++++++++++++++ schema/math-spec.schema.json | 70 +++++++++++++++++-- src/math_spec/dimensions.py | 20 ++++++ src/math_spec/expansion.py | 20 ++++-- src/math_spec/model.py | 58 +++++++++++++++- src/math_spec/validation.py | 47 ++++++++++++- tests/test_validation.py | 96 +++++++++++++++++++++++++- 7 files changed, 359 insertions(+), 16 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index a7111839..d767633c 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -337,6 +337,70 @@ 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) +``` + +**`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. + +Not yet referenceable: a reference substitutes one body where the name stood, +and this quantity has one per region +([#2](https://github.com/energy-models/math-spec/issues/2)). The declaration is +checked; the reference is a load error rather than a guess. + ## Macros A **parameterised** template. It has no dims until it is called, and each call diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index b97daa98..15a82cfc 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,33 @@ "title": "Description" }, "expression": { - "title": "Expression", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "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 +183,38 @@ } ] }, + "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": { + "title": "Expression", + "type": "string" + }, + "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/dimensions.py b/src/math_spec/dimensions.py index 01f3e53f..c41e7710 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -277,6 +277,26 @@ def check_schema(schema: Model) -> None: f'{sorted(frame)}.' ) + for ename, block in schema.expressions.items(): + if not block.cases: + continue + frame = frozenset(block.foreach or []) + stray = sorted(frame - set(schema.dimensions)) + if stray: + raise DimensionError( + f"Named expression '{ename}': foreach names {stray}, which are not dimensions.\n" + f' Dimensions: {sorted(schema.dimensions)}' + ) + 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..600b047f 100644 --- a/src/math_spec/expansion.py +++ b/src/math_spec/expansion.py @@ -179,13 +179,25 @@ def _cycle(name: str, kind: str) -> None: def _parse_named(name: str, schema: Model, context: str) -> ArithmeticNode: - body = parse_expression(schema.expressions[name].expression) - if isinstance(body, ComparisonNode): + block = schema.expressions[name] + if block.cases: + # Substitution puts one body where the name stood, and a cased + # expression has one per region. What a reference expands to — a core + # node carrying the cases, or a constraint whose rows are built per + # region — is the open half of energy-models/math-spec#2, so the + # declaration is checked and the reference is refused rather than + # guessed at. msg = ( - f"{context}: named expression '{name}' must not contain a " - f'comparison operator. Got: {schema.expressions[name].expression!r}' + f"named expression '{name}' has `cases:`, which cannot be referenced yet — " + f'a reference substitutes one value and this quantity has one per region. ' + f'Declare it without cases, or inline the case you mean.' ) raise SchemaError(msg) + 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 comparison operator. Got: {block.expression!r}" + raise SchemaError(msg) return body diff --git a/src/math_spec/model.py b/src/math_spec/model.py index a0ff3b9f..b5cb5c39 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -314,6 +314,21 @@ def _check_formals(self) -> MacroBlock: return self +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: str + description: str | None = None + + class ExpressionBlock(_StrictBlock): """A named quantity: one arithmetic expression, readable after a solve. @@ -335,7 +350,16 @@ class ExpressionBlock(_StrictBlock): _label: ClassVar[str] = 'a named expression' - expression: str + expression: str | 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 +367,29 @@ class ExpressionBlock(_StrictBlock): def _from_string(cls, data: Any) -> Any: return {'expression': data} if isinstance(data, str) else data + @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 +397,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} diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 834359ce..3f1a901a 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -48,10 +48,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 +136,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 +267,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, diff --git a/tests/test_validation.py b/tests/test_validation.py index b3393d08..4e386775 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -7,11 +7,14 @@ 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._yaml import parse_yaml +from math_spec.dimensions import DimensionError +from math_spec.errors import LanguageError, SchemaError from math_spec.resolution import Namespace, where_of from math_spec.validation import load_model, validate_expressions from math_spec.where_parser import DimensionPositionNode @@ -355,3 +358,92 @@ 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_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_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_cased_expression_cannot_be_referenced_yet(self): + """Deferred rather than guessed: substitution puts one body where the name stood.""" + schema = self._schema(**self.PREVIOUS_STATUS) + schema['constraints'] = {'c': {'foreach': ['snapshot', 'generator'], 'expression': 'status <= previous_status'}} + with pytest.raises(SchemaError, match='cannot be referenced yet'): + validate_expressions(load_model(schema)) From ef843582170ff9e9574e590d5de2b921b861a7db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:28:26 +0000 Subject: [PATCH 2/9] feat: a cased expression is referenceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reference to a `cases:` expression expanded to a load error: substitution puts one body where the name stood, and the quantity has one per region. That made the declaration checkable and useless — nothing could read it. The reference now expands to `CasesNode`, a core arithmetic node carrying every arm. The partition proved on the declaration is what makes that a value rather than a choice: exactly one arm applies at each coordinate, decided without data, so a consumer selects per row the way a `where` already filters. Every dispatcher gains the arm — expansion, resolution, dims, degree, boundedness, the typeset walk — and the format seam gains `cases()`, spelled `\begin{cases}` in LaTeX, `cases()` in Typst, and `\cr` rather than `\\` in Markdown, whose escape pass eats one of the two backslashes before MathJax sees them. The dims of a reference are the declared `foreach`, not the union of the arms: an arm narrower than the frame broadcasts, exactly as a parameter with fewer dims does. The notation page gains the block it never showed — a named expression prints nothing under its own name, so `cases:` is only readable beside the declaration. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/reference/language/expressions.md | 13 ++++-- docs/reference/notation.md | 28 ++++++++++++ src/math_spec/__init__.py | 4 ++ src/math_spec/boundedness.py | 7 +++ src/math_spec/degree.py | 3 +- src/math_spec/dimensions.py | 7 +++ src/math_spec/expansion.py | 42 ++++++++++++------ src/math_spec/expression_parser.py | 38 +++++++++++++++- src/math_spec/resolution.py | 13 ++++++ src/math_spec/typeset/format.py | 8 ++++ src/math_spec/typeset/latex.py | 4 ++ src/math_spec/typeset/markdown.py | 9 ++++ src/math_spec/typeset/typst.py | 4 ++ src/math_spec/typeset/walk.py | 7 +++ src/math_spec/validation.py | 8 ++++ tests/test_validation.py | 61 ++++++++++++++++++++++++-- tests/typeset/golden/latex.out | 1 + tests/typeset/golden/markdown.out | 4 ++ tests/typeset/golden/model.yaml | 14 ++++++ tests/typeset/golden/typst.out | 1 + tools/notation.py | 25 ++++++++--- 21 files changed, 272 insertions(+), 29 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index d767633c..781cdf9c 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -366,6 +366,10 @@ expressions: 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 @@ -396,10 +400,11 @@ 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. -Not yet referenceable: a reference substitutes one body where the name stood, -and this quantity has one per region -([#2](https://github.com/energy-models/math-spec/issues/2)). The declaration is -checked; the reference is a load error rather than a guess. +**A reference carries the regions with it.** `no_restart` above names the +quantity once, so the inequality is written once and the case conditions ride +along into what it prints: + +$$\mathit{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} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ ## Macros diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 36df7332..908ec40e 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -126,6 +126,22 @@ $\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. +### Named expressions + +A named expression is substituted where its name is used, so it prints nothing under its own name — its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value defined by region is a construct, and the regions read beside the declaration rather than at the use site. + +```yaml +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 +``` ### The objective @@ -428,6 +444,18 @@ 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 \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \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 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 c41e7710..0eba0dd3 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) diff --git a/src/math_spec/expansion.py b/src/math_spec/expansion.py index 600b047f..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) @@ -181,18 +189,7 @@ def _cycle(name: str, kind: str) -> None: def _parse_named(name: str, schema: Model, context: str) -> ArithmeticNode: block = schema.expressions[name] if block.cases: - # Substitution puts one body where the name stood, and a cased - # expression has one per region. What a reference expands to — a core - # node carrying the cases, or a constraint whose rows are built per - # region — is the open half of energy-models/math-spec#2, so the - # declaration is checked and the reference is refused rather than - # guessed at. - msg = ( - f"named expression '{name}' has `cases:`, which cannot be referenced yet — " - f'a reference substitutes one value and this quantity has one per region. ' - f'Declare it without cases, or inline the case you mean.' - ) - raise SchemaError(msg) + return _parse_cased(name, block, context) assert block.expression is not None body = parse_expression(block.expression) if isinstance(body, ComparisonNode): @@ -201,6 +198,25 @@ def _parse_named(name: str, schema: Model, context: str) -> ArithmeticNode: 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/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/format.py b/src/math_spec/typeset/format.py index 48358e53..f32ca2be 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -202,6 +202,14 @@ 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 there is no otherwise-arm and no + order-dependence — a format may print them in any order it likes. + """ + ... + 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..21216008 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -128,6 +128,10 @@ 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: + rows = r' \\ '.join(rf'{value} & \text{{if }} {condition}' for value, condition in arms) + return rf'\begin{{cases}} {rows} \end{{cases}}' + 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..800bfe8f 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -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, 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 _LATEX.cases(arms).replace(r' \\ ', r' \cr ') + def apply(self, function: str, argument: str) -> str: return _LATEX.apply(function, argument) 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..1ccfe911 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, @@ -302,6 +303,12 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: if isinstance(node, FunctionCallNode): return self._call(node, ctx) + if isinstance(node, CasesNode): + # An atom: a cases block is self-delimiting, so it never needs a + # bracket around it however it sits in the surrounding arithmetic. + arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] + return self.format.cases(arms), _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) diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 3f1a901a..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, @@ -434,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 4e386775..b6c887eb 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -15,7 +15,8 @@ from math_spec._yaml import parse_yaml from math_spec.dimensions import DimensionError from math_spec.errors import LanguageError, SchemaError -from math_spec.resolution import Namespace, where_of +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 @@ -441,9 +442,61 @@ def test_a_case_may_not_widen_the_frame(self): with pytest.raises(DimensionError, match="not in the frame \\['generator'\\]"): validate_expressions(load_model(schema)) - def test_a_cased_expression_cannot_be_referenced_yet(self): - """Deferred rather than guessed: substitution puts one body where the name stood.""" + 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'}} - with pytest.raises(SchemaError, match='cannot be referenced yet'): + 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..dc6bedf3 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -92,6 +92,7 @@ \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 \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{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 diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index 746cea9d..ae81bf4e 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -158,6 +158,10 @@ $$\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 \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}$$ + **`always`** $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T}$$ diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 35113da2..c54d2320 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -85,6 +85,17 @@ 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 + constraints: balance: # sum over a lookup foreach: [snapshot, bus] @@ -165,6 +176,9 @@ 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 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..5d12110f 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -81,6 +81,7 @@ $ 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 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("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 $ diff --git a/tools/notation.py b/tools/notation.py index 7f4b3892..dc05e2b4 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -189,12 +189,22 @@ def legend(rendered: str) -> str: def preamble(text: str) -> str: """The fixture's ``dimensions``/``lookups``/``parameters`` blocks, verbatim.""" - blocks = [] - for name in DECLARED: - body = text[text.index(f'\n{name}:') + 1 :] - end = re.search(r'\n(?=\w)', body) - blocks.append(body[: end.start()] if end else body) - return '\n'.join(blocks).strip() + return '\n'.join(_block(text, name) for name in DECLARED).strip() + + +def _block(text: str, name: str) -> str: + """One top-level block of the fixture, from its key to the next one.""" + body = text[text.index(f'\n{name}:') + 1 :] + end = re.search(r'\n(?=\w)', body) + return body[: end.start()] if end else body + + +#: What the page says about the one block that declares math and prints none. +NAMED = ( + 'A named expression is substituted where its name is used, so it prints nothing under its own name \N{EM DASH} ' + 'its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value ' + 'defined by region is a construct, and the regions read beside the declaration rather than at the use site.' +) #: What each section says about itself, where the section needs saying. @@ -216,6 +226,9 @@ def block() -> str: 'print is the legend every model opens with.', f'```yaml\n{preamble(MODEL.read_text())}\n```', legend(rendered), + '### Named expressions', + NAMED, + f'```yaml\n{_block(MODEL.read_text(), "expressions").strip()}\n```', ] printed = equations(rendered) for section, title in SECTIONS.items(): From 0eca93dd211c6ab026a99b4b7d02fe7ae46a4c2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:39:52 +0000 Subject: [PATCH 3/9] feat: an example that uses cases, and a constant case written as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `examples/commitment.yaml` is the formulation #2 factors: three regimes for the state a unit carries into a snapshot, so `ramp_up` holds a running unit to `ramp_limit` and a starting one to `start_up_limit` in one inequality instead of three. It is picked up by `tools/render_tex.py` like every other model, so the LaTeX gate compiles it. Writing it found a bug in the documented example: `expression: 1` is how a constant case is spelled, YAML reads it as an int, and the annotation took only a string — so the block in `expressions.md` did not load. Nothing caught it because no test loads the page's snippets. A number is now accepted wherever an expression string is declared, and the checked-in JSON schema says so, since a `mode='before'` rewrite is otherwise invisible to it. Booleans still fail: `true` is not arithmetic, and an error naming the type reads better than one naming `'True'`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- examples/commitment.yaml | 74 ++++++++++++++++++++++++++++++++++++ schema/math-spec.schema.json | 14 ++++++- src/math_spec/model.py | 23 +++++++++-- tests/test_validation.py | 17 +++++++++ 4 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 examples/commitment.yaml 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/schema/math-spec.schema.json b/schema/math-spec.schema.json index 15a82cfc..4d7c1109 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -152,6 +152,9 @@ { "type": "string" }, + { + "type": "number" + }, { "type": "null" } @@ -200,8 +203,15 @@ "title": "Description" }, "expression": { - "title": "Expression", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "title": "Expression" }, "when": { "title": "When", diff --git a/src/math_spec/model.py b/src/math_spec/model.py index b5cb5c39..8ebf1c07 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,22 @@ 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. @@ -325,7 +342,7 @@ class ExpressionCase(_StrictBlock): _label: ClassVar[str] = 'an expression case' when: str - expression: str + expression: Expression description: str | None = None @@ -350,7 +367,7 @@ class ExpressionBlock(_StrictBlock): _label: ClassVar[str] = 'a named expression' - expression: str | None = None + 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 diff --git a/tests/test_validation.py b/tests/test_validation.py index b6c887eb..8ec50b3e 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -404,6 +404,23 @@ def test_it_round_trips(self): 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'): From 0277b645e3396cc961e2808ecc5f7bbe542cdd87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:12:05 +0000 Subject: [PATCH 4/9] docs: the commitment model joins the example gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `examples/commitment.yaml` arrives with this change, so its gallery page does too: the file verbatim, then the document the typesetter prints from it. The page is the fourth in `docs/examples/`, whose generator and currency test came in with the section itself. What it prints today is the block inlined at each use, twice in one row of `ramp_up` — which is the argument for the rendering change stacked on top of this, made in the place a reader meets it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- .prettierignore | 1 + docs/examples/commitment.md | 158 +++++++++++++++++++++++++ docs/examples/index.md | 2 + docs/reference/language/expressions.md | 3 + docs/reference/notation.md | 1 + mkdocs.yml | 1 + tools/gallery.py | 1 + 7 files changed, 167 insertions(+) create mode 100644 docs/examples/commitment.md 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..bb7eae55 --- /dev/null +++ b/docs/examples/commitment.md @@ -0,0 +1,158 @@ + + +# 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. + + +```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 | +|---|---| +| $\mathit{committable}$ | `committable` over $\mathcal{G}$ — whether the unit may be switched off | +| $\mathit{status}^{\mathrm{initial}}$ | `status_initial` over $\mathcal{G}$ — whether the unit was running before the horizon | +| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | +| $p^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ — output floor while running | +| $\mathit{ramp\_limit}$ | `ramp_limit` over $\mathcal{G}$ — how far output may move between snapshots while running | +| $\mathit{start\_up\_limit}$ | `start_up_limit` over $\mathcal{G}$ — how far it may move in the snapshot it starts in | +| $\mathit{load}$ | `load` over $\mathcal{T}$ — demand to be met | +| $\mathit{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 | + +$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 \mathit{cost}_{g}$$ + +#### Subject to + +**`power_balance`** + +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathit{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ + +**`upper`** + +$$p_{t,g} \le \mathit{status}_{t,g} \cdot 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 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 \mathit{ramp\_limit}_{g} \cdot \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} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \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} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Variable domains + +**`p`** + +$$0 \le p_{t,g} \le 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 781cdf9c..aaa4cbba 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -400,6 +400,9 @@ 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 carries the regions with it.** `no_restart` above names the quantity once, so the inequality is written once and the case conditions ride along into what it prints: diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 908ec40e..e388d90c 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -126,6 +126,7 @@ $\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ in: the subscript names the map, $\mathcal{T}_{\mathrm{lookup}(t)}$ is the group it lands in, and that group has a first position of its own. $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. + ### Named expressions A named expression is substituted where its name is used, so it prints nothing under its own name — its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value defined by region is a construct, and the regions read beside the declaration rather than at the use site. 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/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 From 336e7ea65f3cc69ead2ecd67a2163fdf8b5730a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:57:14 +0000 Subject: [PATCH 5/9] refactor: fold the cases checks into the mechanisms that already existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cleanups, no behaviour change beyond where an error is raised. A cased expression's `foreach` naming a declared dimension was a bespoke check in `dimensions.py`, with wording of its own. `Model` already holds that check for parameters, variables and constraints, keyed off `referenced_dims`, so the block joins that table and the message is the one every other declaration gets — raised at load rather than in `check_schema`. `MarkdownFormat.cases` rewrote the string `LatexFormat.cases` had just built, matching on ` \\ ` to swap in ` \cr `. A change to the spelling on the LaTeX side would have gone quiet rather than failing, and MathJax would have lost the row break with nothing to say so. Both now call one builder that takes the separator. `partition.py`'s docstring still said the module was not wired into the schema. It has been since the previous commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- src/math_spec/dimensions.py | 8 ++------ src/math_spec/model.py | 6 ++++++ src/math_spec/partition.py | 5 +++-- src/math_spec/typeset/latex.py | 19 +++++++++++++++++-- src/math_spec/typeset/markdown.py | 6 +++--- tests/test_validation.py | 7 +++++++ 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 0eba0dd3..02610f4c 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -287,13 +287,9 @@ def check_schema(schema: Model) -> None: 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 []) - stray = sorted(frame - set(schema.dimensions)) - if stray: - raise DimensionError( - f"Named expression '{ename}': foreach names {stray}, which are not dimensions.\n" - f' Dimensions: {sorted(schema.dimensions)}' - ) 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) diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 8ebf1c07..7d7416be 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -384,6 +384,11 @@ 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): @@ -918,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/typeset/latex.py b/src/math_spec/typeset/latex.py index 21216008..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`.""" @@ -129,8 +145,7 @@ def fraction(self, numerator: str, denominator: str) -> str: return rf'\frac{{{numerator}}}{{{denominator}}}' def cases(self, arms: list[tuple[str, str]]) -> str: - rows = r' \\ '.join(rf'{value} & \text{{if }} {condition}' for value, condition in arms) - return rf'\begin{{cases}} {rows} \end{{cases}}' + 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 800bfe8f..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 @@ -93,13 +93,13 @@ 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, with TeX's own row primitive in place of ``\\``. + 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 _LATEX.cases(arms).replace(r' \\ ', r' \cr ') + return cases_block(arms, r' \cr ') def apply(self, function: str, argument: str) -> str: return _LATEX.apply(function, argument) diff --git a/tests/test_validation.py b/tests/test_validation.py index 8ec50b3e..2edf156c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -448,6 +448,13 @@ def test_the_two_forms_do_not_mix(self, block: dict[str, Any], fragment: str): 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. From fa1ce4ab9faaa2f2d3e783b04288c1d9c5a2a14a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:45:05 +0000 Subject: [PATCH 6/9] feat: a cased expression prints once, as a definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inlining the 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 the middle arm and reads as part of that arm's condition. `examples/commitment.yaml` is the worst case and it is not contrived: `ramp_up` names the quantity twice, so one row carried the same three arms twice over. And a quantity written once in the file was written once per use on the page, which is the opposite of what naming it was for. So a use prints the symbol and the block prints under a `Definitions` section, which is how a paper states a quantity defined by region. Nothing about expansion changes — the AST still inlines, and `CasesNode` already carries the name and the frame the walk needs. Cased expressions join the symbol pool, so they derive a symbol like any other name and `--symbols` can rename one. Uncased ones stay out: they print nothing under their own name, and a table entry that never applies is the silent typo the table is strict to avoid. `definitions()` runs after the sections that use it, since what lands there is what they reached; an arm may name another cased expression, so it runs to a fixpoint. An expression nobody names prints nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/examples/commitment.md | 11 +++- docs/reference/language/expressions.md | 14 +++-- docs/reference/notation.md | 41 ++++++------ src/math_spec/typeset/__init__.py | 10 ++- src/math_spec/typeset/format.py | 6 +- src/math_spec/typeset/symbols.py | 21 +++++-- src/math_spec/typeset/walk.py | 46 ++++++++++++-- tests/typeset/golden/latex.out | 7 ++- tests/typeset/golden/markdown.out | 8 ++- tests/typeset/golden/typst.out | 6 +- tests/typeset/test_typeset.py | 87 +++++++++++++++++++++++++- tools/notation.py | 31 ++++----- 12 files changed, 229 insertions(+), 59 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index bb7eae55..24a485f0 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -15,6 +15,9 @@ 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: >- @@ -142,7 +145,13 @@ $$p_{t,g} \ge \mathit{status}_{t,g} \cdot p^{\mathrm{min}}_{g} \qquad \forall\th **`ramp_up`** -$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \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} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \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} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathit{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 \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}$$ #### Variable domains diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index aaa4cbba..7ed45604 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -403,11 +403,17 @@ 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 carries the regions with it.** `no_restart` above names the -quantity once, so the inequality is written once and the case conditions ride -along into what 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} - \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} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\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 diff --git a/docs/reference/notation.md b/docs/reference/notation.md index e388d90c..e5c29cbd 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -127,23 +127,6 @@ $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. -### Named expressions - -A named expression is substituted where its name is used, so it prints nothing under its own name — its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value defined by region is a construct, and the regions read beside the declaration rather than at the use site. - -```yaml -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 -``` - ### The objective #### `objective` @@ -455,7 +438,7 @@ started: expression: slack >= on * startup_cost ``` -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `always` @@ -496,6 +479,28 @@ 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 +``` + +$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ### Variable domains #### `p` diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index 0e4b7d68..1f02970a 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -102,10 +102,14 @@ def typeset( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) + # Order matters: `definitions()` prints what the other sections reached, + # so they run first and their output is placed around it. + objective, constraints, variables = walk.objective(), walk.constraints(), walk.variables() sections = [ - ('Objective', walk.objective()), - ('Subject to', walk.constraints()), - ('Variable domains', walk.variables()), + ('Objective', objective), + ('Subject to', constraints), + ('Definitions', walk.definitions()), + ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index f32ca2be..baae59ed 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -205,8 +205,10 @@ 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 there is no otherwise-arm and no - order-dependence — a format may print them in any order it likes. + 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. """ ... diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 48ba994a..5647def9 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. @@ -132,9 +143,11 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: 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}) #: Names whose symbol came from the table rather than the derivation. #: The convention note quotes only the others: a table is printed @@ -147,7 +160,7 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: 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) + for name in (*schema.parameters, *schema.variables, *printed) } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -265,7 +278,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/walk.py b/src/math_spec/typeset/walk.py index 1ccfe911..3db6c166 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -230,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] @@ -304,10 +307,11 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return self._call(node, ctx) if isinstance(node, CasesNode): - # An atom: a cases block is self-delimiting, so it never needs a - # bracket around it however it sits in the surrounding arithmetic. - arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] - return self.format.cases(arms), _ATOM + # 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.' @@ -634,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/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index dc6bedf3..ad8af8a4 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -92,12 +92,17 @@ \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 \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{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,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} && \mathit{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} +\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 ae81bf4e..47013974 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -160,7 +160,7 @@ $$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \foral **`started`** -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \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}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`always`** @@ -174,6 +174,12 @@ $$\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`** + +$$\mathit{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}$$ + #### Variable domains **`p`** diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index 5d12110f..db549070 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -81,11 +81,15 @@ $ 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 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("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot italic("startup_cost")_(t,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") & italic("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) $ + == 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..dee578b5 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -470,7 +470,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 +481,73 @@ 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) + assert rendered.count(fmt.italic('headroom')) == 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_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.italic('headroom') in rendered, 'the inner definition was reached through an arm' + assert rendered.count(fmt.italic('opening_cost')) == 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'}) @@ -1125,6 +1195,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/notation.py b/tools/notation.py index dc05e2b4..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', @@ -189,26 +190,21 @@ def legend(rendered: str) -> str: def preamble(text: str) -> str: """The fixture's ``dimensions``/``lookups``/``parameters`` blocks, verbatim.""" - return '\n'.join(_block(text, name) for name in DECLARED).strip() - - -def _block(text: str, name: str) -> str: - """One top-level block of the fixture, from its key to the next one.""" - body = text[text.index(f'\n{name}:') + 1 :] - end = re.search(r'\n(?=\w)', body) - return body[: end.start()] if end else body - - -#: What the page says about the one block that declares math and prints none. -NAMED = ( - 'A named expression is substituted where its name is used, so it prints nothing under its own name \N{EM DASH} ' - 'its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value ' - 'defined by region is a construct, and the regions read beside the declaration rather than at the use site.' -) + blocks = [] + for name in DECLARED: + body = text[text.index(f'\n{name}:') + 1 :] + end = re.search(r'\n(?=\w)', body) + blocks.append(body[: end.start()] if end else body) + return '\n'.join(blocks).strip() #: 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 ' @@ -226,9 +222,6 @@ def block() -> str: 'print is the legend every model opens with.', f'```yaml\n{preamble(MODEL.read_text())}\n```', legend(rendered), - '### Named expressions', - NAMED, - f'```yaml\n{_block(MODEL.read_text(), "expressions").strip()}\n```', ] printed = equations(rendered) for section, title in SECTIONS.items(): From 43be9b43f1316e9b4f539ecef711cfe6eae15b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:57:46 +0000 Subject: [PATCH 7/9] refactor: the definitions pass reads as the ordering it depends on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walk.definitions()` prints what the other sections reached, so it has to run after them. It sat inside the section list, where that dependency held only because Python evaluates the tuple assignment above it first — invisible to anyone tidying the list back into inline calls, and silent when broken: the section would simply come out empty. It is a statement of its own now, after the three it depends on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ADtfZf4V6W9XcLRSwSgHzE --- docs/examples/commitment.md | 32 ++++++++++++++++--------------- docs/reference/notation.md | 2 +- src/math_spec/typeset/__init__.py | 8 +++++--- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index 24a485f0..f52e77da 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -105,14 +105,14 @@ Unit commitment with a start-up ramp, the formulation `cases:` exists for. The s | Symbol | Meaning | |---|---| -| $\mathit{committable}$ | `committable` over $\mathcal{G}$ — whether the unit may be switched off | -| $\mathit{status}^{\mathrm{initial}}$ | `status_initial` over $\mathcal{G}$ — whether the unit was running before the horizon | -| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | -| $p^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ — output floor while running | -| $\mathit{ramp\_limit}$ | `ramp_limit` over $\mathcal{G}$ — how far output may move between snapshots while running | -| $\mathit{start\_up\_limit}$ | `start_up_limit` over $\mathcal{G}$ — how far it may move in the snapshot it starts in | -| $\mathit{load}$ | `load` over $\mathcal{T}$ — demand to be met | -| $\mathit{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | +| $\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 @@ -121,43 +121,45 @@ Unit commitment with a start-up ramp, the formulation `cases:` exists for. The s | $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 \mathit{cost}_{g}$$ +$$\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} = \mathit{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\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 p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$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 p^{\mathrm{min}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$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 \mathit{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathit{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}$$ +$$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 \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}$$ +$$\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 p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`status`** diff --git a/docs/reference/notation.md b/docs/reference/notation.md index e5c29cbd..e93ee63e 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -499,7 +499,7 @@ startup_cost: expression: cost ``` -$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{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}$$ ### Variable domains diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index 1f02970a..34dea609 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -102,13 +102,15 @@ def typeset( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) - # Order matters: `definitions()` prints what the other sections reached, - # so they run first and their output is placed around it. + # `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', objective), ('Subject to', constraints), - ('Definitions', walk.definitions()), + ('Definitions', definitions), ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] From 68ed8a86a2b15887ed5f3452887bbe6892e8bfd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:47:54 +0000 Subject: [PATCH 8/9] feat: a cased expression is on whichever side its arms put it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebasing onto `main` brought the convention's whole-page guard with it, and it failed here at once: `startup_cost` printed italic, and both of its arms are parameters. Upright is what the model is given, and a quantity written in two regions is still given when every region is — the number of regions is a statement about the frame, not about who decides the value. So `typeset` works out which cased expressions reach a variable, which it can do and `Symbols` cannot: it holds the namespace an arm has to be resolved against. The walk over the resolved arm answers for the whole chain, since an arm naming another cased expression resolves to that one's arms. startup_cost arms: cost * p_max, cost → upright, the model is given it committed_power arms: p, 0 → italic, the solver decides it The fixture gains the second, which it had no case for: with only the first, the page showed the rule holding in one direction and nothing testing the other. Two of this branch's own tests move to `upright` with it — `CASED`'s `headroom` is a parameter in every arm — and one of them now counts the *indexed* symbol, because Typst spells a row label and an upright symbol the same way and only the symbol carries the dims. --- docs/reference/notation.md | 34 +++++++++++++++++++++-- src/math_spec/typeset/__init__.py | 45 ++++++++++++++++++++++++++++--- src/math_spec/typeset/symbols.py | 10 +++++-- tests/typeset/golden/latex.out | 6 +++-- tests/typeset/golden/markdown.out | 12 +++++++-- tests/typeset/golden/model.yaml | 12 +++++++++ tests/typeset/golden/typst.out | 6 +++-- tests/typeset/test_typeset.py | 29 +++++++++++++++----- 8 files changed, 134 insertions(+), 20 deletions(-) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index e93ee63e..8363f894 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -438,7 +438,19 @@ started: expression: slack >= on * startup_cost ``` -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\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` @@ -499,7 +511,25 @@ startup_cost: expression: cost ``` -$$\mathit{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}$$ +$$\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 diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index 34dea609..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,7 +102,9 @@ 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 @@ -124,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/symbols.py b/src/math_spec/typeset/symbols.py index 5647def9..3af647ca 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -136,7 +136,9 @@ 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 ' @@ -148,6 +150,10 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: # 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 @@ -159,7 +165,7 @@ 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) + 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} diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index ad8af8a4..4049aeb5 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -92,7 +92,8 @@ \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 \mathit{startup\_cost}_{t,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 @@ -100,7 +101,8 @@ \paragraph{Definitions} \begin{align} -\text{startup\_cost} && \mathit{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{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} diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index 47013974..8a6e46ec 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -160,7 +160,11 @@ $$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \foral **`started`** -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\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`** @@ -178,7 +182,11 @@ $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace **`startup_cost`** -$$\mathit{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}$$ +$$\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 diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index c54d2320..805ba29c 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -95,6 +95,15 @@ expressions: 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 @@ -179,6 +188,9 @@ constraints: 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 db549070..1fb626a1 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -81,14 +81,16 @@ $ 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 italic("startup_cost")_(t,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") & italic("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("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)") diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index dee578b5..9e77be25 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 @@ -509,7 +518,11 @@ def test_a_cased_expression_is_the_exception_that_keeps_its_name(fmt: Format): puts whatever follows it beside its middle arm. """ rendered = typeset(CASED, fmt, legend=False) - assert rendered.count(fmt.italic('headroom')) == 2, 'one use and one definition, no more' + # 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'] @@ -539,8 +552,8 @@ def test_a_definition_naming_another_one_prints_both(fmt: Format): }, ) rendered = typeset(nested, fmt, legend=False) - assert fmt.italic('headroom') in rendered, 'the inner definition was reached through an arm' - assert rendered.count(fmt.italic('opening_cost')) == 2 + 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]: @@ -650,11 +663,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} From 35d62ac474e95e822967506c30b858ae95431a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:49:28 +0000 Subject: [PATCH 9/9] test: a mask is not what makes a quantity the solver's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two halves of a case say different things. The value decides *what the quantity is*; the mask decides *which region applies*. A variable in a mask is asking whether that variable exists at a coordinate — which the model settles when it is built, not something a solver returns — so a cased expression whose every arm is a parameter is data however its regions are cut. That is what `_reaching_a_variable` already does, walking `case.expression` and not `case.when`. Worth a test rather than a reading of the code: the obvious "fix" for someone who meets this later is to scan both, and it would make a pure-data quantity print as one the solver decides. --- tests/typeset/test_typeset.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 9e77be25..3814a02f 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -535,6 +535,31 @@ def test_a_definition_is_printed_only_where_something_reached_it(fmt: Format): 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."""