From fc2f624927786ac3953bc1a807f5c394252f76b8 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:28:04 +0200 Subject: [PATCH 1/8] feat(language): a named expression may write a constant as a number Co-Authored-By: Claude Opus 5 (1M context) --- schema/math-spec.schema.json | 11 +++++++++-- src/math_spec/model.py | 18 ++++++++++++++++-- tests/test_validation.py | 18 +++++++++++++++++- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index b6e593a5..01c84743 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -140,8 +140,15 @@ "title": "Description" }, "expression": { - "title": "Expression", - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "title": "Expression" } }, "required": [ diff --git a/src/math_spec/model.py b/src/math_spec/model.py index fee2504f..d0de2f4b 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -14,10 +14,11 @@ from __future__ import annotations import math -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, @@ -308,6 +309,19 @@ def _check_formals(self) -> MacroBlock: return self +def _number_is_an_expression(value: Any) -> Any: + """``expression: 0`` is how a file writes a constant — YAML reads it as an int. + + 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 ExpressionBlock(_StrictBlock): """A named quantity: one arithmetic expression, readable after a solve. @@ -324,7 +338,7 @@ class ExpressionBlock(_StrictBlock): _label: ClassVar[str] = 'a named expression' - expression: str + expression: Expression description: str | None = None @model_validator(mode='before') diff --git a/tests/test_validation.py b/tests/test_validation.py index c4e8fc97..bbb00b1a 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -12,7 +12,7 @@ import pytest from math_spec._yaml import parse_yaml -from math_spec.errors import LanguageError +from math_spec.errors import LanguageError, SchemaError from math_spec.resolution import Namespace, where_of from math_spec.validation import load_model from math_spec.where_parser import DimensionPositionNode @@ -637,3 +637,19 @@ def test_a_default_is_written_out_and_an_absence_is_not(self): assert 'upper' in written['variables']['p']['bounds'] and 'where' not in written['variables']['p'], ( 'a null and an infinite bound say nothing, so they are not written' ) + + +class TestANumberIsAnExpression: + """`expression: 0` is a constant, and YAML reads it as an int rather than a string.""" + + def test_a_number_is_read_as_the_expression_it_writes(self): + assert _schema(**{'expressions.always': {'expression': 1}}).expressions['always'].expression == '1' + + def test_it_survives_the_round_trip_as_the_string_it_became(self): + model = _schema(**{'expressions.always': {'expression': 1.5}}) + assert load_model(model.to_dict()).expressions['always'].expression == '1.5' + + def test_a_boolean_is_still_not_an_expression(self): + """`true` is not arithmetic, and an error naming the type reads better than one naming `'True'`.""" + with pytest.raises(SchemaError, match='valid string'): + _schema(**{'expressions.always': {'expression': True}}) From aca650261e76af04c986aa1b8ad36a8e2f295024 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:31:07 +0200 Subject: [PATCH 2/8] feat(language): a named expression may give a value per region Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/language/expressions.md | 77 ++++++++++++ docs/reference/notation.md | 29 +++++ schema/math-spec.schema.json | 66 +++++++++- src/math_spec/__init__.py | 4 + src/math_spec/boundedness.py | 6 + src/math_spec/dimensions.py | 20 +++ src/math_spec/expansion.py | 44 ++++++- src/math_spec/expression_parser.py | 42 ++++++- src/math_spec/model.py | 108 +++++++++++++++- src/math_spec/resolution.py | 13 ++ src/math_spec/typesetting/__init__.py | 1 + src/math_spec/typesetting/format.py | 8 ++ src/math_spec/typesetting/latex.py | 6 + src/math_spec/typesetting/markdown.py | 4 + src/math_spec/typesetting/symbols.py | 45 ++++++- src/math_spec/typesetting/typst.py | 3 + src/math_spec/typesetting/walk.py | 54 ++++++++ src/math_spec/validation.py | 19 ++- tests/test_public_surface.py | 2 + tests/test_validation.py | 133 +++++++++++++++++++- tests/typesetting/golden/latex.out | 6 + tests/typesetting/golden/markdown.out | 10 ++ tests/typesetting/golden/model.yaml | 12 ++ tests/typesetting/golden/typst.out | 7 +- tests/typesetting/test_cases.py | 168 +++++++++++++++++++++++++ tests/typesetting/test_walk.py | 7 +- tools/notation.py | 1 + 27 files changed, 866 insertions(+), 29 deletions(-) create mode 100644 tests/typesetting/test_cases.py diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 5c362c35..ed0f165f 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -333,6 +333,83 @@ 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 have no single expression. The commitment state a unit carries +into a snapshot is `1` for a unit that is never switched off, an initial +condition at the first snapshot, and last snapshot's status everywhere else — +three regimes, one quantity. Written at the constraint they fork it three ways; +named here, the inequality that uses it is written once: + +```yaml +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: "position(snapshot) == 0" + expression: status_initial + interior: + expression: shift(status, over=snapshot, offset=1) +constraints: + no_restart: + foreach: [snapshot, generator] + expression: status - previous_status <= 1 +``` + +**The cases are read in order, and the last one is the fallback.** The value at +a coordinate is the first arm whose `when` holds there, and the last arm +carries no `when` at all. So the arms cannot disagree — only the first to match +is read — and none of them has to cover everything, because the fallback has no +condition to fail. Both are properties of the block's shape rather than claims +about the masks, so nothing has to decide what a predicate could be true of. + +The fallback is required rather than optional because a gap would leave the +quantity undefined there, and absence [spreads](absence.md) — every constraint +referencing it would lose rows it never masked. It is not free: with no mask to +narrow the frame, the last arm has to say what an absent parameter or an +unnamed label gets. + +**`when:`, not `where:`.** A case selects which value a coordinate takes; it +creates no absence and deletes no row, which is what `where` means on every +other block. A cased expression has no `where` of its own. + +**`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 — `always_on` above is exactly that. Each +`when` is held to that frame the way a variable's or a constraint's mask is, +and each case's value must sit inside it. **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. + +One `expression:` or a set of `cases:`, never both and never neither, and two +cases at least — one case is one value everywhere, which the plain form says. + +### A cased expression is the one that keeps its name + +Every other named expression is substituted where it is used and prints nothing +under its own name. A cased one is the exception: three arms are three rows +tall, so inlined at the use site whatever follows sits beside the **middle** +arm and reads as part of that arm's condition. + +So a use prints the symbol, and the block prints once under a **Definitions** +heading between `Subject to` and `Variable domains`, in declaration order, +where a paper states a quantity defined by region: + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +The heading is _Definitions_ rather than the conventional _where_, which in +this repo is a keyword and would read as the wrong thing. A cased expression +joins the symbol pool like any other quantity, so `--symbols` can rename one; +uncased ones stay out, since a table entry for one would never apply. + +**Cases in a `macros:` template are a follow-up.** The fallback would have to +cover a frame the macro does not have until it is called. + ## Macros A **parameterised** template. It has no dims until it is called, and each call diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 99da7a1b..2b3d0843 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -141,6 +141,18 @@ $$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm ### Constraints +#### `starts` + +names the cased expression: its symbol prints here, its block once below + +```yaml +starts: + foreach: [snapshot, generator] + expression: p <= startup_cost +``` + +$$p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### `balance` sum over a lookup @@ -489,6 +501,23 @@ never: $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +### Definitions + +#### `startup_cost` + +a quantity defined by region: read in order, the last arm the fallback + +```yaml +startup_cost: + foreach: [snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: cost } + winter: { when: "season_of == 'winter'", expression: cost * 2 } + rest: { expression: 0 } +``` + +$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \cr 0 & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ### Variable domains #### `p` diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index 01c84743..ccd64238 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -125,8 +125,16 @@ "anyOf": [ { "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", + "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\nA quantity whose value varies by **region** is written as ``cases:``\ninstead \u2014 an ordered set of arms over a declared ``foreach:``, the last of\nthem the fallback::\n\n previous_status:\n foreach: [snapshot, generator]\n cases:\n always_on: { when: \"not committable\", expression: 1 }\n boundary: { when: \"position(snapshot) == 0\", expression: status_initial }\n interior: { expression: shift(status, over=snapshot, offset=1) }\n\nSo the constraint that needs it names it, rather than being forked into one\ncopy per regime.", "properties": { + "cases": { + "additionalProperties": { + "$ref": "#/$defs/ExpressionCase" + }, + "default": {}, + "title": "Cases", + "type": "object" + }, "description": { "anyOf": [ { @@ -146,14 +154,30 @@ }, { "type": "number" + }, + { + "type": "null" } ], + "default": null, "title": "Expression" + }, + "foreach": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Foreach" } }, - "required": [ - "expression" - ], "title": "ExpressionBlock", "type": "object" }, @@ -162,6 +186,40 @@ } ] }, + "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, which is what ``where`` means on every other\nblock (:doc:`absence `). The **last** case\nomits ``when:`` and is the fallback, which is what makes the quantity total.", + "properties": { + "expression": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ], + "title": "Expression" + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "When" + } + }, + "required": [ + "expression" + ], + "title": "ExpressionCase", + "type": "object" + }, "LookupBlock": { "additionalProperties": false, "description": "A named single-valued map out of a dimension (the declaration rules).\n\nExactly one of ``into:`` (a groupable map onto that dimension, what\n``sum(by=)`` lands terms on) or ``dtype:`` (its own label space, selection\nonly)::\n\n lookups:\n bus_of: {over: generator, into: bus}\n period: {over: snapshot, dtype: int}\n\n``values:`` gives the map in the file as ``{label of over: value}``; a label\nit omits is unmapped. Without it the map is supplied at bind time.", diff --git a/src/math_spec/__init__.py b/src/math_spec/__init__.py index 45c0a9ca..9fe22917 100644 --- a/src/math_spec/__init__.py +++ b/src/math_spec/__init__.py @@ -21,6 +21,8 @@ ArithmeticNode, BinaryOperatorNode, BranchNode, + CaseArm, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -108,6 +110,8 @@ 'BooleanLiteralNode', 'BranchNode', 'Buildable', + 'CaseArm', + 'CasesNode', 'ComparisonNode', 'ConnectiveWhereNode', 'DimensionComparisonNode', diff --git a/src/math_spec/boundedness.py b/src/math_spec/boundedness.py index 2dbad3ae..4d67df56 100644 --- a/src/math_spec/boundedness.py +++ b/src/math_spec/boundedness.py @@ -23,6 +23,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, ExpressionNode, FunctionCallNode, @@ -172,4 +173,9 @@ 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 does + for arm in node.arms: + _walk(arm.value, sign, signs) + return assert_never(node) diff --git a/src/math_spec/dimensions.py b/src/math_spec/dimensions.py index 83a98842..aa9b4c59 100644 --- a/src/math_spec/dimensions.py +++ b/src/math_spec/dimensions.py @@ -30,6 +30,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, DimensionNode, ExpressionNode, @@ -108,6 +109,11 @@ 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: an arm narrower than + # it broadcasts, as a parameter with fewer dims does + return frozenset(schema.expressions[node.name].foreach or ()) + assert_never(node) @@ -314,6 +320,20 @@ 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 []) + 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) context = f"Constraint '{cname}'" diff --git a/src/math_spec/expansion.py b/src/math_spec/expansion.py index f1696c52..8f09de88 100644 --- a/src/math_spec/expansion.py +++ b/src/math_spec/expansion.py @@ -17,6 +17,8 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CaseArm, + CasesNode, ComparisonNode, ExpressionNode, FunctionCallNode, @@ -25,11 +27,12 @@ UnaryOperatorNode, 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 def parse_and_expand(text: str, schema: Model, context: str = 'expression') -> ExpressionNode: @@ -106,6 +109,9 @@ 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 over the frame, checked where the cases are declared + return CasesNode(node.name, tuple(CaseArm(a.label, a.when, recurse(a.value)) for a in node.arms)) assert_never(node) @@ -135,12 +141,38 @@ def _cycle(name: str, kind: str) -> None: def _parse_named(name: str, schema: Model, context: str) -> ArithmeticNode: - body = parse_expression(schema.expressions[name].expression) + block = schema.expressions[name] + if block.cases: + return _parse_cased(name, block, context) + assert block.expression is not None + return _parse_body(block.expression, f"named expression '{name}'", context) + + +def _parse_cased(name: str, block: ExpressionBlock, context: str) -> CasesNode: + """A cased expression, as the node that stands where its name was. + + The arms come out in file order, which is the order they are read in, and + carry unresolved ``when`` masks — expansion runs before resolution, so + :mod:`math_spec.resolution` types those along with everything else. + """ + return CasesNode( + name, + tuple( + CaseArm( + label, + None if case.when is None else parse_where(case.when), + _parse_body(case.expression, f"named expression '{name}', case '{label}'", context), + ) + for label, case in block.cases.items() + ), + ) + + +def _parse_body(text: str, subject: str, context: str) -> ArithmeticNode: + """Parse one expression string that stands for a value, not a relation.""" + body = parse_expression(text) if isinstance(body, ComparisonNode): - msg = ( - f"{context}: named expression '{name}' must not contain a " - f'comparison operator. Got: {schema.expressions[name].expression!r}' - ) + msg = f'{context}: {subject} must not contain a comparison operator. Got: {text!r}' raise SchemaError(msg) return body diff --git a/src/math_spec/expression_parser.py b/src/math_spec/expression_parser.py index 27ca8b2d..c82b2c3f 100644 --- a/src/math_spec/expression_parser.py +++ b/src/math_spec/expression_parser.py @@ -16,12 +16,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['<=', '>=', '=='] # --------------------------------------------------------------------------- @@ -147,6 +150,37 @@ 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. + + ``when`` is ``None`` on the **last** arm and only there — the fallback, + which is what makes the quantity total without anything having to prove it. + """ + + label: str + when: WhereNode | None + 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, since a file writes the cases on the + declaration rather than at the use site. + + The arms are **ordered** and the last is the fallback, so exactly one + applies at every coordinate without a checker having to establish it. The + frame is not carried here: it is on the declaration, which every consumer + needing it already holds. + """ + + name: str + arms: tuple[CaseArm, ...] + + ArithmeticNode = ( NumberNode | NameNode @@ -160,6 +194,7 @@ class FunctionCallNode: | UnaryOperatorNode | BinaryOperatorNode | FunctionCallNode + | CasesNode ) @@ -196,7 +231,7 @@ def shown(names: tuple[str, ...]) -> str: #: Every node carrying sub-expressions, which is exactly what :func:`children` #: descends and the only place a walk recurses. -BranchNode = UnaryOperatorNode | BinaryOperatorNode | ComparisonNode | FunctionCallNode +BranchNode = UnaryOperatorNode | BinaryOperatorNode | ComparisonNode | FunctionCallNode | CasesNode def children(node: ExpressionNode) -> tuple[ArithmeticNode, ...]: @@ -216,6 +251,9 @@ 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): + # the values only: a `when` is a mask over the frame, not a value in it + return tuple(arm.value for arm in node.arms) return () diff --git a/src/math_spec/model.py b/src/math_spec/model.py index d0de2f4b..7db80eba 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -322,6 +322,21 @@ def _number_is_an_expression(value: Any) -> Any: Expression = Annotated[str, BeforeValidator(_number_is_an_expression, json_schema_input_type=str | float)] +class ExpressionCase(_StrictBlock): + """One case of a named expression: the value, and where it is the value. + + ``when`` rather than ``where``: a case selects which value a coordinate + takes and creates no absence, which is what ``where`` means on every other + block (:doc:`absence `). The **last** case + omits ``when:`` and is the fallback, which is what makes the quantity total. + """ + + _label: ClassVar[str] = 'an expression case' + + when: str | None = None + expression: Expression + + class ExpressionBlock(_StrictBlock): """A named quantity: one arithmetic expression, readable after a solve. @@ -334,11 +349,32 @@ class ExpressionBlock(_StrictBlock): emissions: expression: sum(p * rate, over=generator) description: CO2 released, the quantity the cap bounds + + A quantity whose value varies by **region** is written as ``cases:`` + instead — an ordered set of arms over a declared ``foreach:``, the last of + them the fallback:: + + previous_status: + foreach: [snapshot, generator] + cases: + always_on: { when: "not committable", expression: 1 } + boundary: { when: "position(snapshot) == 0", expression: status_initial } + interior: { expression: shift(status, over=snapshot, offset=1) } + + So the constraint that needs it names it, rather than being forked into one + copy per regime. """ _label: ClassVar[str] = 'a named expression' - expression: Expression + expression: Expression | None = None + #: The frame the cases are read over — required with them and refused + #: without, since no one case's body gives a cased expression its shape. + foreach: list[str] | None = None + #: The regions this quantity is defined by, keyed by the name labelling the + #: row it prints. **Ordered**: the first arm whose ``when`` holds is the + #: value, and the last arm carries no ``when`` at all. + cases: dict[str, ExpressionCase] = {} description: str | None = None @model_validator(mode='before') @@ -346,6 +382,66 @@ 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: + """One ``expression:`` or two or more ``cases:``, and a ``foreach:`` with those. + + Each near-miss gets its own sentence, being a different mistake: both + is not knowing which wins, neither is an empty declaration, and a + ``foreach:`` alone is a second answer to what the body already answers. + """ + 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 are read over, 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) + if self.cases and len(self.cases) < 2: + msg = ( + 'a `cases:` block needs at least two cases — one case is one value everywhere, ' + 'which is what a plain `expression:` already says.' + ) + raise ValueError(msg) + return self + + @model_validator(mode='after') + def _the_last_case_is_the_fallback(self) -> Self: + """Exactly one case carries no ``when``, and it is written last. + + Read in order, the first ``when`` that holds is the value, so no two + arms claim one coordinate; the fallback catches the rest, so none is + left without one. Both are the block's *shape*, which is why nothing + here decides what a predicate can be true of. + """ + if not self.cases: + return self + labels = list(self.cases) + bare = [name for name, case in self.cases.items() if case.when is None] + if bare != labels[-1:]: + named = f'`{"`, `".join(bare)}`' if bare else 'nothing' + msg = ( + f'the last case is the fallback and carries no `when:`, and no other case may omit ' + f'one — here that is {named}, and the last case is `{labels[-1]}`. The cases are read ' + f'in order, so the fallback is what covers every coordinate the ones above it do not: ' + f'without it the quantity would have no value there, and absence spreads to every ' + f'constraint that names it.' + ) + raise ValueError(msg) + return self + @classmethod @override def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: @@ -353,7 +449,14 @@ 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(exclude_none=True) for name, case in self.cases.items()} + return written + assert self.expression is not None if self.description is None: return self.expression return {'expression': self.expression, 'description': self.description} @@ -767,6 +870,7 @@ def _validate_references(self) -> Model: *(('Parameter', name, p.dims) for name, p in self.parameters.items()), *(('Variable', name, v.foreach) for name, v in self.variables.items()), *(('Constraint', name, c.foreach) for name, c in self.constraints.items()), + *(('Named expression', name, e.foreach or []) for name, e in self.expressions.items()), ] for kind, name, dims in frames: errors.extend(undeclared_dimension(kind, name, d) for d in dims if d not in self.dimensions) diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index d5e171e2..8b8a5d15 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -21,6 +21,8 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CaseArm, + CasesNode, ComparisonNode, DimensionNode, EdgeNode, @@ -328,6 +330,17 @@ def _resolve_arith( ) return node + if isinstance(node, CasesNode): + arms = tuple( + CaseArm( + arm.label, + None if arm.when is None else _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, arms) + assert_never(node) diff --git a/src/math_spec/typesetting/__init__.py b/src/math_spec/typesetting/__init__.py index d884bec0..8178d2de 100644 --- a/src/math_spec/typesetting/__init__.py +++ b/src/math_spec/typesetting/__init__.py @@ -104,6 +104,7 @@ def typeset( sections = [ ('Objective', walk.objective()), ('Subject to', walk.constraints()), + ('Definitions', walk.definitions()), ('Variable domains', walk.variables()), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] diff --git a/src/math_spec/typesetting/format.py b/src/math_spec/typesetting/format.py index 4750eb9c..880bab9f 100644 --- a/src/math_spec/typesetting/format.py +++ b/src/math_spec/typesetting/format.py @@ -169,6 +169,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, in order. + + Both halves arrive rendered — which arm is the fallback is the walk's + to decide, and this only stacks the rows. + """ + ... + def apply(self, function: str, argument: str) -> str: """A coordinate map applied to an index: ``bus(g)``.""" ... diff --git a/src/math_spec/typesetting/latex.py b/src/math_spec/typesetting/latex.py index afe6e324..f28f5e40 100644 --- a/src/math_spec/typesetting/latex.py +++ b/src/math_spec/typesetting/latex.py @@ -53,6 +53,8 @@ class LatexFormat: notation: ClassVar[str] = 'latex' #: TeX's own em-dash ligature. dash: ClassVar[str] = '---' + #: Between the rows of a ``cases`` block. + cases_row: ClassVar[str] = r' \\ ' operators: ClassVar[Mapping[str, str]] = {name: latex for name, (latex, _) in OPERATOR_SPELLINGS.items()} @@ -109,6 +111,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 = self.cases_row.join(f'{value} & {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/typesetting/markdown.py b/src/math_spec/typesetting/markdown.py index df0884d8..452154c5 100644 --- a/src/math_spec/typesetting/markdown.py +++ b/src/math_spec/typesetting/markdown.py @@ -36,6 +36,10 @@ class MarkdownFormat(LatexFormat): #: hyphens in the middle of a legend row. dash: ClassVar[str] = '\N{EM DASH}' + #: TeX's own row primitive, not ``\\``: Markdown's escape pass eats one of + #: those two backslashes, so MathJax would never break the row. + cases_row: ClassVar[str] = r' \cr ' + #: LaTeX's, except where the spelling uses a backslash before punctuation. #: GitHub runs Markdown's escape processing *inside* `$$`, so `\,` arrives #: as a literal comma and `\;` as a semicolon — `\forall\, s` renders as diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 92a43151..01219d9a 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -19,7 +19,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from math_spec import read_yaml +from math_spec import Namespace, expression_of, read_yaml +from math_spec.degree import carries_variable from math_spec.errors import SchemaError, did_you_mean if TYPE_CHECKING: @@ -74,6 +75,36 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, giv return _word(name, fmt, given=given) +def printed_expressions(schema: Buildable) -> tuple[str, ...]: + """The named expressions that print under their own name, in declaration order. + + 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: it + prints as a definition of its own, which the equations using it name. The + order is the file's, because the definitions print in it. + """ + return tuple(name for name, block in schema.expressions.items() if block.cases) + + +def chosen_expressions(schema: Buildable) -> frozenset[str]: + """The cased expressions the solver decides, rather than is handed. + + A ``when`` does not move one: a variable there asks whether the variable + *exists*, which the model settles when it is built. Only a value reaching a + variable does — through a second cased expression's arms too, since + :func:`~math_spec.expression_of` expands those where the name stood. + """ + namespace = Namespace.of(schema) + return frozenset( + name + for name in printed_expressions(schema) + if any( + carries_variable(expression_of(case.expression, schema, namespace, f"expression '{name}', case '{label}'")) + for label, case in schema.expressions[name].cases.items() + ) + ) + + class Symbols: r"""How every declared name prints: overrides first, derivation for the rest. @@ -93,17 +124,19 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: f'and nothing translates between notations — write a {fmt.notation} table.' ) raise SchemaError(msg) - declared = frozenset({*schema.parameters, *schema.variables}) + printed = printed_expressions(schema) + chosen = frozenset(schema.variables) | chosen_expressions(schema) + 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 being free to #: map a parameter to an italic symbol. - self.overridden = frozenset(table.names) & {*schema.parameters, *schema.variables} + self.overridden = frozenset(table.names) & declared self.name: dict[str, str] = { name: table.names[name] if name in table.names - else _derive_name_symbol(name, declared, fmt, given=name in schema.parameters) - for name in (*schema.parameters, *schema.variables) + else _derive_name_symbol(name, declared, fmt, given=name not in chosen) + for name in (*schema.parameters, *schema.variables, *printed) } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -213,7 +246,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) | set(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/typesetting/typst.py b/src/math_spec/typesetting/typst.py index 90e768d8..df212421 100644 --- a/src/math_spec/typesetting/typst.py +++ b/src/math_spec/typesetting/typst.py @@ -111,6 +111,9 @@ 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: + return 'cases({})'.format(', '.join(f'{value} & {condition}' for value, condition in arms)) + def summation(self, domain: str, body: str) -> str: return f'sum_({domain}) {body}' diff --git a/src/math_spec/typesetting/walk.py b/src/math_spec/typesetting/walk.py index 6f42dc74..5613657a 100644 --- a/src/math_spec/typesetting/walk.py +++ b/src/math_spec/typesetting/walk.py @@ -20,6 +20,7 @@ ArithmeticNode, BinaryOperatorNode, BooleanLiteralNode, + CasesNode, ComparisonNode, DimensionComparisonNode, DimensionNode, @@ -48,6 +49,7 @@ where_of, ) from math_spec.typesetting.format import Entry, Glossary, Line +from math_spec.typesetting.symbols import printed_expressions if TYPE_CHECKING: import datetime @@ -295,6 +297,10 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: if isinstance(node, FunctionCallNode): return self._call(node, ctx) + if isinstance(node, CasesNode): + # the symbol, not the block: :meth:`definitions` prints that, once + return ctx.indexed(self.symbols.name[node.name], self._frame(node.name)), _ATOM + if isinstance(node, UnresolvedNode | KwargNode): msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.' raise AssertionError(msg) @@ -598,6 +604,54 @@ def constraints(self) -> list[Line]: ) return lines + def definitions(self) -> list[Line]: + """One line per cased expression, in declaration order, defining it. + + Inlining the block where its name stood is what the AST does and the + wrong thing to print: three arms are three rows tall, so whatever + follows sits beside the middle one. So a use prints the symbol and the + block prints here, as a paper states a quantity defined by region. + + Every declared one prints, used or not — the rule a variable's domain + follows, and what keeps this section independent of the others having + run. + """ + lines = [] + for name in printed_expressions(self.schema): + node = expression_of(name, self.schema, self.namespace, f"expression '{name}'") + assert isinstance(node, CasesNode) + frame = self._frame(name) + ctx = self.context(frame) + lines.append( + Line( + label=name, + left=ctx.indexed(self.symbols.name[name], frame), + right=f'{self.op("equal")} {self.format.cases(self._arms(node, ctx))}', + condition=self.quantifier(frame, ''), + ) + ) + return lines + + def _frame(self, name: str) -> list[str]: + """The dims a cased expression is read over — its declaration's, not a copy.""" + return list(self.schema.expressions[name].foreach or ()) + + def _arms(self, node: CasesNode, ctx: _Context) -> list[tuple[str, str]]: + """Each arm as its value and the words saying where it applies. + + Which arm is the fallback is a fact about the math, so the *walk* + chooses between "if" and "otherwise" and a Format only stacks the rows. + """ + arms = [] + for arm in node.arms: + when = ( + self.format.prose('otherwise') + if arm.when is None + else f'{self.format.prose("if ")} {self.where(arm.when, ctx, need=1)}' + ) + arms.append((self.arithmetic(arm.value, ctx), when)) + return arms + def variables(self) -> list[Line]: """One line per variable, and one more for a set the variable carries. diff --git a/src/math_spec/validation.py b/src/math_spec/validation.py index 725c608e..d94afba5 100644 --- a/src/math_spec/validation.py +++ b/src/math_spec/validation.py @@ -18,6 +18,7 @@ from math_spec.expression_parser import ( ArithmeticNode, BinaryOperatorNode, + CasesNode, ComparisonNode, FunctionCallNode, KeywordNode, @@ -102,9 +103,15 @@ 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, ceiling=1 - ) + context = f"Named expression '{ename}'" + if not block.cases: + assert block.expression is not None + _check_expression(block.expression, schema, ns, context, errors, comparison=False, ceiling=1) + continue + for case_name, case in block.cases.items(): + case_context = f"{context}, case '{case_name}'" + _check_where(case.when, ns, case_context, errors) + _check_expression(case.expression, schema, ns, case_context, errors, comparison=False, ceiling=1) for vname, vdef in schema.variables.items(): _check_where(vdef.where, ns, f"Variable '{vname}'", errors, self_variable=vname) @@ -288,4 +295,10 @@ def _check_template_names( _check_template_names(value, template, context, ns, formals, errors) return + if isinstance(node, CasesNode): + # the values only: a `when` is 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_public_surface.py b/tests/test_public_surface.py index eecc51d5..c72633c9 100644 --- a/tests/test_public_surface.py +++ b/tests/test_public_surface.py @@ -30,6 +30,8 @@ 'NameListNode', 'VariableNode', 'ParameterNode', 'DimensionNode', 'LookupNode', 'EdgeNode', 'KeywordNode', 'UnaryOperatorNode', 'BinaryOperatorNode', 'FunctionCallNode', 'children', + # a value defined by region, and one of its arms + 'CasesNode', 'CaseArm', # the groups a pass asks about, rather than re-listing the classes in it 'LeafNode', 'BranchNode', 'KwargNode', 'UnresolvedNode', 'UnresolvedWhereNode', 'TypedPredicateNode', 'ConnectiveWhereNode', diff --git a/tests/test_validation.py b/tests/test_validation.py index bbb00b1a..75822a4c 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -6,13 +6,15 @@ from __future__ import annotations +import copy import datetime -from typing import TYPE_CHECKING +import re +from typing import TYPE_CHECKING, Any import pytest from math_spec._yaml import parse_yaml -from math_spec.errors import LanguageError, SchemaError +from math_spec.errors import DimensionError, LanguageError, SchemaError from math_spec.resolution import Namespace, where_of from math_spec.validation import load_model from math_spec.where_parser import DimensionPositionNode @@ -639,6 +641,133 @@ def test_a_default_is_written_out_and_an_absence_is_not(self): ) +#: A model with room for a cased expression: two dimensions, so an arm can be +#: narrower than the frame, and a variable, so an arm can reach one. +CASED_BASE = { + 'dimensions': {'snapshot': {'dtype': 'int'}, 'generator': {'values': ['gas', 'oil']}}, + 'parameters': {'p_max': {'dims': ['generator']}, 'load': {'dims': ['snapshot']}}, + 'variables': {'p': {'foreach': ['snapshot', 'generator']}}, +} + +#: Two regions of one quantity: the opening snapshot, and everything else. +OPENING_THEN_REST = { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'expression': 0}, +} + + +def _cased(cases: dict[str, Any] | None = None, **block: Any) -> dict[str, Any]: + """`CASED_BASE` with one cased expression named `headroom`.""" + declared = {'foreach': ['snapshot', 'generator'], 'cases': cases or OPENING_THEN_REST, **block} + return {**copy.deepcopy(CASED_BASE), 'expressions': {'headroom': declared}} + + +class TestExpressionCases: + """`cases:` on a named expression — the declaration, and the shape it must have.""" + + def test_a_cased_expression_loads(self): + schema = load_model(_cased()) + assert list(schema.expressions['headroom'].cases) == ['opening', 'later'] + assert schema.expressions['headroom'].cases['later'].when is None + + def test_it_round_trips(self): + """The mapping form goes back out as it came in, fallback and all.""" + schema = load_model(_cased(description='what is spare')) + assert load_model(schema.to_dict()).to_yaml() == schema.to_yaml() + + def test_a_constant_case_may_be_written_as_a_number(self): + """YAML reads `expression: 0` as an int, and a constant is the common case body.""" + assert load_model(_cased()).expressions['headroom'].cases['later'].expression == '0' + + @pytest.mark.parametrize( + ('block', 'fragment'), + [ + pytest.param( + {'expression': 'load', 'foreach': ['snapshot'], 'cases': OPENING_THEN_REST}, + 'this has both', + id='both', + ), + pytest.param({'description': 'nothing at all'}, 'this has neither', id='neither'), + pytest.param({'cases': OPENING_THEN_REST}, '`cases:` needs a `foreach:`', id='no-foreach'), + pytest.param( + {'expression': 'load', 'foreach': ['snapshot']}, + '`foreach:` is only for a named expression with `cases:`', + id='foreach-alone', + ), + ], + ) + def test_the_two_forms_do_not_mix(self, block: dict[str, Any], fragment: str): + model = {**copy.deepcopy(CASED_BASE), 'expressions': {'headroom': block}} + with pytest.raises(SchemaError, match=re.escape(fragment)): + load_model(model) + + def test_one_case_is_one_expression(self): + with pytest.raises(SchemaError, match='at least two cases'): + load_model(_cased({'only': {'expression': 0}})) + + def test_the_last_case_must_be_the_fallback(self): + """Without one the quantity has no value outside the arms, and absence spreads.""" + cases = { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'when': 'position(snapshot) > 0', 'expression': 0}, + } + with pytest.raises(SchemaError, match='the last case is the fallback'): + load_model(_cased(cases)) + + def test_no_earlier_case_may_omit_its_when(self): + """A fallback above another arm would make that arm unreachable.""" + cases = {'always': {'expression': 'p_max'}, 'opening': {'when': 'position(snapshot) == 0', 'expression': 0}} + with pytest.raises(SchemaError, match='the last case is the fallback'): + load_model(_cased(cases)) + + def test_the_frame_must_name_declared_dimensions(self): + with pytest.raises(SchemaError, match="references undeclared dimension 'region'"): + load_model(_cased(foreach=['snapshot', 'region'])) + + def test_a_case_may_not_widen_the_frame(self): + """A case is a value within the frame, and `load` carries a dim it lacks.""" + cases = {'gas': {'when': "generator == 'gas'", 'expression': 'p_max'}, 'rest': {'expression': 'load'}} + with pytest.raises(DimensionError, match="case 'rest': the value carries dims \\['snapshot'\\]"): + load_model(_cased(cases, foreach=['generator'])) + + def test_a_when_may_not_test_a_dim_outside_the_frame(self): + """The same rule a variable's or a constraint's mask is held to.""" + cases = {'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, 'later': {'expression': 0}} + with pytest.raises(DimensionError, match="'snapshot', which is not in the frame"): + load_model(_cased(cases, foreach=['generator'])) + + def test_an_unknown_name_in_a_case_is_a_load_error(self): + with pytest.raises(SchemaError, match="case 'later'"): + load_model(_cased({**OPENING_THEN_REST, 'later': {'expression': 'nonexistent'}})) + + def test_a_case_may_not_compare(self): + cases = {'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max >= 0'}, 'later': {'expression': 0}} + with pytest.raises(SchemaError, match='must not contain a comparison operator'): + load_model(_cased(cases)) + + def test_a_constraint_naming_it_carries_the_declared_frame(self): + """Not the union of the arms: an arm narrower than the frame broadcasts.""" + model = _cased() + model['constraints'] = {'spare': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}} + load_model(model) + + model['constraints'] = {'spare': {'foreach': ['generator'], 'expression': 'p <= headroom'}} + with pytest.raises(DimensionError, match='snapshot'): + load_model(model) + + def test_an_arm_may_name_another_expression(self): + model = _cased({**OPENING_THEN_REST, 'opening': {'when': 'position(snapshot) == 0', 'expression': 'spare'}}) + model['expressions']['spare'] = 'p_max * 2' + model['constraints'] = {'cap': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}} + load_model(model) + + def test_a_macro_may_name_one(self): + model = _cased() + model['macros'] = {'twice': {'args': ['x'], 'template': 'x * 2'}} + model['constraints'] = {'cap': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= twice(headroom)'}} + load_model(model) + + class TestANumberIsAnExpression: """`expression: 0` is a constant, and YAML reads it as an int rather than a string.""" diff --git a/tests/typesetting/golden/latex.out b/tests/typesetting/golden/latex.out index c0c9a2fc..e14dbea2 100644 --- a/tests/typesetting/golden/latex.out +++ b/tests/typesetting/golden/latex.out @@ -68,6 +68,7 @@ \paragraph{Subject to} \begin{align} +\text{starts} && p_{t,g} & \le \mathrm{startup\_cost}_{t,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{balance} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} & = \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{ramp} && p_{t,g} - p_{t \ominus 1,g} & \le p_{t - 1,g} + \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{edges} && p_{t \boxminus_{0} 1,g} & \le p_{t \boxplus_{0} 1,g} + \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ @@ -98,6 +99,11 @@ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \end{align} +\paragraph{Definitions} +\begin{align} +\text{startup\_cost} && \mathrm{startup\_cost}_{t,g} & = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \\ 0 & \text{otherwise} \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} \,:\, \mathrm{p}^{\mathrm{max}}_{g} > 0 \wedge \neg \mathrm{is\_flexible}_{g} \vee \mathrm{p}^{\mathrm{min}}_{g} > 0 \\ diff --git a/tests/typesetting/golden/markdown.out b/tests/typesetting/golden/markdown.out index 7da9a915..347b2e4f 100644 --- a/tests/typesetting/golden/markdown.out +++ b/tests/typesetting/golden/markdown.out @@ -65,6 +65,10 @@ $$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm #### Subject to +**`starts`** + +$$p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + **`balance`** $$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} = \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ @@ -177,6 +181,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`** + +$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \cr 0 & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### Variable domains **`p`** diff --git a/tests/typesetting/golden/model.yaml b/tests/typesetting/golden/model.yaml index 5df0b2f6..1e388314 100644 --- a/tests/typesetting/golden/model.yaml +++ b/tests/typesetting/golden/model.yaml @@ -82,7 +82,19 @@ sos: over: generator type: 2 +expressions: + startup_cost: # a quantity defined by region: read in order, the last arm the fallback + description: what starting a unit in this snapshot costs, which the horizon's edge changes + foreach: [snapshot, generator] + cases: + opening: { when: "position(snapshot) == 0", expression: cost } + winter: { when: "season_of == 'winter'", expression: cost * 2 } + rest: { expression: 0 } + constraints: + starts: # names the cased expression: its symbol prints here, its block once below + foreach: [snapshot, generator] + expression: p <= startup_cost balance: # sum over a lookup foreach: [snapshot, bus] expression: sum(p, by=gen_bus) + spill - slack == load diff --git a/tests/typesetting/golden/typst.out b/tests/typesetting/golden/typst.out index 18e05dbf..59df3e6d 100644 --- a/tests/typesetting/golden/typst.out +++ b/tests/typesetting/golden/typst.out @@ -57,7 +57,8 @@ $ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) + sum_ == Subject to #set math.equation(numbering: "(1)") -$ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) + italic("spill")_(t) - italic("slack")_(t) & = upright("load")_(t,b) & forall t in cal(T), b in cal(B) \ +$ upright("starts") & p_(t,g) & <= upright("startup_cost")_(t,g) & forall t in cal(T), g in cal(G) \ + upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) + italic("spill")_(t) - italic("slack")_(t) & = upright("load")_(t,b) & forall t in cal(T), b in cal(B) \ upright("ramp") & p_(t,g) - p_(t minus.o 1,g) & <= p_(t - 1,g) + upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("edges") & p_(t minus.square_(0) 1,g) & <= p_(t plus.square_(0) 1,g) + upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("ahead") & p_(t,g) & <= p_(t plus.o 1,g) & forall t in cal(T), g in cal(G) \ @@ -86,6 +87,10 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top and italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ +== Definitions +#set math.equation(numbering: "(1)") +$ upright("startup_cost") & upright("startup_cost")_(t,g) & = cases(upright("cost")_(g) & upright("if ") upright("pos")(t) = 0, upright("cost")_(g) dot 2 & upright("if ") upright("season_of")(t) = upright("'winter'"), 0 & upright("otherwise")) & 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/typesetting/test_cases.py b/tests/typesetting/test_cases.py new file mode 100644 index 00000000..cf10df05 --- /dev/null +++ b/tests/typesetting/test_cases.py @@ -0,0 +1,168 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""A cased expression is the one named expression that prints: once, as a definition, and its uses name it.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from math_spec import SchemaError, expand_piecewise, load_model, to_latex, typeset +from math_spec.typesetting.symbols import chosen_expressions, printed_expressions +from tests.fixtures import DISPATCH_MODEL as DISPATCH +from tests.fixtures import override +from tests.typesetting.fixtures import EVERY_FORMAT + +if TYPE_CHECKING: + from math_spec.typesetting.format import Format + +#: One region and a fallback. `opening` is a column and the fallback a scalar, +#: so the arms alone would not give a quantity its shape — the `foreach` does. +BY_REGION = { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'expression': 0}, + }, +} + +#: The dispatch model, with a quantity defined by region and a constraint using it. +CASED = override( + DISPATCH, + **{ + 'expressions.headroom': BY_REGION, + 'constraints.spare': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}, + }, +) + + +def _sections(rendered: str) -> list[str]: + """The section titles the render printed, in order.""" + return [title for title in ('Objective', 'Subject to', 'Definitions', 'Variable domains') if title in rendered] + + +@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: a block three arms tall puts whatever follows + it beside its middle arm. + """ + rendered = typeset(CASED, fmt, legend=False) + # 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 _sections(rendered) == ['Objective', 'Subject to', 'Definitions', 'Variable domains'] + + +@EVERY_FORMAT +def test_the_last_arm_prints_as_the_fallback_rather_than_a_condition(fmt: Format): + """It has no `when` to print, and `otherwise` is how a paper writes that.""" + rendered = typeset(CASED, fmt, legend=False) + assert fmt.prose('otherwise') in rendered + assert rendered.count(fmt.prose('if ')) == 1, 'one arm carries a condition, and the fallback carries none' + + +@EVERY_FORMAT +def test_a_declared_definition_prints_whether_or_not_a_row_names_it(fmt: Format): + """The rule a variable's domain follows: the file declared it, so it prints.""" + unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + rendered = typeset(unused, fmt, legend=False) + assert rendered.count(fmt.subscript(fmt.upright('headroom'), ['t', 'g'])) == 1, 'the definition, and no use' + assert 'Definitions' 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 mask asks whether the variable *exists* at a coordinate, which the + model settles when it is built; only a value reaching one is a quantity the + solver returns. + """ + masked = override( + CASED, + **{ + 'expressions.headroom.cases': { + 'running': {'when': 'p', 'expression': 'p_max'}, + 'idle': {'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_case_reaching_a_variable_is_chosen(fmt: Format): + """One arm holding a variable is enough: the solver decides the quantity.""" + decided = override(CASED, **{'expressions.headroom.cases.opening.expression': 'p'}) + assert fmt.italic('headroom') in typeset(decided, fmt, legend=False) + + +@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.""" + rendered = typeset(_NESTED, fmt, legend=False) + assert fmt.italic('headroom') in rendered, 'the inner definition was reached through an arm' + assert rendered.count(fmt.subscript(fmt.italic('opening_cost'), ['t', 'g'])) == 2 + + +#: One cased expression reached only through another's arm. `opening_cost` has +#: no variable of its own — its route to one runs through `headroom`. +_NESTED = override( + CASED, + **{ + 'expressions.headroom.cases.opening.expression': 'p', + 'expressions.opening_cost.foreach': ['snapshot', 'generator'], + 'expressions.opening_cost.cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'headroom * cost'}, + 'later': {'expression': 0}, + }, + 'constraints.spare.expression': 'p <= opening_cost', + }, +) + + +def test_a_variable_reached_through_another_cased_expression_still_prints_chosen(): + """The given/chosen cut follows the whole chain, not one link of it. + + `opening_cost` names `headroom` and nothing else that moves; `headroom` + holds a variable. A walk stopping at the inner block would print the outer + one upright — a quantity the solver decides, set as one the model was handed. + """ + schema = expand_piecewise(load_model(_NESTED)) + assert chosen_expressions(schema) == {'headroom', 'opening_cost'} + assert r'\mathit{opening\_cost}' in to_latex(_NESTED, legend=False) + + +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. + """ + 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) + + +def test_the_definitions_print_in_declaration_order(): + """The file's order, not a set's — six of them, so a shuffle cannot pass by luck. + + `printed_expressions` collected into a `frozenset`, whose iteration order + follows string hashes and so is re-randomised every process: the section + printed its rows in a different order on each run, and a generated page + carrying two of them would churn on every regeneration. + """ + declared = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot'] + schema = expand_piecewise(load_model(override(CASED, **{f'expressions.{n}': BY_REGION for n in declared}))) + assert list(printed_expressions(schema)) == ['headroom', *declared], "declaration order, the file's own" diff --git a/tests/typesetting/test_walk.py b/tests/typesetting/test_walk.py index 26071733..33bf76c0 100644 --- a/tests/typesetting/test_walk.py +++ b/tests/typesetting/test_walk.py @@ -15,7 +15,7 @@ from math_spec.piecewise import expand_piecewise from math_spec.typesetting import FORMATS, SymbolTable, to_latex, typeset from math_spec.typesetting.format import OPERATOR_NAMES -from math_spec.typesetting.symbols import Symbols, _derive_name_symbol +from math_spec.typesetting.symbols import Symbols, _derive_name_symbol, chosen_expressions from math_spec.validation import load_model from tests.fixtures import DISPATCH_MODEL, OPERATOR_PROBES, override from tests.typesetting import golden @@ -471,9 +471,10 @@ def test_nothing_the_model_is_given_prints_italic(): lands in one of these two nets. """ schema = expand_piecewise(load_model(golden.MODEL)) + chosen = set(schema.variables) | chosen_expressions(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' ) diff --git a/tools/notation.py b/tools/notation.py index 766ab07d..fa0a4738 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -53,6 +53,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', From 9a215daf3ca158729973df9f75da5ba4c6bd56d5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:31:07 +0200 Subject: [PATCH 3/8] docs: unit commitment, the model cases exists for Co-Authored-By: Claude Opus 5 (1M context) --- .prettierignore | 1 + docs/examples/commitment.md | 168 +++++++++++++++++++++++++ docs/examples/index.md | 2 + docs/reference/language/expressions.md | 3 + examples/commitment.yaml | 73 +++++++++++ mkdocs.yml | 1 + tools/gallery.py | 1 + 7 files changed, 249 insertions(+) create mode 100644 docs/examples/commitment.md create mode 100644 examples/commitment.yaml diff --git a/.prettierignore b/.prettierignore index 128960b9..6618d943 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 docs/examples/pypsa.md docs/examples/pypsa_quadratic.md diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md new file mode 100644 index 00000000..563f66ea --- /dev/null +++ b/docs/examples/commitment.md @@ -0,0 +1,168 @@ + + +# 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 read **in order**, and +the last one carries no `when:` — so the first arm whose condition holds is the +value, and the fallback covers every coordinate the others leave. Exactly one +value at every coordinate, and a value at every one, which is what makes the +quantity a quantity `ramp_up` can use the way it uses a parameter. + +It prints the way a paper writes it: `ramp_up` names the quantity, and the +block itself prints once below, under **Definitions**. + + +```yaml +description: >- + Unit commitment with a start-up ramp, the formulation `cases:` exists for. + The state a unit carries into a snapshot has three regimes — a unit that is + never off, the first snapshot, and every later one — and writing them at the + constraint would fork `ramp_up` three ways. Named once, the inequality is + written once. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { values: [nuclear, gas, oil], description: generating units } + +parameters: + committable: { dims: [generator], dtype: bool, description: whether the unit may be switched off } + status_initial: { dims: [generator], description: whether the unit was running before the horizon } + p_max: { dims: [generator], description: installed capacity } + p_min: { dims: [generator], description: output floor while running } + ramp_limit: { dims: [generator], description: how far output may move between snapshots while running } + start_up_limit: { dims: [generator], description: how far it may move in the snapshot it starts in } + load: { dims: [snapshot], description: demand to be met } + cost: { dims: [generator], description: marginal cost } + +variables: + p: + description: output of a generator in a snapshot + foreach: [snapshot, generator] + bounds: { lower: 0, upper: p_max } + status: + description: whether the unit is running in a snapshot + foreach: [snapshot, generator] + domain: binary + +expressions: + previous_status: + description: the commitment state a unit carries into a snapshot + foreach: [snapshot, generator] + cases: + always_on: + when: "not committable" + expression: 1 + boundary: + when: "position(snapshot) == 0" + expression: status_initial + interior: + expression: shift(status, over=snapshot, offset=1) + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + upper: + description: a unit that is not running produces nothing + foreach: [snapshot, generator] + expression: p <= status * p_max + lower: + description: and one that is running produces at least its floor + foreach: [snapshot, generator] + expression: p >= status * p_min + ramp_up: + description: >- + one inequality for both regimes — a unit already running is held to + `ramp_limit`, a unit starting up to `start_up_limit`. + foreach: [snapshot, generator] + expression: >- + p - shift(p, over=snapshot, offset=1, edge=0) + <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) + +objective: + sense: minimize + expression: sum(p * cost) +``` + +Unit commitment with a start-up ramp, the formulation `cases:` exists for. The state a unit carries into a snapshot has three regimes — a unit that is never off, the first snapshot, and every later one — and writing them at the constraint would fork `ramp_up` three ways. Named once, the inequality is written once. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $\mathcal{T}$ | index $t$ — `snapshot` — dispatch periods | +| $\mathcal{G}$ | index $g$ — `generator` — generating units | + +#### Parameters + +| Symbol | Meaning | +|---|---| +| $\mathrm{committable}$ | `committable` over $\mathcal{G}$ — whether the unit may be switched off | +| $\mathrm{status}^{\mathrm{initial}}$ | `status_initial` over $\mathcal{G}$ — whether the unit was running before the horizon | +| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | +| $\mathrm{p}^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ — output floor while running | +| $\mathrm{ramp\_limit}$ | `ramp_limit` over $\mathcal{G}$ — how far output may move between snapshots while running | +| $\mathrm{start\_up\_limit}$ | `start_up_limit` over $\mathcal{G}$ — how far it may move in the snapshot it starts in | +| $\mathrm{load}$ | `load` over $\mathcal{T}$ — demand to be met | +| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot | +| $\mathit{status}$ | `status` over $\mathcal{T} \times \mathcal{G}$ — whether the unit is running in a snapshot | + +Upright is what the model is given — a parameter such as $\mathrm{committable}$, a coordinate map, a label — and italic is what the solver chooses, such as $p$. An index is italic too, being what a quantifier chooses, and a set is script. + +$t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped. + +$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, not the order labels sort in — counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions. + +#### Objective + +$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}$$ + +#### Subject to + +**`power_balance`** + +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathrm{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ + +**`upper`** + +$$p_{t,g} \le \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`lower`** + +$$p_{t,g} \ge \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{min}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`ramp_up`** + +$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathrm{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathrm{start\_up\_limit}_{g} \cdot \left( 1 - \mathit{previous\_status}_{t,g} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Definitions + +**`previous_status`** + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Variable domains + +**`p`** + +$$0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +**`status`** + +$$\mathit{status}_{t,g} \in \{0, 1\} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/index.md b/docs/examples/index.md index c0a69a5c..30fed3bd 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. - [PyPSA in one file](pypsa.md) — the model `n.optimize()` builds, a diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index ed0f165f..78451dc6 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -407,6 +407,9 @@ this repo is a keyword and would read as the wrong thing. A cased expression joins the symbol pool like any other quantity, so `--symbols` can rename one; uncased ones stay out, since a table entry for one would never apply. +[The unit commitment example](../../examples/commitment.md) is the whole model +this section is drawn from. + **Cases in a `macros:` template are a follow-up.** The fallback would have to cover a frame the macro does not have until it is called. diff --git a/examples/commitment.yaml b/examples/commitment.yaml new file mode 100644 index 00000000..d950baa2 --- /dev/null +++ b/examples/commitment.yaml @@ -0,0 +1,73 @@ +# 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: "position(snapshot) == 0" + expression: status_initial + interior: + expression: shift(status, over=snapshot, offset=1) + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + upper: + description: a unit that is not running produces nothing + foreach: [snapshot, generator] + expression: p <= status * p_max + lower: + description: and one that is running produces at least its floor + foreach: [snapshot, generator] + expression: p >= status * p_min + ramp_up: + description: >- + one inequality for both regimes — a unit already running is held to + `ramp_limit`, a unit starting up to `start_up_limit`. + foreach: [snapshot, generator] + expression: >- + p - shift(p, over=snapshot, offset=1, edge=0) + <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) + +objective: + sense: minimize + expression: sum(p * cost) diff --git a/mkdocs.yml b/mkdocs.yml index 27c63dc2..2f2c241a 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 - PyPSA in one file: examples/pypsa.md - PyPSA, the quadratic class: examples/pypsa_quadratic.md diff --git a/tools/gallery.py b/tools/gallery.py index e46f56d1..277fdf01 100644 --- a/tools/gallery.py +++ b/tools/gallery.py @@ -36,6 +36,7 @@ #: fragments is what the reference pages already are. MODELS = { 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', + 'commitment.md': ROOT / 'examples' / 'commitment.yaml', } #: Page -> the model it shows one declaration at a time — its YAML, then the From 0e55665faac0c39b069ef0eefd6ed03dd1aa7cbb Mon Sep 17 00:00:00 2001 From: Fabian Date: Wed, 26 Aug 2026 20:21:42 +0200 Subject: [PATCH 4/8] refactor: compactify the cases feature One spelling of a cased expression's frame via referenced_dims, one names tuple in Symbols.__init__, and the arm builders as plain loops matching the resolution idiom. --- src/math_spec/expansion.py | 17 ++++++----------- src/math_spec/resolution.py | 15 ++++++--------- src/math_spec/typesetting/symbols.py | 5 +++-- 3 files changed, 15 insertions(+), 22 deletions(-) diff --git a/src/math_spec/expansion.py b/src/math_spec/expansion.py index 8f09de88..8bb3ee9e 100644 --- a/src/math_spec/expansion.py +++ b/src/math_spec/expansion.py @@ -155,17 +155,12 @@ def _parse_cased(name: str, block: ExpressionBlock, context: str) -> CasesNode: carry unresolved ``when`` masks — expansion runs before resolution, so :mod:`math_spec.resolution` types those along with everything else. """ - return CasesNode( - name, - tuple( - CaseArm( - label, - None if case.when is None else parse_where(case.when), - _parse_body(case.expression, f"named expression '{name}', case '{label}'", context), - ) - for label, case in block.cases.items() - ), - ) + arms = [] + for label, case in block.cases.items(): + when = None if case.when is None else parse_where(case.when) + value = _parse_body(case.expression, f"named expression '{name}', case '{label}'", context) + arms.append(CaseArm(label, when, value)) + return CasesNode(name, tuple(arms)) def _parse_body(text: str, subject: str, context: str) -> ArithmeticNode: diff --git a/src/math_spec/resolution.py b/src/math_spec/resolution.py index 8b8a5d15..2dc10714 100644 --- a/src/math_spec/resolution.py +++ b/src/math_spec/resolution.py @@ -331,15 +331,12 @@ def _resolve_arith( return node if isinstance(node, CasesNode): - arms = tuple( - CaseArm( - arm.label, - None if arm.when is None else _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, arms) + arms = [] + for arm in node.arms: + arm_context = f"{context}, case '{arm.label}'" + when = None if arm.when is None else _resolve_where(arm.when, ns, arm_context, errors) + arms.append(CaseArm(arm.label, when, _resolve_arith(arm.value, ns, arm_context, errors))) + return CasesNode(node.name, tuple(arms)) assert_never(node) diff --git a/src/math_spec/typesetting/symbols.py b/src/math_spec/typesetting/symbols.py index 01219d9a..c65eed1c 100644 --- a/src/math_spec/typesetting/symbols.py +++ b/src/math_spec/typesetting/symbols.py @@ -126,7 +126,8 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: raise SchemaError(msg) printed = printed_expressions(schema) chosen = frozenset(schema.variables) | chosen_expressions(schema) - declared = frozenset({*schema.parameters, *schema.variables, *printed}) + names = (*schema.parameters, *schema.variables, *printed) + declared = frozenset(names) #: Names whose symbol came from the table rather than the derivation; #: the convention note quotes only the others, a table being free to @@ -136,7 +137,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 not in chosen) - for name in (*schema.parameters, *schema.variables, *printed) + for name in names } spoken_for = {s for s in self.name.values() if len(s) == 1} From 5ce68e00f17e4c4b00fbd310c205c1c7223da982 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 27 Aug 2026 12:32:25 +0200 Subject: [PATCH 5/8] docs: the cases pages state behavior, and the example constraint binds A vacuous snippet constraint becomes the real ramp inequality, the macro note says not-supported instead of follow-up, and the garbled sentences read straight. --- docs/examples/commitment.md | 12 ++++++------ docs/reference/language/expressions.md | 25 ++++++++++++++----------- examples/commitment.yaml | 4 ++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index 563f66ea..164caabb 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -11,9 +11,9 @@ exists for. Read `previous_status` and then `ramp_up`. The cases are read **in order**, and the last one carries no `when:` — so the first arm whose condition holds is the -value, and the fallback covers every coordinate the others leave. Exactly one -value at every coordinate, and a value at every one, which is what makes the -quantity a quantity `ramp_up` can use the way it uses a parameter. +value, and the fallback covers every coordinate the others leave. One value at +every coordinate — never two, never none — is what lets `ramp_up` use the +quantity the way it uses a parameter. It prints the way a paper writes it: `ramp_up` names the quantity, and the block itself prints once below, under **Definitions**. @@ -24,8 +24,8 @@ 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. + constraint would fork `ramp_up` three ways. With the regimes named once, the + inequality is written once. dimensions: snapshot: { dtype: int, description: dispatch periods } @@ -91,7 +91,7 @@ objective: 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. +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. With the regimes named once, the inequality is written once. #### Sets diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 78451dc6..953d9d17 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -338,8 +338,8 @@ reads none pays for none. Some quantities have no single expression. The commitment state a unit carries into a snapshot is `1` for a unit that is never switched off, an initial condition at the first snapshot, and last snapshot's status everywhere else — -three regimes, one quantity. Written at the constraint they fork it three ways; -named here, the inequality that uses it is written once: +three regimes, one quantity. Written at the constraint, the regimes fork it +three ways; named here, the inequality that uses the quantity is written once: ```yaml expressions: @@ -356,17 +356,19 @@ expressions: interior: expression: shift(status, over=snapshot, offset=1) constraints: - no_restart: + ramp_up: foreach: [snapshot, generator] - expression: status - previous_status <= 1 + expression: >- + p - shift(p, over=snapshot, offset=1, edge=0) + <= ramp_limit * previous_status + start_up_limit * (1 - previous_status) ``` **The cases are read in order, and the last one is the fallback.** The value at a coordinate is the first arm whose `when` holds there, and the last arm carries no `when` at all. So the arms cannot disagree — only the first to match is read — and none of them has to cover everything, because the fallback has no -condition to fail. Both are properties of the block's shape rather than claims -about the masks, so nothing has to decide what a predicate could be true of. +condition to fail. Both guarantees come from the _shape_ of the block; nothing +has to analyse the conditions to establish them. The fallback is required rather than optional because a gap would leave the quantity undefined there, and absence [spreads](absence.md) — every constraint @@ -392,9 +394,10 @@ cases at least — one case is one value everywhere, which the plain form says. ### A cased expression is the one that keeps its name Every other named expression is substituted where it is used and prints nothing -under its own name. A cased one is the exception: three arms are three rows -tall, so inlined at the use site whatever follows sits beside the **middle** -arm and reads as part of that arm's condition. +under its own name. A cased one is the exception, because it cannot inline +legibly: three arms are three rows tall, so inlined at the use site, whatever +follows the name would sit beside the **middle** arm — and a quantity written +once in the file would print once per use on the page. So a use prints the symbol, and the block prints once under a **Definitions** heading between `Subject to` and `Variable domains`, in declaration order, @@ -410,8 +413,8 @@ uncased ones stay out, since a table entry for one would never apply. [The unit commitment example](../../examples/commitment.md) is the whole model this section is drawn from. -**Cases in a `macros:` template are a follow-up.** The fallback would have to -cover a frame the macro does not have until it is called. +**`cases:` inside a `macros:` template is not supported.** The fallback would +have to cover a frame the macro does not have until it is called. ## Macros diff --git a/examples/commitment.yaml b/examples/commitment.yaml index d950baa2..3c3c7235 100644 --- a/examples/commitment.yaml +++ b/examples/commitment.yaml @@ -6,8 +6,8 @@ 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. + constraint would fork `ramp_up` three ways. With the regimes named once, the + inequality is written once. dimensions: snapshot: { dtype: int, description: dispatch periods } From 579549084e5013a6673946ccc084d2e735362f33 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 27 Aug 2026 12:58:20 +0200 Subject: [PATCH 6/8] docs(language): a case's `when` is documented on its own terms rather than against `where` The contrast explained a value-selecting keyword with row-deleting vocabulary, and repeated it at four sites. The closed schema's own difflib suggestion already names `when` at the moment of the typo. Schema regenerated, the docstring being the block's description. Co-Authored-By: Claude --- docs/reference/language/expressions.md | 18 +++++++----------- schema/math-spec.schema.json | 2 +- src/math_spec/model.py | 10 ++-------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 953d9d17..d941aa58 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -376,17 +376,14 @@ referencing it would lose rows it never masked. It is not free: with no mask to narrow the frame, the last arm has to say what an absent parameter or an unnamed label gets. -**`when:`, not `where:`.** A case selects which value a coordinate takes; it -creates no absence and deletes no row, which is what `where` means on every -other block. A cased expression has no `where` of its own. - **`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 — `always_on` above is exactly that. Each -`when` is held to that frame the way a variable's or a constraint's mask is, -and each case's value must sit inside it. **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. +be a scalar while the condition selecting it is not — `always_on` above is +exactly that. Each `when` is held to that frame the way a variable's or a +constraint's mask is, and each case's value must sit inside it. **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. One `expression:` or a set of `cases:`, never both and never neither, and two cases at least — one case is one value everywhere, which the plain form says. @@ -405,8 +402,7 @@ where a paper states a quantity defined by region: $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -The heading is _Definitions_ rather than the conventional _where_, which in -this repo is a keyword and would read as the wrong thing. A cased expression +A cased expression joins the symbol pool like any other quantity, so `--symbols` can rename one; uncased ones stay out, since a table entry for one would never apply. diff --git a/schema/math-spec.schema.json b/schema/math-spec.schema.json index ccd64238..8ecba05d 100644 --- a/schema/math-spec.schema.json +++ b/schema/math-spec.schema.json @@ -188,7 +188,7 @@ }, "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, which is what ``where`` means on every other\nblock (:doc:`absence `). The **last** case\nomits ``when:`` and is the fallback, which is what makes the quantity total.", + "description": "One case of a named expression: the value, and when it is the value.", "properties": { "expression": { "anyOf": [ diff --git a/src/math_spec/model.py b/src/math_spec/model.py index 7db80eba..985cfdcd 100644 --- a/src/math_spec/model.py +++ b/src/math_spec/model.py @@ -323,13 +323,7 @@ def _number_is_an_expression(value: Any) -> Any: 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, which is what ``where`` means on every other - block (:doc:`absence `). The **last** case - omits ``when:`` and is the fallback, which is what makes the quantity total. - """ + """One case of a named expression: the value, and when it is the value.""" _label: ClassVar[str] = 'an expression case' @@ -400,7 +394,7 @@ def _one_form_or_the_other(self) -> Self: if self.cases and self.foreach is None: msg = ( '`cases:` needs a `foreach:` — it is the frame the cases are read over, and no one ' - "case's body gives it, since a case may be a scalar where its `when` is not." + "case's body gives it, since a case may be a scalar while the condition selecting it is not." ) raise ValueError(msg) if self.foreach is not None and not self.cases: From 6a8ec51e54290681f7860c87007807a5d9eff1d4 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 27 Aug 2026 13:38:30 +0200 Subject: [PATCH 7/8] docs(language): the fallback rule and the hole it cannot close are read apart --- docs/reference/language/expressions.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index d941aa58..50f815a8 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -370,12 +370,20 @@ is read — and none of them has to cover everything, because the fallback has n condition to fail. Both guarantees come from the _shape_ of the block; nothing has to analyse the conditions to establish them. -The fallback is required rather than optional because a gap would leave the -quantity undefined there, and absence [spreads](absence.md) — every constraint -referencing it would lose rows it never masked. It is not free: with no mask to -narrow the frame, the last arm has to say what an absent parameter or an +The fallback is required. Without it a coordinate no `when` matches would have +no value, and absence [spreads](absence.md) — a constraint reading the +expression would lose rows it never masked. The last arm has no mask to narrow +its frame, so it is the arm that has to say what an absent parameter or an unnamed label gets. +But it picks an arm, not a value, and the arm that wins can have none itself. +`interior` above is the case in point: its `shift` carries no `edge=`, so it has +no value at the first snapshot, and `previous_status` is whole only because +`boundary` sits above it and takes that coordinate first. Close such a hole by +ordering an arm ahead of the one that drops out, by giving the `shift` an +`edge=`, or with `absence: zero` on a masked variable. Nothing catches one left +open at load, because whether an arm has a value there depends on the data. + **`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 while the condition selecting it is not — `always_on` above is From 51dd7b7d43bd6be6a3fe4b6bee34ba13f0729393 Mon Sep 17 00:00:00 2001 From: Fabian Date: Thu, 27 Aug 2026 15:41:30 +0200 Subject: [PATCH 8/8] docs(language): the cases pages state one rule per sentence --- docs/examples/commitment.md | 8 +++---- docs/reference/language/expressions.md | 33 +++++++++++++------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index 164caabb..d4407779 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -10,10 +10,10 @@ formulation [`cases:`](../reference/language/expressions.md#cases--one-quantity- exists for. Read `previous_status` and then `ramp_up`. The cases are read **in order**, and -the last one carries no `when:` — so the first arm whose condition holds is the -value, and the fallback covers every coordinate the others leave. One value at -every coordinate — never two, never none — is what lets `ramp_up` use the -quantity the way it uses a parameter. +the last one carries no `when:`. So the value at a coordinate is the first arm +whose condition holds there, and the fallback covers every coordinate the others +leave. One value at every coordinate — never two, never none — is what lets +`ramp_up` use the quantity the way it uses a parameter. It prints the way a paper writes it: `ramp_up` names the quantity, and the block itself prints once below, under **Definitions**. diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index 50f815a8..a183e7d8 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -336,10 +336,10 @@ reads none pays for none. ### `cases:` — one quantity, a value per region Some quantities have no single expression. The commitment state a unit carries -into a snapshot is `1` for a unit that is never switched off, an initial -condition at the first snapshot, and last snapshot's status everywhere else — -three regimes, one quantity. Written at the constraint, the regimes fork it -three ways; named here, the inequality that uses the quantity is written once: +into a snapshot has three regimes: `1` for a unit that is never switched off, an +initial condition at the first snapshot, and the last snapshot's status +everywhere else. Written at the constraint, those regimes fork the inequality +three ways. Named here, the inequality is written once: ```yaml expressions: @@ -385,13 +385,14 @@ ordering an arm ahead of the one that drops out, by giving the `shift` an open at load, because whether an arm has a value there depends on the data. **`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 +expression's dims fall out of its body. A cased one's cannot, since a case may be a scalar while the condition selecting it is not — `always_on` above is -exactly that. Each `when` is held to that frame the way a variable's or a -constraint's mask is, and each case's value must sit inside it. **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. +exactly that. The declared frame is what each `when` is held to, the way a +variable's or a constraint's mask is, and each case's value must sit inside it. + +**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. One `expression:` or a set of `cases:`, never both and never neither, and two cases at least — one case is one value everywhere, which the plain form says. @@ -400,9 +401,9 @@ cases at least — one case is one value everywhere, which the plain form says. Every other named expression is substituted where it is used and prints nothing under its own name. A cased one is the exception, because it cannot inline -legibly: three arms are three rows tall, so inlined at the use site, whatever -follows the name would sit beside the **middle** arm — and a quantity written -once in the file would print once per use on the page. +legibly. Three arms are three rows tall, so whatever follows the name at the use +site would sit beside the **middle** arm — and a quantity written once in the +file would print once per use on the page. So a use prints the symbol, and the block prints once under a **Definitions** heading between `Subject to` and `Variable domains`, in declaration order, @@ -410,9 +411,9 @@ where a paper states a quantity defined by region: $$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -A cased expression -joins the symbol pool like any other quantity, so `--symbols` can rename one; -uncased ones stay out, since a table entry for one would never apply. +A cased expression joins the symbol pool like any other quantity, so +`--symbols` can rename one. Uncased ones stay out, since a table entry for one +would never apply. [The unit commitment example](../../examples/commitment.md) is the whole model this section is drawn from.