diff --git a/docs/examples/commitment.md b/docs/examples/commitment.md index bb7eae55..24a485f0 100644 --- a/docs/examples/commitment.md +++ b/docs/examples/commitment.md @@ -15,6 +15,9 @@ a quantity: exactly one arm applies at every coordinate, so `ramp_up` can use it the way it uses a parameter. A gap or an overlap is a load error naming a witness for it. +It prints the way a paper writes it: `ramp_up` names the quantity, and the +block itself prints once below, under **Definitions**. + ```yaml description: >- @@ -142,7 +145,13 @@ $$p_{t,g} \ge \mathit{status}_{t,g} \cdot p^{\mathrm{min}}_{g} \qquad \forall\th **`ramp_up`** -$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathit{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathit{start\_up\_limit}_{g} \cdot \left( 1 - \mathit{previous\_status}_{t,g} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +#### Definitions + +**`previous_status`** + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### Variable domains diff --git a/docs/reference/language/expressions.md b/docs/reference/language/expressions.md index aaa4cbba..7ed45604 100644 --- a/docs/reference/language/expressions.md +++ b/docs/reference/language/expressions.md @@ -403,11 +403,17 @@ and each `when` must sit **inside** that frame; neither may widen it. [`examples/commitment.yaml`](../../examples/commitment.md) is the whole model this comes from, beside the math it prints. -**A reference carries the regions with it.** `no_restart` above names the -quantity once, so the inequality is written once and the case conditions ride -along into what it prints: +**A reference names the quantity; the block prints once.** A cased expression +is the one kind that does not read well inlined — three arms are three rows +tall, so whatever follows in the equation sits beside the middle one. So a use +prints the symbol, -$$\mathit{status}_{t,g} - \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{status}_{t,g} - \mathit{previous\_status}_{t,g} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + +and the block prints under **Definitions**, which is where a paper states a +quantity defined by region: + +$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathit{committable}_{g} \cr \mathit{status}^{\mathrm{initial}}_{g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathit{committable}_{g} \wedge \mathrm{pos}(t) > 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ ## Macros diff --git a/docs/reference/notation.md b/docs/reference/notation.md index c2b0ac13..6f14ebd9 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -121,23 +121,6 @@ $\mathrm{pos}_{\mathrm{lookup}(t)}(t)$ counts within the group a lookup puts $t$ $\lvert \mathcal{T} \rvert$ denotes the size of the set being counted along, and a position counted from the end prints against it — $\lvert \mathcal{T} \rvert - 1$ is the last position, one less than the size because the first is $0$. -### Named expressions - -A named expression is substituted where its name is used, so it prints nothing under its own name — its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value defined by region is a construct, and the regions read beside the declaration rather than at the use site. - -```yaml -expressions: - startup_cost: # a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate - foreach: [snapshot, generator] - cases: - opening: - when: "position(snapshot) == 0" - expression: cost * p_max - later: - when: "position(snapshot) != 0" - expression: cost -``` - ### The objective #### `objective` @@ -437,7 +420,7 @@ started: expression: slack >= on * startup_cost ``` -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `always` @@ -465,6 +448,28 @@ never: $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +### Definitions + +A named expression is substituted where its name is used, so it normally prints nothing under its own name. A cased one is the exception: its value is defined by region, which is a definition of its own, and the equations using it name it rather than repeating the block. + +#### `startup_cost` + +a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate + +```yaml +startup_cost: + foreach: [snapshot, generator] + cases: + opening: + when: "position(snapshot) == 0" + expression: cost * p_max + later: + when: "position(snapshot) != 0" + expression: cost +``` + +$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + ### Variable domains #### `p` diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index e8f2f004..90a4966c 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -102,10 +102,16 @@ def typeset( table = symbols if isinstance(symbols, SymbolTable) else SymbolTable.load(symbols) walk = Walk(schema, Namespace.of(schema), Symbols(schema, fmt, table.checked_against(schema)), fmt) + # `definitions()` prints what the other sections reached, so it runs after + # them — a statement apart rather than a call inside the list, where the + # order it depends on would be invisible. + objective, constraints, variables = walk.objective(), walk.constraints(), walk.variables() + definitions = walk.definitions() sections = [ - ('Objective', walk.objective()), - ('Subject to', walk.constraints()), - ('Variable domains', walk.variables()), + ('Objective', objective), + ('Subject to', constraints), + ('Definitions', definitions), + ('Variable domains', variables), ] rendered = [fmt.section(title, fmt.equations(lines, numbered=numbered)) for title, lines in sections if lines] diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index 207d58c5..b54ba81f 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -196,8 +196,10 @@ def summation(self, domain: str, body: str) -> str: ... def cases(self, arms: list[tuple[str, str]]) -> str: """A value defined by region: ``(value, condition)`` per arm. - The arms partition the frame, so there is no otherwise-arm and no - order-dependence — a format may print them in any order it likes. + The arms partition the frame, so every one carries a condition and + there is no otherwise-arm to print last. They arrive in the order the + file declares them and print in it — nothing depends on the order, but + a reader comparing the page to the file does. """ ... diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 9ae0766d..896530d6 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -61,6 +61,17 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format) -> str return _word(name, fmt) +def printed_expressions(schema: Buildable) -> frozenset[str]: + """The named expressions that reach the page under their own name. + + A named expression is substituted where it is used, so it normally prints + nothing a symbol could stand for. A **cased** one is the exception: its + value is defined by region, which reads as a definition of its own and is + referred to by name from the equations that use it. + """ + return frozenset(name for name, block in schema.expressions.items() if block.cases) + + class Symbols: r"""How every declared name prints: overrides first, derivation for the rest. @@ -82,11 +93,12 @@ 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.dimensions, *schema.parameters, *schema.variables}) + printed = printed_expressions(schema) + declared = frozenset({*schema.dimensions, *schema.parameters, *schema.variables, *printed}) self.name: dict[str, str] = { name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt) - for name in (*schema.parameters, *schema.variables) + for name in (*schema.parameters, *schema.variables, *printed) } spoken_for = {s for s in self.name.values() if len(s) == 1} @@ -204,7 +216,7 @@ def load(cls, source: str | Path | Mapping[str, Any]) -> SymbolTable: def checked_against(self, schema: Buildable) -> SymbolTable: """Reject entries naming nothing in *schema*, with the near miss.""" dims = set(schema.dimensions) - everything = dims | set(schema.parameters) | set(schema.variables) + everything = dims | set(schema.parameters) | set(schema.variables) | printed_expressions(schema) errors = [ *(_unknown_entry(d, 'dimensions', dims) for d in {*self.indices, *self.sets} - dims), *(_unknown_entry(n, 'names', everything - dims) for n in set(self.names) - everything), diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 6b3488bc..f5459e7d 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -228,6 +228,9 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.policies: set[str] = set() self.positions: set[str] = set() self.numeric_coordinates: set[str] = set() + #: Cased expressions met while rendering, in first-use order. Each one + #: prints once, as a definition of its own; see :meth:`definitions`. + self.defined: dict[str, CasesNode] = {} def op(self, name: str) -> str: return self.format.operators[name] @@ -290,10 +293,11 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return self._call(node, ctx) if isinstance(node, CasesNode): - # An atom: a cases block is self-delimiting, so it never needs a - # bracket around it however it sits in the surrounding arithmetic. - arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] - return self.format.cases(arms), _ATOM + # The symbol, not the cases: the block prints once as a definition + # of its own, and a use of it reads like any other quantity. See + # :meth:`definitions` for why. + self.defined.setdefault(node.name, node) + return ctx.indexed(self.symbols.name[node.name], list(node.foreach)), _ATOM if isinstance(node, (NameNode, NameListNode, KeywordNode, DimensionNode, LookupNode, EdgeNode)): msg = f'{type(node).__name__} reached the typesetter; resolve the expression first.' @@ -605,6 +609,40 @@ def constraints(self) -> list[Line]: ) return lines + def definitions(self) -> list[Line]: + """One line per cased expression the equations used, defining it. + + Inlining a cases block where its name stood is what the AST does, and + it is the wrong thing to print: a three-arm block is three rows tall, + so whatever follows it in the equation sits beside its middle arm and + reads as part of that arm's condition. Worse, a quantity written once + in the file would be written once per use on the page — the opposite of + what naming it was for. + + So a use prints the symbol and the block prints here, which is how a + paper states a quantity defined by region. Run this **after** the + sections that use it: what lands here is what they reached, and an arm + may itself name another cased expression, so the loop runs to a + fixpoint rather than over one pass. + """ + lines: list[Line] = [] + done: set[str] = set() + while pending := [name for name in self.defined if name not in done]: + for name in pending: + done.add(name) + node = self.defined[name] + ctx = self.context(ceiling=2) + arms = [(self.arithmetic(arm.value, ctx), self.where(arm.when, ctx, need=1)) for arm in node.arms] + lines.append( + Line( + label=name, + left=ctx.indexed(self.symbols.name[name], list(node.foreach)), + right=f'{self.op("equal")} {self.format.cases(arms)}', + condition=self.quantifier(list(node.foreach), ''), + ) + ) + return lines + def variables(self) -> list[Line]: """One line per variable, and one more for a set the variable carries. diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index 2113c087..0bdeef72 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -86,11 +86,16 @@ \text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = 0 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = 0 \right) \\ \text{last} && \mathit{on}_{t,g} & = 0 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{pos}(t) = \lvert \mathcal{T} \rvert - 1 \vee \mathrm{pos}_{\mathrm{season\_of}(t)}(t) = \lvert \mathcal{T}_{\mathrm{season\_of}(t)} \rvert - 1 \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ -\text{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{started} && \mathit{slack}_{t} & \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \end{align} +\paragraph{Definitions} +\begin{align} +\text{startup\_cost} && \mathit{startup\_cost}_{t,g} & = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \\ \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} +\end{align} + \paragraph{Variable domains} \begin{align} \text{p} && p^{\mathrm{min}}_{g} \le p_{t,g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( p^{\mathrm{max}}_{g} > 0 \wedge \neg \mathit{is\_flexible}_{g} \vee p^{\mathrm{min}}_{g} > 0 \right) \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index e9b1b40c..a605ff9f 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -151,7 +151,7 @@ $$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mat **`started`** -$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathit{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`always`** @@ -161,6 +161,12 @@ $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$ +#### Definitions + +**`startup_cost`** + +$$\mathit{startup\_cost}_{t,g} = \begin{cases} \mathit{cost}_{g} \cdot p^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### Variable domains **`p`** diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index 3b06bb0e..5307114a 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -75,10 +75,14 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = 0 or upright("pos")_(upright("season_of")(t))(t) = 0) \ upright("last") & italic("on")_(t,g) & = 0 & forall t in cal(T), g in cal(G) colon (upright("pos")(t) = abs(cal(T)) - 1 or upright("pos")_(upright("season_of")(t))(t) = abs(cal(T)_(upright("season_of")(t))) - 1) \ upright("northern") & italic("slack")_(t) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ - upright("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot cases(italic("cost")_(g) dot p^(upright("max"))_(g) & upright("if ") upright("pos")(t) = 0, italic("cost")_(g) & upright("if ") upright("pos")(t) != 0) & forall t in cal(T), g in cal(G) \ + upright("started") & italic("slack")_(t) & >= italic("on")_(t,g) dot italic("startup_cost")_(t,g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ +== Definitions +#set math.equation(numbering: "(1)") +$ upright("startup_cost") & italic("startup_cost")_(t,g) & = cases(italic("cost")_(g) dot p^(upright("max"))_(g) & upright("if ") upright("pos")(t) = 0, italic("cost")_(g) & upright("if ") upright("pos")(t) != 0) & forall t in cal(T), g in cal(G) $ + == Variable domains #set math.equation(numbering: "(1)") $ upright("p") & p^(upright("min"))_(g) <= p_(t,g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) colon (p^(upright("max"))_(g) > 0 and not italic("is_flexible")_(g) or p^(upright("min"))_(g) > 0) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 56ffdfc6..bd845ce4 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -443,7 +443,10 @@ def test_a_description_is_joined_to_its_name_by_a_dash_the_format_renders(fmt: F @EVERY_FORMAT def test_macros_and_named_expressions_are_expanded_away(fmt: Format): - """What prints is the math a backend builds, not the sugar it was spelled with.""" + """What prints is the math a backend builds, not the sugar it was spelled with. + + A cased expression is the one exception, and the test below it says why. + """ model = override( DISPATCH, **{'expressions.supply': 'sum(p, over=generator)', 'constraints.power_balance.expression': 'supply == load'}, @@ -451,6 +454,73 @@ def test_macros_and_named_expressions_are_expanded_away(fmt: Format): assert 'supply' not in typeset(model, fmt, legend=False) +#: The dispatch model, with a quantity defined by region and a constraint using +#: it. `first` is a column and `later` a scalar, so the arms alone would not +#: give the quantity its shape — the declared `foreach` does. +CASED = override( + DISPATCH, + **{ + 'expressions.headroom': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'p_max'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare': {'foreach': ['snapshot', 'generator'], 'expression': 'p <= headroom'}, + }, +) + + +@EVERY_FORMAT +def test_a_cased_expression_is_the_exception_that_keeps_its_name(fmt: Format): + """It prints once, as a definition, and its uses name it. + + The other way round — the block inlined at each use — is what the AST does + and the wrong thing to print twice over: a quantity written once in the + file would be written once per use on the page, and a block three arms tall + puts whatever follows it beside its middle arm. + """ + rendered = typeset(CASED, fmt, legend=False) + assert rendered.count(fmt.italic('headroom')) == 2, 'one use and one definition, no more' + assert _section(rendered, fmt) == ['Objective', 'Subject to', 'Definitions', 'Variable domains'] + + +@EVERY_FORMAT +def test_a_definition_is_printed_only_where_something_reached_it(fmt: Format): + """An expression nobody names is sugar nobody unwrapped — it prints nothing.""" + unused = override(CASED, **{'constraints.spare.expression': 'p <= p_max'}) + rendered = typeset(unused, fmt, legend=False) + assert 'headroom' not in rendered + assert 'Definitions' not in rendered + + +@EVERY_FORMAT +def test_a_definition_naming_another_one_prints_both(fmt: Format): + """The arms are walked too, so the collection runs to a fixpoint.""" + nested = override( + CASED, + **{ + 'expressions.opening_cost': { + 'foreach': ['snapshot', 'generator'], + 'cases': { + 'opening': {'when': 'position(snapshot) == 0', 'expression': 'headroom * cost'}, + 'later': {'when': 'position(snapshot) != 0', 'expression': 0}, + }, + }, + 'constraints.spare.expression': 'p <= opening_cost', + }, + ) + rendered = typeset(nested, fmt, legend=False) + assert fmt.italic('headroom') in rendered, 'the inner definition was reached through an arm' + assert rendered.count(fmt.italic('opening_cost')) == 2 + + +def _section(rendered: str, fmt: Format) -> list[str]: + """The section titles *fmt* printed, in order.""" + return [title for title in ('Objective', 'Subject to', 'Definitions', 'Variable domains') if title in rendered] + + @EVERY_FORMAT def test_an_invalid_model_fails_the_same_way_check_does(fmt: Format): broken = override(DISPATCH, **{'objective.expression': 'p * nonexistent'}) @@ -978,6 +1048,21 @@ def test_the_table_overrides_and_the_rest_is_still_derived(): assert r'u \in \mathcal{U}' in tex +def test_the_table_may_rename_a_cased_expression_but_not_a_plain_one(): + """It names what prints, and a cased expression is the only expression that does. + + An entry that never applies is the failure mode the table is strict about: + a reader writes a spelling, sees the old symbol, and has nothing to tell + them why. + """ + tex = to_latex(CASED, symbols={'notation': 'latex', 'names': {'headroom': r'\bar h'}}, legend=False) + assert r'\bar h_{t,g}' in tex + + plain = override(DISPATCH, **{'expressions.supply': 'sum(p, over=generator)'}) + with pytest.raises(SchemaError, match='is not declared by the model'): + to_latex(plain, symbols={'notation': 'latex', 'names': {'supply': 's'}}, legend=False) + + DESCRIBED = override( DISPATCH, **{ diff --git a/tools/notation.py b/tools/notation.py index d8e39997..7770b5b3 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -66,6 +66,7 @@ SECTIONS = { 'objective': 'The objective', 'constraints': 'Constraints', + 'expressions': 'Definitions', 'variables': 'Variable domains', 'piecewise': 'Curves, as what they expand to', 'sos': 'Sets carried to the solver', @@ -189,26 +190,21 @@ def legend(rendered: str) -> str: def preamble(text: str) -> str: """The fixture's ``dimensions``/``lookups``/``parameters`` blocks, verbatim.""" - return '\n'.join(_block(text, name) for name in DECLARED).strip() - - -def _block(text: str, name: str) -> str: - """One top-level block of the fixture, from its key to the next one.""" - body = text[text.index(f'\n{name}:') + 1 :] - end = re.search(r'\n(?=\w)', body) - return body[: end.start()] if end else body - - -#: What the page says about the one block that declares math and prints none. -NAMED = ( - 'A named expression is substituted where its name is used, so it prints nothing under its own name \N{EM DASH} ' - 'its math is in the row of the constraint that names it. `cases:` is why the page shows the block: a value ' - 'defined by region is a construct, and the regions read beside the declaration rather than at the use site.' -) + blocks = [] + for name in DECLARED: + body = text[text.index(f'\n{name}:') + 1 :] + end = re.search(r'\n(?=\w)', body) + blocks.append(body[: end.start()] if end else body) + return '\n'.join(blocks).strip() #: What each section says about itself, where the section needs saying. NOTES = { + 'expressions': ( + 'A named expression is substituted where its name is used, so it normally prints nothing under its own ' + 'name. A cased one is the exception: its value is defined by region, which is a definition of its own, and ' + 'the equations using it name it rather than repeating the block.' + ), 'piecewise': ( 'A curve is sugar: what prints is the formulation it expands to, which is the math the solver ' 'receives. One row per `method:`, each from the model named under it, so the symbols in this ' @@ -226,9 +222,6 @@ def block() -> str: 'print is the legend every model opens with.', f'```yaml\n{preamble(MODEL.read_text())}\n```', legend(rendered), - '### Named expressions', - NAMED, - f'```yaml\n{_block(MODEL.read_text(), "expressions").strip()}\n```', ] printed = equations(rendered) for section, title in SECTIONS.items():