From fb9a5e393a7bbe760aa613c76b3d757fc77f6d10 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 11:44:44 +0000 Subject: [PATCH 1/5] feat: six notation fixes, and the curve weights get a table rather than a rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pass over the page as a whole — which is what `notation.md` exists to make possible — turned up six places where the notation said something other than what it meant. - **A name that is a Greek letter prints as the letter.** A variable called `theta` set as the italic word *theta* is the one derived symbol no paper would accept. Same shape as `_INDEX_ALIASES`: a small curated map, lower case only, with `--symbols` for anything it does not know. It composes with the qualifier rule, so `theta_max` is now a head a qualifier hangs off. - **A dimension is not a head a qualifier hangs off.** `zone_cap` is a capacity *indexed by* zone, not a zone qualified by cap — and reading the axis as the head made a parameter's symbol depend on whether some unrelated dimension happened to share its prefix. The head must name a quantity now. The cost is real and visible in the goldens: `zone_cap` and `bp_x` print as plain names where they used to split, which is what a symbol table is for. - **A negation under a `+` folds into the operator.** The objective was printing `- reserve + -headroom`; nobody writes `a + -b`. Folding also hands the right operand subtraction's bracket rule, which is the one it needs. - **A bracketed operand delimits its own negation**, so `-(Σ x) · 3` no longer wears a second pair of brackets around the whole of it. - **The fill and the group take the operator's two slots** — fill below, group above. They shared one subscript because two subscripts is a TeX error (#1165), but comma-joined there, `0,season_of(t)` said nothing about which was the value standing at the boundary and which was the group. A legend note now states the rule, gated like the others on the symbol appearing. - **A mask that is only `True` prints no condition.** `expressions.md` says `True` is the same as no `where`, so `∀ t ∈ T : ⊤` was a condition that reads as one and is not. Nested it still prints: `⊤ ∧ x` is what the file says, and simplifying a mask belongs to resolution. The seventh was the curve weights: `economies_of_scale_lam` printed six times in one row is unreadable, and papers call it λ. Renaming it in the typesetter would have been a symbol nobody could trace back to the file, so the rename is a **declaration** — the `--symbols` sidecar any reader may write, under the filename convention `tools/render_tex.py` already uses — and the notation page prints the table beside the math it renamed. `bp_x`/`bp_y` ride along as x and y, which is also what those models' equations want. The fixture gains the two cases these need: a nested `True` beside the mask that is only one, and a unary `+`, whose walk arm the line-coverage guard had never reached — it was hidden inside a ternary before this change split it. --- docs/examples/operators.md | 2 +- docs/reference/notation.md | 125 ++++++++++++++++++++-------- examples/symbols/piecewise.yaml | 15 ++++ examples/symbols/piecewise_lp.yaml | 9 ++ examples/symbols/sos.yaml | 15 ++++ examples/symbols/transport_pwl.yaml | 16 ++++ src/math_spec/typeset/format.py | 9 ++ src/math_spec/typeset/latex.py | 3 + src/math_spec/typeset/markdown.py | 3 + src/math_spec/typeset/symbols.py | 42 ++++++++-- src/math_spec/typeset/typst.py | 3 + src/math_spec/typeset/walk.py | 67 ++++++++++++--- tests/typeset/golden/latex.out | 23 ++--- tests/typeset/golden/markdown.out | 26 +++--- tests/typeset/golden/model.yaml | 12 ++- tests/typeset/golden/typst.out | 23 ++--- tests/typeset/test_typeset.py | 77 ++++++++++++++--- tools/notation.py | 30 ++++++- 18 files changed, 400 insertions(+), 100 deletions(-) create mode 100644 examples/symbols/piecewise.yaml create mode 100644 examples/symbols/piecewise_lp.yaml create mode 100644 examples/symbols/sos.yaml create mode 100644 examples/symbols/transport_pwl.yaml diff --git a/docs/examples/operators.md b/docs/examples/operators.md index 0b953aff..1cfe8782 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -324,7 +324,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$p_{t} \le p_{t \ominus_{\mathrm{season\_of}(t)} 1} \qquad \forall\thinspace t \in \mathcal{T}$ +$p_{t} \le p_{t \ominus^{\mathrm{season\_of}(t)} 1} \qquad \forall\thinspace t \in \mathcal{T}$ ### `sum_back(array, over=dim, within=n)` diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 2400c306..89cceee1 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -88,7 +88,7 @@ parameters: | $\mathit{cost}$ | `cost` over $\mathcal{G}$ | | $\mathit{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | | $\mathit{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | -| $\mathit{zone}^{\mathrm{cap}}$ | `zone_cap` over $\mathcal{Z}$ | +| $\mathit{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | | $\mathit{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | | $\mathit{min\_up}$ | `min_up` over $\mathcal{G}$ | | $\mathit{lead}$ | `lead` over $\mathcal{G}$ | @@ -102,7 +102,7 @@ parameters: | $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ | | $\mathit{spill}$ | `spill` over $\mathcal{T}$ | | $\mathit{slack}$ | `slack` over $\mathcal{T}$ | -| $\mathit{theta}$ | `theta` over $\mathcal{B}$ | +| $\theta$ | `theta` over $\mathcal{B}$ | | $\mathit{on}$ | `on` over $\mathcal{T} \times \mathcal{G}$ | | $\mathit{units}$ | `units` over $\mathcal{G}$ | | $\mathit{spare}$ | `spare` over $\mathcal{G}$ | @@ -115,6 +115,8 @@ $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $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. +$t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. + ### The objective #### `objective` @@ -126,7 +128,7 @@ sense: maximize expression: sum(p * cost) + sum(p * p * cost) + sum(p * cost * growth ** lead) + sum(p * p_max) - reserve + -headroom ``` -$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} + -\mathit{headroom}$$ +$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ ### Constraints @@ -238,7 +240,7 @@ in_season: expression: p <= shift(p, over=snapshot, offset=1, edge='wrap', by=season_of) ``` -$$p_{t,g} \le p_{t \ominus_{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} \le p_{t \ominus^{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `held_in_season` @@ -250,7 +252,7 @@ held_in_season: expression: p <= shift(p, over=snapshot, offset=1, edge=0, by=season_of) ``` -$$p_{t,g} \le p_{t \boxminus_{0,\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `window` @@ -286,7 +288,7 @@ pullback: expression: spill <= at(zone_cap, by=zone_of) ``` -$$\mathit{spill}_{t} \le \mathit{zone}^{\mathrm{cap}}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\mathit{spill}_{t} \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ #### `grouped_twice` @@ -314,7 +316,7 @@ $$\mathit{units}_{g} \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_t #### `arithmetic` -division, unary minus, nested reduction, bracketing +division, both unary signs, nested reduction, bracketing ```yaml arithmetic: @@ -322,10 +324,10 @@ arithmetic: # using it would not be a model. Format.power stays exercised by unit # tests rather than from here. foreach: [snapshot] - expression: sum(p / 2 + -cost, over=generator) >= -sum(p, over=generator) * 3 + expression: sum(p / 2 + -cost, over=generator) >= -sum(+p, over=generator) * 3 ``` -$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} + -\mathit{cost}_{g} \right) \ge \left( -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ #### `total` @@ -363,7 +365,7 @@ running: expression: theta <= load ``` -$$\mathit{theta}_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathit{theta}_{b} \text{ exists} \wedge t \ge 3$$ +$$\theta_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ #### `first` @@ -393,7 +395,7 @@ $$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mat #### `always` -the two constant masks, which are a quantifier and no equation +a mask that is only the constant true, which the language says is no mask at all — so none prints ```yaml always: @@ -402,11 +404,24 @@ always: expression: spill >= 0 ``` -$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \top$$ +$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T}$$ + +#### `redundant` + +the same constant *inside* a mask, where it is what the file says and prints + +```yaml +redundant: + foreach: [snapshot] + where: "True AND spill" + expression: spill >= 0 +``` + +$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \top \wedge \mathit{spill}_{t} \text{ exists}$$ #### `never` -the other constant mask +the other constant mask, which says the rows are none and is worth seeing ```yaml never: @@ -465,7 +480,7 @@ theta: foreach: [bus] ``` -$$\mathit{theta}_{b} \in \mathbb{R} \qquad \forall\thinspace b \in \mathcal{B}$$ +$$\theta_{b} \in \mathbb{R} \qquad \forall\thinspace b \in \mathcal{B}$$ #### `on` @@ -561,6 +576,18 @@ A curve is sugar: what prints is the formulation it expands to, which is the mat **`method: adjacency`** — a binary per segment, and a row making the two nonzero weights neighbours, in `examples/ports/transport_pwl.yaml`. +Rendered with the sidecar symbol table `examples/symbols/transport_pwl.yaml`, which is what the weights print as: + +```yaml +notation: latex + +names: + economies_of_scale_lam: "\\lambda" + economies_of_scale_seg: "\\delta" + bp_x: x + bp_y: y +``` + ```yaml economies_of_scale: over: bp @@ -569,24 +596,35 @@ economies_of_scale: - [scaled, bp_y] ``` -$$\sum_{b \in \mathcal{B}} \mathit{economies\_of\_scale\_lam}_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\sum_{b \in \mathcal{B}} \lambda_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \mathit{economies\_of\_scale\_lam}_{p,m,b} \cdot \mathit{bp}^{\mathrm{x}}_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot x_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \mathit{economies\_of\_scale\_lam}_{p,m,b} \cdot \mathit{bp}^{\mathrm{y}}_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot y_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\sum_{b \in \mathcal{B}} \mathit{economies\_of\_scale\_seg}_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\sum_{b \in \mathcal{B}} \delta_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\mathit{economies\_of\_scale\_lam}_{p,m,b} \le \mathit{economies\_of\_scale\_seg}_{p,m,b} + \mathit{economies\_of\_scale\_seg}_{p,m,b \boxminus_{0} 1} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ +$$\lambda_{p,m,b} \le \delta_{p,m,b} + \delta_{p,m,b \boxminus_{0} 1} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ -$$0 \le \mathit{economies\_of\_scale\_lam}_{p,m,b} \le 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ +$$0 \le \lambda_{p,m,b} \le 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ -$$\mathit{economies\_of\_scale\_seg}_{p,m,b} \in \{0, 1\} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ +$$\delta_{p,m,b} \in \{0, 1\} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M},\enspace b \in \mathcal{B}$$ #### `cost_curve` **`method: sos2`** — the same weights, restricted by a set the sink branches on (the sos rules), in `examples/sos.yaml`. +Rendered with the sidecar symbol table `examples/symbols/sos.yaml`, which is what the weights print as: + +```yaml +notation: latex + +names: + cost_curve_lam: "\\lambda" + bp_x: x + bp_y: y +``` + ```yaml cost_curve: over: bp @@ -596,20 +634,31 @@ cost_curve: method: sos2 ``` -$$\sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$p_{t,g} = \sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} \cdot \mathit{bp}^{\mathrm{x}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} \cdot \mathit{bp}^{\mathrm{y}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot y_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$0 \le \mathit{cost\_curve\_lam}_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ +$$0 \le \lambda_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ -$$\left( \mathit{cost\_curve\_lam}_{t,g,b} \right)_{b \in \mathcal{B}} \in \mathrm{SOS}2 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\left( \lambda_{t,g,b} \right)_{b \in \mathcal{B}} \in \mathrm{SOS}2 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `cost_curve` **`method: convex`** — nothing — the weights range over the hull, which is a pure LP, in `examples/piecewise.yaml`. +Rendered with the sidecar symbol table `examples/symbols/piecewise.yaml`, which is what the weights print as: + +```yaml +notation: latex + +names: + cost_curve_lam: "\\lambda" + bp_x: x + bp_y: y +``` + ```yaml cost_curve: over: bp @@ -619,18 +668,28 @@ cost_curve: method: convex ``` -$$\sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$p_{t,g} = \sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} \cdot \mathit{bp}^{\mathrm{x}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \mathit{cost\_curve\_lam}_{t,g,b} \cdot \mathit{bp}^{\mathrm{y}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot y_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$0 \le \mathit{cost\_curve\_lam}_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ +$$0 \le \lambda_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ #### `cost_curve` **`method: lp`** — no weights at all — one row per segment line, plus the two rows holding the domain, in `examples/piecewise_lp.yaml`. +Rendered with the sidecar symbol table `examples/symbols/piecewise_lp.yaml`, which is what the weights print as: + +```yaml +notation: latex + +names: + bp_x: x + bp_y: y +``` + ```yaml cost_curve: over: bp @@ -640,11 +699,11 @@ cost_curve: method: lp ``` -$$\mathit{op\_cost}_{t,g} \cdot \left( \mathit{bp}^{\mathrm{x}}_{g,b} - \mathit{bp}^{\mathrm{x}}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathit{bp}^{\mathrm{y}}_{g,b} - \mathit{bp}^{\mathrm{y}}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathit{bp}^{\mathrm{x}}_{g,b} \right) + \mathit{bp}^{\mathrm{y}}_{g,b} \cdot \left( \mathit{bp}^{\mathrm{x}}_{g,b} - \mathit{bp}^{\mathrm{x}}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ +$$\mathit{op\_cost}_{t,g} \cdot \left( x_{g,b} - x_{g,b \boxminus_{0} 1} \right) \ge \left( y_{g,b} - y_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - x_{g,b} \right) + y_{g,b} \cdot \left( x_{g,b} - x_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ -$$p_{t,g} \ge \mathit{bp}^{\mathrm{x}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ +$$p_{t,g} \ge x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ -$$p_{t,g} \le \mathit{bp}^{\mathrm{x}}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ +$$p_{t,g} \le x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ ### Sets carried to the solver diff --git a/examples/symbols/piecewise.yaml b/examples/symbols/piecewise.yaml new file mode 100644 index 00000000..4eedf972 --- /dev/null +++ b/examples/symbols/piecewise.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/piecewise.yaml`. The weights a curve +# expands to are named after the block that declared them, which is right in +# the file and unreadable in an equation that names one six times: papers write +# the convex-combination weight as lambda, so this says so out loud rather than +# the typesetter renaming anything behind the reader's back. +notation: latex + +names: + cost_curve_lam: "\\lambda" + bp_x: x + bp_y: y diff --git a/examples/symbols/piecewise_lp.yaml b/examples/symbols/piecewise_lp.yaml new file mode 100644 index 00000000..4ce464ea --- /dev/null +++ b/examples/symbols/piecewise_lp.yaml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +notation: latex + +names: + bp_x: x + bp_y: y diff --git a/examples/symbols/sos.yaml b/examples/symbols/sos.yaml new file mode 100644 index 00000000..5cc12e90 --- /dev/null +++ b/examples/symbols/sos.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/sos.yaml`. The weights a curve +# expands to are named after the block that declared them, which is right in +# the file and unreadable in an equation that names one six times: papers write +# the convex-combination weight as lambda, so this says so out loud rather than +# the typesetter renaming anything behind the reader's back. +notation: latex + +names: + cost_curve_lam: "\\lambda" + bp_x: x + bp_y: y diff --git a/examples/symbols/transport_pwl.yaml b/examples/symbols/transport_pwl.yaml new file mode 100644 index 00000000..32b536ad --- /dev/null +++ b/examples/symbols/transport_pwl.yaml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +# The sidecar symbol table for `examples/ports/transport_pwl.yaml`. The +# adjacency method expands to two families — the convex-combination weights and +# the binary that says which segment is live — and names both after the block, +# which no equation can carry. Lambda and delta are what the piecewise-linear +# literature calls them. +notation: latex + +names: + economies_of_scale_lam: "\\lambda" + economies_of_scale_seg: "\\delta" + bp_x: x + bp_y: y diff --git a/src/math_spec/typeset/format.py b/src/math_spec/typeset/format.py index f3302644..844568e0 100644 --- a/src/math_spec/typeset/format.py +++ b/src/math_spec/typeset/format.py @@ -149,6 +149,15 @@ def script(self, letter: str) -> str: """A set symbol.""" ... + def greek(self, name: str) -> str: + """A name that *is* a Greek letter, set as the letter. + + The walk decides which names those are; a format only spells one. + Lower-case names only — every one of them has a letter in both + notations, which the capitals do not. + """ + ... + def prose(self, text: str) -> str: """Words inside math.""" ... diff --git a/src/math_spec/typeset/latex.py b/src/math_spec/typeset/latex.py index ecc26605..6e281890 100644 --- a/src/math_spec/typeset/latex.py +++ b/src/math_spec/typeset/latex.py @@ -95,6 +95,9 @@ def upright(self, name: str) -> str: def script(self, letter: str) -> str: return rf'\mathcal{{{letter}}}' + def greek(self, name: str) -> str: + return f'\\{name}' + def prose(self, text: str) -> str: return rf'\text{{{_escape(text)}}}' diff --git a/src/math_spec/typeset/markdown.py b/src/math_spec/typeset/markdown.py index cdeb6afa..1bb6a8cf 100644 --- a/src/math_spec/typeset/markdown.py +++ b/src/math_spec/typeset/markdown.py @@ -65,6 +65,9 @@ def upright(self, name: str) -> str: def script(self, letter: str) -> str: return _LATEX.script(letter) + def greek(self, name: str) -> str: + return _LATEX.greek(name) + def prose(self, text: str) -> str: return _LATEX.prose(text) diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 9ae0766d..408f1f48 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -38,8 +38,30 @@ _INDEX_ALIASES = {'snapshot': 't', 'snapshots': 't', 'time': 't', 'timestep': 't', 'timesteps': 't'} +#: Names that are a Greek letter written out. A variable called ``theta`` +#: printed as the italic word *theta* is the one derived symbol a paper would +#: never accept, and the fix is the same shape as ``_INDEX_ALIASES``: a small +#: curated map, with ``--symbols`` for anything it does not know. Lower case +#: only — every one of these has a letter in LaTeX and in Typst, which the +#: capitals do not, and ``omicron`` is left out because LaTeX spells it ``o``. +_GREEK = frozenset( + { + 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', + 'iota', 'kappa', 'lambda', 'mu', 'nu', 'xi', 'rho', 'sigma', 'tau', + 'upsilon', 'phi', 'chi', 'psi', 'omega', + } +) # fmt: skip + + def _word(name: str, fmt: Format) -> str: - """One name as one symbol: a letter stays a letter, a word is set italic.""" + """One name as one symbol: a letter stays a letter, a word is set italic. + + A name that *is* a Greek letter is set as the letter, which is what the + author meant by writing it out — the alphabet a model has, not one it is + kept out of. + """ + if name in _GREEK: + return fmt.greek(name) return name if len(name) == 1 else fmt.italic(name) @@ -47,16 +69,22 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format) -> str r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``. An underscore is a **qualifier** only when what precedes it is a symbol in - its own right — a single letter (``p_max``) or another declared name - (``soc_max``). Everywhere else it is word separation, where splitting - produces nonsense: ``marginal_cost`` is not *marginal* raised to *cost*. + its own right — a single letter (``p_max``), a Greek letter written out + (``theta_max``), or another declared **quantity** (``soc_max``). + Everywhere else it is word separation, where splitting produces nonsense: + ``marginal_cost`` is not *marginal* raised to *cost*. + + A quantity, not a dimension: ``zone_cap`` is a capacity *indexed by* zone, + not a zone qualified by cap, and reading the axis as the head made a + parameter's symbol depend on whether some unrelated dimension happened to + share its prefix. The fallback therefore prints the name as written, underscore and all, which is plain rather than beautiful; ``--symbols`` is what makes it pretty. A qualifier lands in the superscript, the subscript slot being spoken for by the dimensions. """ head, _, tail = name.partition('_') - if tail and (len(head) == 1 or head in declared): + if tail and (len(head) == 1 or head in _GREEK or head in declared): return fmt.superscript(_word(head, fmt), fmt.upright(tail.replace('_', ','))) return _word(name, fmt) @@ -82,7 +110,9 @@ 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}) + # quantities only — see `_derive_name_symbol` for why an axis is not a + # head a qualifier may hang off + declared = frozenset({*schema.parameters, *schema.variables}) self.name: dict[str, str] = { name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt) diff --git a/src/math_spec/typeset/typst.py b/src/math_spec/typeset/typst.py index 4b618e45..70a51cb5 100644 --- a/src/math_spec/typeset/typst.py +++ b/src/math_spec/typeset/typst.py @@ -105,6 +105,9 @@ def upright(self, name: str) -> str: def script(self, letter: str) -> str: return f'cal({letter})' + def greek(self, name: str) -> str: + return name + def prose(self, text: str) -> str: return f'upright({_quote(text)})' diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 75e9b1ec..2cd7991e 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -213,8 +213,9 @@ def indexed(self, symbol: str, dims: list[str]) -> str: class Walk: """Walks a validated schema, emitting :class:`Line`s in one format. - Stateful only in what it has *noticed* — which edge policies appeared, - which the legend needs to explain the symbols they print. + Stateful only in what it has *noticed* — which edge policies appeared and + whether a translation was counted inside a group, which the legend needs + to explain the symbols they print. """ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fmt: Format) -> None: @@ -223,6 +224,7 @@ def __init__(self, schema: Buildable, namespace: Namespace, symbols: Symbols, fm self.symbols = symbols self.format = fmt self.policies: set[str] = set() + self.grouped = False def op(self, name: str) -> str: return self.format.operators[name] @@ -230,16 +232,23 @@ def op(self, name: str) -> str: def translation(self, step: _Step, group: str = '') -> str: """The operator for one translation, carrying its fill and its group. - Both ride the operator, so a call with both writes *one* subscript - group: two subscripts on one symbol is a TeX error rather than a - rendering, and the equation carrying it stopped compiling (#1165). + Both ride the operator, and they take **different slots**: the fill + below, the partition above. Sharing one subscript is what a single + symbol allows — two subscripts is a TeX error rather than a rendering + (#1165) — but comma-joined there, ``0,season_of(t)`` said nothing about + which of the two was the value standing at the boundary and which was + the group the translation stays inside. """ backward, forward = _TRANSLATIONS[step.policy] # a named offset is always backward: `by=-p` is refused, so the sign is # in the data and the operator cannot read it off the call operator = self.op(backward if isinstance(step.by, str) or step.by > 0 else forward) - indices = [index for index in (step.fill, group) if index] - return self.format.subscript(operator, indices) if indices else operator + if step.fill: + operator = self.format.subscript(operator, [step.fill]) + if not group: + return operator + self.grouped = True + return self.format.superscript(operator, group) def context(self, ceiling: int = 1) -> _Context: return _Context(self, ceiling=ceiling) @@ -275,8 +284,13 @@ def _arithmetic(self, node: ArithmeticNode, ctx: _Context) -> tuple[str, int]: return ctx.indexed(self.symbols.name[node.name], list(self.schema.variables[node.name].foreach)), _ATOM if isinstance(node, UnaryOperatorNode): - operand = self.arithmetic(node.operand, ctx, need=2) - return (f'{self.op("minus")}{operand}' if node.op == '-' else operand), 1 + text, precedence = self._arithmetic(node.operand, ctx) + operand = self.format.parenthesise(text) if precedence < 2 else text + if node.op != '-': + return operand, precedence + # the operand's own brackets delimit the negation, so `-(Σ x) · 3` + # needs no second pair around the whole of it + return f'{self.op("minus")}{operand}', 2 if precedence < 2 else 1 if isinstance(node, BinaryOperatorNode): return self._binary(node, ctx) @@ -317,9 +331,15 @@ def _binary(self, node: BinaryOperatorNode, ctx: _Context) -> tuple[str, int]: return self.format.superscript(base, self.arithmetic(node.right, ctx)), _PRECEDENCE['**'] precedence = _PRECEDENCE[node.op] left = self.arithmetic(node.left, ctx, need=precedence) - right = self.arithmetic(node.right, ctx, need=precedence + (1 if node.op == '-' else 0)) + # `a + -b` is a spelling nobody uses: a negation under a `+` is the + # subtraction it already means, and folding it also hands the right + # operand subtraction's bracket rule, which is the one it needs + operand, op = node.right, node.op + if op == '+' and isinstance(operand, UnaryOperatorNode) and operand.op == '-': + operand, op = operand.operand, '-' + right = self.arithmetic(operand, ctx, need=_PRECEDENCE[op] + (1 if op == '-' else 0)) names = {'*': 'cdot', '+': 'plus', '-': 'minus'} - return self.format.joined([left, right], self.op(names[node.op])), precedence + return self.format.joined([left, right], self.op(names[op])), precedence def _call(self, node: FunctionCallNode, ctx: _Context) -> tuple[str, int]: """Render an operator: a translation at the leaves, or a summation. @@ -516,7 +536,16 @@ def position(self, dimension: str, at: int, grouping: str | None = None) -> str: return self.format.apply(self.format.upright('index'), ', '.join(parts)) def conjoined(self, ctx: _Context, *nodes: WhereNode | None) -> str: - parts = [self.where(n, ctx, need=1) for n in nodes if n is not None] + r"""The mask on a quantifier, as one condition. + + A mask that is *only* ``True`` prints nothing: the language says it is + the same as no ``where`` at all, so a quantifier reading ``: \top`` + would put a condition on the page that reads as one and is not. Nested + it still prints — ``\top \wedge x`` is what the file says, and + simplifying a mask is resolution's job rather than the typesetter's. + """ + kept = [n for n in nodes if n is not None and not (isinstance(n, BooleanLiteralNode) and n.value)] + parts = [self.where(n, ctx, need=1) for n in kept] return self.format.joined(parts, self.op('and')) if parts else '' def quantifier(self, dims: list[str], condition: str) -> str: @@ -717,4 +746,18 @@ def translation_notes(self) -> list[str]: f'{self.format.math("t-k")} leaves the dimension ({self.format.mono("shift(edge=v)")}), so the row ' f'at that boundary is built and carries {self.format.math("v")} rather than being dropped.' ) + if self.grouped: + applied = self.format.apply(self.format.upright('lookup'), 't') + counted = self.format.math(f't {self.format.superscript(self.op("cyclic_minus"), applied)} k') + note = ( + f'{counted} denotes a translation counted inside the group a lookup puts {self.format.math("t")} ' + f'in ({self.format.mono("shift(by=lookup)")}), so a term never crosses out of its own group.' + ) + if 'edge' in self.policies: + both = self.format.superscript(self.format.subscript(self.op('edge_minus'), ['v']), applied) + note += ( + f' The two modifiers take different slots {self.format.dash} the group above, the fill ' + f'below {self.format.dash} so {self.format.math(f"t {both} k")} is both at once.' + ) + notes.append(note) return notes diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index f0bbee8b..5f665f77 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -24,7 +24,7 @@ \item[$\mathit{cost}$] \texttt{cost} over $\mathcal{G}$ \item[$\mathit{load}$] \texttt{load} over $\mathcal{T} \times \mathcal{B}$ \item[$\mathit{is\_flexible}$] \texttt{is\_flexible} over $\mathcal{G}$ -\item[$\mathit{zone}^{\mathrm{cap}}$] \texttt{zone\_cap} over $\mathcal{Z}$ +\item[$\mathit{zone\_cap}$] \texttt{zone\_cap} over $\mathcal{Z}$ \item[$\mathit{tech\_cap}$] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ \item[$\mathit{min\_up}$] \texttt{min\_up} over $\mathcal{G}$ \item[$\mathit{lead}$] \texttt{lead} over $\mathcal{G}$ @@ -37,7 +37,7 @@ \item[$p$] \texttt{p} over $\mathcal{T} \times \mathcal{G}$ \item[$\mathit{spill}$] \texttt{spill} over $\mathcal{T}$ \item[$\mathit{slack}$] \texttt{slack} over $\mathcal{T}$ -\item[$\mathit{theta}$] \texttt{theta} over $\mathcal{B}$ +\item[$\theta$] \texttt{theta} over $\mathcal{B}$ \item[$\mathit{on}$] \texttt{on} over $\mathcal{T} \times \mathcal{G}$ \item[$\mathit{units}$] \texttt{units} over $\mathcal{G}$ \item[$\mathit{spare}$] \texttt{spare} over $\mathcal{G}$ @@ -51,9 +51,11 @@ \noindent $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (\texttt{shift(edge=v)}), so the row at that boundary is built and carries $v$ rather than being dropped. +\noindent $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (\texttt{shift(by=lookup)}), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. + \paragraph{Objective} \begin{align} - && \max & \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} + -\mathit{headroom} + && \max & \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom} \end{align} \paragraph{Subject to} @@ -66,20 +68,21 @@ \text{uncomposed} && p_{\left( t - 1 \right) - \mathit{lead},g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{crossed} && p_{t \ominus 1,g + 1} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{lead\_time} && p_{t \boxminus_{0} \mathit{lead},g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{in\_season} && p_{t,g} & \le p_{t \ominus_{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{held\_in\_season} && p_{t,g} & \le p_{t \boxminus_{0,\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{in\_season} && p_{t,g} & \le p_{t \ominus^{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{held\_in\_season} && p_{t,g} & \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{window} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{history} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t \ominus t' < \mathit{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{pullback} && \mathit{spill}_{t} & \le \mathit{zone}^{\mathrm{cap}}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ +\text{pullback} && \mathit{spill}_{t} & \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ \text{grouped\_twice} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} & \le \mathit{tech\_cap}_{b,e} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B},\ e \in \mathcal{E} \\ \text{pulled\_back\_twice} && \mathit{units}_{g} & \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} && \forall\, g \in \mathcal{G} \\ -\text{arithmetic} && \sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} + -\mathit{cost}_{g} \right) & \ge \left( -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \right) \cdot 3 && \forall\, t \in \mathcal{T} \\ +\text{arithmetic} && \sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) & \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 && \forall\, t \in \mathcal{T} \\ \text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathit{budget} \\ \text{scalar} && \mathit{units}_{g} & \le \mathit{budget} && \forall\, g \in \mathcal{G} \,:\, \mathit{cost}_{g} \text{ is defined} \\ -\text{running} && \mathit{theta}_{b} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathit{theta}_{b} \text{ exists} \wedge t \ge 3 \\ +\text{running} && \theta_{b} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ \text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \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{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \\ +\text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ +\text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \wedge \mathit{spill}_{t} \text{ exists} \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot \end{align} @@ -88,7 +91,7 @@ \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) \\ \text{spill} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{slack} && \mathit{slack}_{t} & \le 100 && \forall\, t \in \mathcal{T} \\ -\text{theta} && \mathit{theta}_{b} & \in \mathbb{R} && \forall\, b \in \mathcal{B} \\ +\text{theta} && \theta_{b} & \in \mathbb{R} && \forall\, b \in \mathcal{B} \\ \text{on} && \mathit{on}_{t,g} & \in \{0, 1\} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{units} && 0 \le \mathit{units}_{g} & \le 10, \mathit{units}_{g} \in \mathbb{Z} && \forall\, g \in \mathcal{G} \\ \text{spare} && \mathit{spare}_{g} & \in \mathbb{Z} && \forall\, g \in \mathcal{G} \\ diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index a5009f64..a541781c 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -22,7 +22,7 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | $\mathit{cost}$ | `cost` over $\mathcal{G}$ | | $\mathit{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | | $\mathit{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | -| $\mathit{zone}^{\mathrm{cap}}$ | `zone_cap` over $\mathcal{Z}$ | +| $\mathit{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | | $\mathit{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | | $\mathit{min\_up}$ | `min_up` over $\mathcal{G}$ | | $\mathit{lead}$ | `lead` over $\mathcal{G}$ | @@ -36,7 +36,7 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ | | $\mathit{spill}$ | `spill` over $\mathcal{T}$ | | $\mathit{slack}$ | `slack` over $\mathcal{T}$ | -| $\mathit{theta}$ | `theta` over $\mathcal{B}$ | +| $\theta$ | `theta` over $\mathcal{B}$ | | $\mathit{on}$ | `on` over $\mathcal{T} \times \mathcal{G}$ | | $\mathit{units}$ | `units` over $\mathcal{G}$ | | $\mathit{spare}$ | `spare` over $\mathcal{G}$ | @@ -49,9 +49,11 @@ $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $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. +$t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots — the group above, the fill below — so $t \boxminus_{v}^{\mathrm{lookup}(t)} k$ is both at once. + #### Objective -$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} + -\mathit{headroom}$$ +$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ #### Subject to @@ -89,11 +91,11 @@ $$p_{t \boxminus_{0} \mathit{lead},g} \le p^{\mathrm{max}}_{g} \qquad \forall\th **`in_season`** -$$p_{t,g} \le p_{t \ominus_{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} \le p_{t \ominus^{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`held_in_season`** -$$p_{t,g} \le p_{t \boxminus_{0,\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`window`** @@ -105,7 +107,7 @@ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathit{mi **`pullback`** -$$\mathit{spill}_{t} \le \mathit{zone}^{\mathrm{cap}}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\mathit{spill}_{t} \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ **`grouped_twice`** @@ -117,7 +119,7 @@ $$\mathit{units}_{g} \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_t **`arithmetic`** -$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} + -\mathit{cost}_{g} \right) \ge \left( -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ **`total`** @@ -129,7 +131,7 @@ $$\mathit{units}_{g} \le \mathit{budget} \qquad \forall\thinspace g \in \mathcal **`running`** -$$\mathit{theta}_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathit{theta}_{b} \text{ exists} \wedge t \ge 3$$ +$$\theta_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ **`first`** @@ -141,7 +143,11 @@ $$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mat **`always`** -$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \top$$ +$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T}$$ + +**`redundant`** + +$$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \top \wedge \mathit{spill}_{t} \text{ exists}$$ **`never`** @@ -163,7 +169,7 @@ $$\mathit{slack}_{t} \le 100 \qquad \forall\thinspace t \in \mathcal{T}$$ **`theta`** -$$\mathit{theta}_{b} \in \mathbb{R} \qquad \forall\thinspace b \in \mathcal{B}$$ +$$\theta_{b} \in \mathbb{R} \qquad \forall\thinspace b \in \mathcal{B}$$ **`on`** diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index b70d5653..0ed0e697 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -132,12 +132,12 @@ constraints: pulled_back_twice: # its adjoint, reading one slot through a pair of labels foreach: [generator] expression: units <= at(tech_cap, by=[gen_bus, gen_tech]) - arithmetic: # division, unary minus, nested reduction, bracketing + arithmetic: # division, both unary signs, nested reduction, bracketing # No `**`: the walk renders it, but `lower_program` rejects it, so a model # using it would not be a model. Format.power stays exercised by unit # tests rather than from here. foreach: [snapshot] - expression: sum(p / 2 + -cost, over=generator) >= -sum(p, over=generator) * 3 + expression: sum(p / 2 + -cost, over=generator) >= -sum(+p, over=generator) * 3 total: # a sum naming no dim, whose domain is the one place the dims it took are said foreach: [] expression: sum(p) <= budget @@ -157,11 +157,15 @@ constraints: foreach: [snapshot, bus] where: "zone_of == 'north' AND zone_of != area_of AND zone_of" expression: slack <= load - always: # the two constant masks, which are a quantifier and no equation + always: # a mask that is only the constant true, which the language says is no mask at all — so none prints foreach: [snapshot] where: "true" expression: spill >= 0 - never: # the other constant mask + redundant: # the same constant *inside* a mask, where it is what the file says and prints + foreach: [snapshot] + where: "True AND spill" + expression: spill >= 0 + never: # the other constant mask, which says the rows are none and is worth seeing foreach: [snapshot] where: "false" expression: slack >= 0 diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index cade2a0f..ade7a439 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -17,7 +17,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("cost")$: `cost` over $cal(G)$ / $italic("load")$: `load` over $cal(T) times cal(B)$ / $italic("is_flexible")$: `is_flexible` over $cal(G)$ -/ $italic("zone")^(upright("cap"))$: `zone_cap` over $cal(Z)$ +/ $italic("zone_cap")$: `zone_cap` over $cal(Z)$ / $italic("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ / $italic("min_up")$: `min_up` over $cal(G)$ / $italic("lead")$: `lead` over $cal(G)$ @@ -28,7 +28,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $p$: `p` over $cal(T) times cal(G)$ / $italic("spill")$: `spill` over $cal(T)$ / $italic("slack")$: `slack` over $cal(T)$ -/ $italic("theta")$: `theta` over $cal(B)$ +/ $theta$: `theta` over $cal(B)$ / $italic("on")$: `on` over $cal(T) times cal(G)$ / $italic("units")$: `units` over $cal(G)$ / $italic("spare")$: `spare` over $cal(G)$ @@ -41,9 +41,11 @@ $t minus.o k$ denotes cyclic translation: index $t-k$ taken modulo the size of t $t minus.square_(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. +$t minus.o^(upright("lookup")(t)) k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. The two modifiers take different slots --- the group above, the fill below --- so $t minus.square_(v)^(upright("lookup")(t)) k$ is both at once. + == Objective #set math.equation(numbering: "(1)") -$ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) dot italic("growth")^(italic("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p^(upright("max"))_(g) - italic("reserve") + -italic("headroom") $ +$ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) dot italic("growth")^(italic("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p^(upright("max"))_(g) - italic("reserve") - italic("headroom") $ == Subject to #set math.equation(numbering: "(1)") @@ -55,20 +57,21 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("uncomposed") & p_((t - 1) - italic("lead"),g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("crossed") & p_(t minus.o 1,g + 1) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("lead_time") & p_(t minus.square_(0) italic("lead"),g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ - upright("in_season") & p_(t,g) & <= p_(t minus.o_(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ - upright("held_in_season") & p_(t,g) & <= p_(t minus.square_(0,upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ + upright("in_season") & p_(t,g) & <= p_(t minus.o^(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ + upright("held_in_season") & p_(t,g) & <= p_(t minus.square_(0)^(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ upright("window") & sum_(t' in cal(T) colon 0 <= t - t' < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ upright("history") & sum_(t' in cal(T) colon 0 <= t minus.o t' < italic("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ - upright("pullback") & italic("spill")_(t) & <= italic("zone")^(upright("cap"))_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ + upright("pullback") & italic("spill")_(t) & <= italic("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ upright("grouped_twice") & sum_(g in cal(G) colon upright("gen_bus")(g) = b and upright("gen_tech")(g) = e) p_(t,g) & <= italic("tech_cap")_(b,e) & forall t in cal(T), b in cal(B), e in cal(E) \ upright("pulled_back_twice") & italic("units")_(g) & <= italic("tech_cap")_(upright("gen_bus")(g),upright("gen_tech")(g)) & forall g in cal(G) \ - upright("arithmetic") & sum_(g in cal(G)) (frac(p_(t,g), 2) + -italic("cost")_(g)) & >= (-(sum_(g in cal(G)) p_(t,g))) dot 3 & forall t in cal(T) \ + upright("arithmetic") & sum_(g in cal(G)) (frac(p_(t,g), 2) - italic("cost")_(g)) & >= -(sum_(g in cal(G)) p_(t,g)) dot 3 & forall t in cal(T) \ upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= italic("budget") \ upright("scalar") & italic("units")_(g) & <= italic("budget") & forall g in cal(G) colon italic("cost")_(g) upright(" is defined") \ - upright("running") & italic("theta")_(b) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon italic("theta")_(b) upright(" exists") and t >= 3 \ + upright("running") & theta_(b) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (t = upright("index")(cal(T), 0) or t = upright("index")(cal(T), 0, upright("season_of")(t))) \ 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("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top \ + upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ + upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top and italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ == Variable domains @@ -76,7 +79,7 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) $ 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) \ upright("spill") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("slack") & italic("slack")_(t) & <= 100 & forall t in cal(T) \ - upright("theta") & italic("theta")_(b) & in RR & forall b in cal(B) \ + upright("theta") & theta_(b) & in RR & forall b in cal(B) \ upright("on") & italic("on")_(t,g) & in {0, 1} & forall t in cal(T), g in cal(G) \ upright("units") & 0 <= italic("units")_(g) & <= 10, italic("units")_(g) in ZZ & forall g in cal(G) \ upright("spare") & italic("spare")_(g) & in ZZ & forall g in cal(G) \ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index 61fa467e..d764ce4f 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -234,14 +234,15 @@ def storage(edge: str) -> dict[str, object]: @EVERY_FORMAT -def test_a_fill_and_a_group_share_the_operators_one_subscript(fmt: Format): - """Both policies subscript the operator, so a call carrying both writes one - subscript group rather than two. - - Not a matter of taste: `\\boxminus_{0}_{season_of(t)}` is a *Double - subscript* error, so the page stopped compiling at the equation — and the - two policies are independent, so nothing else in the model has to be - wrong for a model to reach it. +def test_a_fill_and_a_group_take_the_operators_two_slots(fmt: Format): + """The fill subscripts the operator; the group superscripts it. + + One slot each, so neither `\\boxminus_{0}_{season_of(t)}` — a *Double + subscript* error that stopped the page compiling — nor + `\\boxminus_{0,season_of(t)}`, which compiles and leaves a reader to guess + which of the two is the value standing at the boundary and which is the + group the translation stays inside. The two policies are independent, so + nothing else has to be wrong for a model to reach this. """ model = { 'dimensions': {'snapshot': {'dtype': 'int'}, 'season': {'dtype': 'str'}}, @@ -256,10 +257,11 @@ def test_a_fill_and_a_group_share_the_operators_one_subscript(fmt: Format): 'objective': {'sense': 'minimize', 'expression': 'sum(p)'}, } text = typeset(model, fmt, legend=False) - opened = fmt.subscript(fmt.operators['edge_minus'], ['0', 'GROUP']).split('GROUP')[0] - assert opened in text, 'the fill and the group do not share the one subscript the operator has' - assert fmt.subscript(fmt.operators['edge_minus'], ['0']) not in text, ( - 'the fill closed its own subscript and the group opened a second one' + group = fmt.apply(fmt.upright('season_of'), 't') + filled = fmt.subscript(fmt.operators['edge_minus'], ['0']) + assert fmt.superscript(filled, group) in text, 'the fill and the group are not in their own slots' + assert fmt.subscript(fmt.operators['edge_minus'], ['0', group]) not in text, ( + 'the fill and the group are sharing one subscript again' ) @@ -342,6 +344,29 @@ def test_translations_that_disagree_at_the_edge_do_not_merge(fmt: Format): ) +@EVERY_FORMAT +def test_a_negation_under_a_plus_is_the_subtraction_it_means(fmt: Format): + """`a + -b` is a spelling nobody uses, and the walk was printing it.""" + model = override(DISPATCH, **{'objective.expression': 'sum(p) + -sum(p)'}) + text = typeset(model, fmt) + assert f'{fmt.operators["plus"]} {fmt.operators["minus"]}' not in text + assert fmt.operators['minus'] in text, 'the subtraction it folded into should still print' + + +@EVERY_FORMAT +def test_a_mask_that_is_only_true_prints_no_condition(fmt: Format): + """The language says `True` is the same as no `where`, so a `\\top` on the + quantifier would put a condition on the page that reads as one and is not. + + Nested it still prints: `\\top \\wedge x` is what the file says, and simplifying a + mask belongs to resolution rather than to the typesetter. + """ + always = override(DISPATCH, **{'constraints.power_balance.where': 'True'}) + assert fmt.operators['true'] not in typeset(always, fmt) + nested = override(DISPATCH, **{'constraints.power_balance.where': 'True AND load > 0'}) + assert fmt.operators['true'] in typeset(nested, fmt) + + @EVERY_FORMAT def test_the_legend_explains_wraparound_only_when_it_is_used(fmt: Format): rolled = { @@ -401,6 +426,9 @@ def test_an_invalid_model_fails_the_same_way_check_does(fmt: Format): pytest.param('soc_max', r'\mathit{soc}^{\mathrm{max}}', id='declared-head-so-the-tail-is-a-qualifier'), pytest.param('marginal_cost', r'\mathit{marginal\_cost}', id='neither-so-it-stays-one-word'), pytest.param('shut_down', r'\mathit{shut\_down}', id='neither-even-when-the-tail-reads-like-a-qualifier'), + pytest.param('theta', r'\theta', id='a-name-that-is-a-greek-letter-is-the-letter'), + pytest.param('theta_max', r'\theta^{\mathrm{max}}', id='and-is-a-head-a-qualifier-may-hang-off'), + pytest.param('thetas', r'\mathit{thetas}', id='but-only-when-the-whole-name-is-the-letter'), ], ) def test_an_underscore_is_only_a_qualifier_when_its_head_is_a_symbol(name: str, expected: str): @@ -409,6 +437,31 @@ def test_an_underscore_is_only_a_qualifier_when_its_head_is_a_symbol(name: str, assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX) == expected +@EVERY_FORMAT +def test_a_name_that_is_a_greek_letter_prints_as_the_letter(fmt: Format): + """A variable called `theta` set as the italic word *theta* is the one + derived symbol no paper would accept.""" + model = override(DISPATCH, **{'variables.theta': {'foreach': ['snapshot']}}) + assert fmt.greek('theta') in typeset(model, fmt) + + +@EVERY_FORMAT +def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(fmt: Format): + """`zone_cap` is a capacity *indexed by* zone, not a zone qualified by cap. + + Reading the axis as the head also made a parameter's symbol depend on + whether some unrelated dimension happened to share its prefix: declare a + dimension named `tech` and `tech_cap` silently re-rendered. + """ + model = override( + DISPATCH, + **{'dimensions.zone': {'dtype': 'str'}, 'parameters.zone_cap': {'dims': ['zone']}}, + ) + text = typeset(model, fmt) + assert fmt.italic('zone_cap') in text + assert fmt.superscript(fmt.italic('zone'), fmt.upright('cap')) not in text + + @EVERY_FORMAT def test_the_objective_shows_the_summations_the_file_wrote(fmt: Format): """One summation per ``sum`` in the expression, over the dims it took. diff --git a/tools/notation.py b/tools/notation.py index 86c0cb9f..7f4b3892 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -233,7 +233,8 @@ def _curves() -> list[str]: """One row per ``method:``, each captioned with what that method restricts.""" rows = [] for method, source in PIECEWISE.items(): - printed = equations(to_markdown(source, numbered=False)) + table = _symbols(source) + printed = equations(to_markdown(source, symbols=table, numbered=False)) found = [block for block in declarations(source.read_text())['piecewise'] if _method(block) == method] assert found, f'{source.name} declares no piecewise block with method: {method}' for block in found: @@ -241,10 +242,35 @@ def _curves() -> list[str]: caption = ( f'**`method: {method}`** \N{EM DASH} {PIECEWISE_METHODS[method]}, in `{source.relative_to(ROOT)}`.' ) - rows.append(row.replace('\n\n', f'\n\n{caption}\n\n', 1)) + rows.append(row.replace('\n\n', f'\n\n{caption}\n\n{_table_shown(table)}', 1)) return rows +def _symbols(source: Path) -> Path | None: + """The sidecar symbol table for *source*, by the filename convention + ``tools/render_tex.py`` already uses.""" + table = ROOT / 'examples' / 'symbols' / f'{source.stem}.yaml' + return table if table.exists() else None + + +def _table_shown(table: Path | None) -> str: + """The symbol table, printed beside the math it renamed. + + A curve expands to weights named after the block that declared them, which + an equation naming one six times cannot carry. Renaming them in the + typesetter would be a symbol a reader could not trace back to the file, so + the rename is a **declaration** — the same ``--symbols`` sidecar any reader + may write — and the page shows it rather than performing it. + """ + if table is None: + return '' + body = re.sub(r'\A(?:#[^\n]*\n|\n)+', '', table.read_text()).strip() + return ( + f'Rendered with the sidecar symbol table `{table.relative_to(ROOT)}`, ' + f'which is what the weights print as:\n\n```yaml\n{body}\n```\n\n' + ) + + def _method(block: Declaration) -> str: """The block's ``method:``, or the default the language gives it.""" return block.field('method') or 'adjacency' From a0f11ed8bcb6300fe90db2e871c72f18b6ef1c4c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:01:35 +0000 Subject: [PATCH 2/5] feat: upright is what the model is given, italic is what the solver chooses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which of a linear model's symbols the solver picks is the distinction a reader cannot afford to guess at, and the page was leaving it to the legend. In Σ_{g : gen_bus(g) = b} p_{t,g} + spill_t - slack_t = load_{t,b} three of those four are chosen and one is data, and all four printed the same. `ParameterNode` and `VariableNode` reached the page through one call on one symbol dict; nothing between them. The nomenclature table is a real convention and stays — but it is a *lookup* rather than a reading, and an equation quoted on a slide does not take the legend with it. So the symbols carry it. This also completes a system the page was already three-quarters through: script for index sets, upright for the maps, qualifiers and labels a model is handed, italic for quantities. The cut this adds is *inside* italic. load_{t,b} → \mathrm{load}_{t,b} given spill_t → \mathit{spill}_{t} chosen, unchanged p_max → \mathrm{p}^{\mathrm{max}} No exception for single letters: `\mathrm{p}^{\mathrm{max}}` beside a variable `p` is exactly the pair a reader has to be able to tell apart, and carving it out would have left the rule with one. The legend states the convention once, in the model's own symbols, gated on the model having both — a note explaining a contrast the page does not draw is the dead end the translation notes avoid. Two things the distinction does not reach, both deliberate: - **Greek.** LaTeX sets lower-case Greek italic and has no upright form in the two-package preamble the render gate holds itself to, so a Greek-named parameter prints as the letter and the legend is what places it. - **The index-collision guard**, which shrank to exactly the collisions that are still collisions: a dimension may take `p` beside a parameter `p`, because `\mathrm{p}` and `p` are not the same symbol on the page. The sidecar tables follow the rule they now live under: `bp_x`/`bp_y` are data, so they print upright, while the curve weights they sit beside are variables and stay Greek — `λ_{p,m,b} ≤ δ_{p,m,b} + δ_{p,m,b ⊟₀ 1}` is one row that used to name a 25-character word three times. --- docs/examples/dispatch.md | 14 +++-- docs/examples/operators.md | 16 ++--- docs/reference/notation.md | 96 +++++++++++++++-------------- examples/symbols/piecewise.yaml | 4 +- examples/symbols/piecewise_lp.yaml | 4 +- examples/symbols/sos.yaml | 4 +- examples/symbols/transport_pwl.yaml | 4 +- src/math_spec/typeset/__init__.py | 1 + src/math_spec/typeset/symbols.py | 39 +++++++++--- src/math_spec/typeset/walk.py | 22 +++++++ tests/typeset/golden/latex.out | 62 ++++++++++--------- tests/typeset/golden/markdown.out | 62 ++++++++++--------- tests/typeset/golden/typst.out | 62 ++++++++++--------- tests/typeset/test_typeset.py | 64 ++++++++++++++++--- 14 files changed, 275 insertions(+), 179 deletions(-) diff --git a/docs/examples/dispatch.md b/docs/examples/dispatch.md index 997178d0..6c385ea3 100644 --- a/docs/examples/dispatch.md +++ b/docs/examples/dispatch.md @@ -58,9 +58,9 @@ Least-cost dispatch of a generator fleet against an hourly load. | Symbol | Meaning | |---|---| -| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | -| $\mathit{load}$ | `load` over $\mathcal{T}$ — demand to be met | -| $\mathit{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | +| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity | +| $\mathrm{load}$ | `load` over $\mathcal{T}$ — demand to be met | +| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ — marginal cost | #### Variables @@ -68,21 +68,23 @@ Least-cost dispatch of a generator fleet against an hourly load. |---|---| | $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot | +Upright is what the model is given — a parameter such as $\mathrm{p}^{\mathrm{max}}$, 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. + #### Objective -$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g}$$ +$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}$$ #### Subject to **`power_balance`** -$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathit{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathrm{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$ #### Variable domains **`p`** -$$0 \le p_{t,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace p^{\mathrm{max}}_{g} > 0$$ +$$0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \mathrm{p}^{\mathrm{max}}_{g} > 0$$ Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/operators.md b/docs/examples/operators.md index 1cfe8782..a0bdfb56 100644 --- a/docs/examples/operators.md +++ b/docs/examples/operators.md @@ -42,7 +42,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$ +$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathrm{budget}$ ### `sum(array, over=dim)` @@ -71,7 +71,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$\sum_{g \in \mathcal{G}} p_{t,g} \le \mathit{limit}_{t} \qquad \forall\thinspace t \in \mathcal{T}$ +$\sum_{g \in \mathcal{G}} p_{t,g} \le \mathrm{limit}_{t} \qquad \forall\thinspace t \in \mathcal{T}$ ### `sum(array, by=lookup)` @@ -107,7 +107,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathit{limit}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$ +$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathrm{limit}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$ ### `sum(array, by=[lookup, …])` @@ -145,7 +145,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathit{limit}_{t,b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$ +$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathrm{limit}_{t,b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$ ### `at(array, by=lookup)` @@ -179,7 +179,7 @@ constraints: objective: { sense: minimize, expression: sum(p) } ``` -$p_{t} \le \mathit{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ +$p_{t} \le \mathrm{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ ### `shift(array, over=dim, offset=n)` @@ -293,7 +293,7 @@ constraints: objective: { sense: minimize, expression: sum(order) } ``` -$\mathit{order}_{t,m \boxminus_{0} \mathit{lead}} \ge \mathit{demand}_{t,m} \qquad \forall\thinspace t \in \mathcal{T},\enspace m \in \mathcal{M}$ +$\mathit{order}_{t,m \boxminus_{0} \mathrm{lead}} \ge \mathrm{demand}_{t,m} \qquad \forall\thinspace t \in \mathcal{T},\enspace m \in \mathcal{M}$ ### `shift(array, over=dim, offset=n, by=lookup)` @@ -392,7 +392,7 @@ constraints: objective: { sense: minimize, expression: sum(on) } ``` -$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ ### `sum_back(array, over=dim, within=p, edge='wrap')` @@ -426,7 +426,7 @@ constraints: objective: { sense: minimize, expression: sum(on) } ``` -$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ +$\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 89cceee1..35358871 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -83,17 +83,17 @@ parameters: | Symbol | Meaning | |---|---| -| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ | -| $p^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ | -| $\mathit{cost}$ | `cost` over $\mathcal{G}$ | -| $\mathit{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | -| $\mathit{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | -| $\mathit{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | -| $\mathit{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | -| $\mathit{min\_up}$ | `min_up` over $\mathcal{G}$ | -| $\mathit{lead}$ | `lead` over $\mathcal{G}$ | -| $\mathit{budget}$ | `budget` (scalar) | -| $\mathit{growth}$ | `growth` (scalar) | +| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ | +| $\mathrm{p}^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ | +| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ | +| $\mathrm{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | +| $\mathrm{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | +| $\mathrm{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | +| $\mathrm{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | +| $\mathrm{min\_up}$ | `min_up` over $\mathcal{G}$ | +| $\mathrm{lead}$ | `lead` over $\mathcal{G}$ | +| $\mathrm{budget}$ | `budget` (scalar) | +| $\mathrm{growth}$ | `growth` (scalar) | #### Variables @@ -111,6 +111,8 @@ parameters: | $\mathit{void}$ | `void` over $\mathcal{B}$ | | $\mathit{weight}$ | `weight` over $\mathcal{T} \times \mathcal{G}$ | +Upright is what the model is given — a parameter such as $\mathrm{p}^{\mathrm{max}}$, 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 \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound — terms translated past the edge are simply absent. $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. @@ -128,7 +130,7 @@ sense: maximize expression: sum(p * cost) + sum(p * p * cost) + sum(p * cost * growth ** lead) + sum(p * p_max) - reserve + -headroom ``` -$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ +$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ ### Constraints @@ -142,7 +144,7 @@ balance: expression: sum(p, by=gen_bus) + spill - slack == load ``` -$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} = \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\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}$$ #### `ramp` @@ -154,7 +156,7 @@ ramp: expression: p - shift(p, over=snapshot, offset=1, edge='wrap') <= shift(p, over=snapshot, offset=1) + p_max ``` -$$p_{t,g} - p_{t \ominus 1,g} \le p_{t - 1,g} + p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} - p_{t \ominus 1,g} \le p_{t - 1,g} + \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `edges` @@ -168,7 +170,7 @@ edges: <= shift(p, over=snapshot, offset=-1, edge=0) + p_max ``` -$$p_{t \boxminus_{0} 1,g} \le p_{t \boxplus_{0} 1,g} + p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \boxminus_{0} 1,g} \le p_{t \boxplus_{0} 1,g} + \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `ahead` @@ -192,7 +194,7 @@ composed: expression: shift(shift(p, over=snapshot, offset=1), over=snapshot, offset=1) <= shift(p_max, over=generator, offset=0) ``` -$$p_{t - 2,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t - 2,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `uncomposed` @@ -204,7 +206,7 @@ uncomposed: expression: shift(shift(p, over=snapshot, offset=lead), over=snapshot, offset=1) <= p_max ``` -$$p_{\left( t - 1 \right) - \mathit{lead},g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{\left( t - 1 \right) - \mathrm{lead},g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `crossed` @@ -216,7 +218,7 @@ crossed: expression: shift(shift(p, over=snapshot, offset=1, edge='wrap'), over=generator, offset=-1) <= p_max ``` -$$p_{t \ominus 1,g + 1} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \ominus 1,g + 1} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `lead_time` @@ -228,7 +230,7 @@ lead_time: expression: shift(p, over=snapshot, offset=lead, edge=0) <= p_max ``` -$$p_{t \boxminus_{0} \mathit{lead},g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \boxminus_{0} \mathrm{lead},g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `in_season` @@ -276,7 +278,7 @@ history: expression: sum_back(on, over=snapshot, within=min_up, edge='wrap') <= units ``` -$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathit{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ #### `pullback` @@ -288,7 +290,7 @@ pullback: expression: spill <= at(zone_cap, by=zone_of) ``` -$$\mathit{spill}_{t} \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\mathit{spill}_{t} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ #### `grouped_twice` @@ -300,7 +302,7 @@ grouped_twice: expression: sum(p, by=[gen_bus, gen_tech]) <= tech_cap ``` -$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathit{tech\_cap}_{b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$$ +$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathrm{tech\_cap}_{b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$$ #### `pulled_back_twice` @@ -312,7 +314,7 @@ pulled_back_twice: expression: units <= at(tech_cap, by=[gen_bus, gen_tech]) ``` -$$\mathit{units}_{g} \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\thinspace g \in \mathcal{G}$$ +$$\mathit{units}_{g} \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\thinspace g \in \mathcal{G}$$ #### `arithmetic` @@ -327,7 +329,7 @@ arithmetic: expression: sum(p / 2 + -cost, over=generator) >= -sum(+p, over=generator) * 3 ``` -$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathrm{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ #### `total` @@ -339,7 +341,7 @@ total: expression: sum(p) <= budget ``` -$$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$$ +$$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathrm{budget}$$ #### `scalar` @@ -352,7 +354,7 @@ scalar: expression: units <= budget ``` -$$\mathit{units}_{g} \le \mathit{budget} \qquad \forall\thinspace g \in \mathcal{G} \thinspace:\thinspace \mathit{cost}_{g} \text{ is defined}$$ +$$\mathit{units}_{g} \le \mathrm{budget} \qquad \forall\thinspace g \in \mathcal{G} \thinspace:\thinspace \mathrm{cost}_{g} \text{ is defined}$$ #### `running` @@ -365,7 +367,7 @@ running: expression: theta <= load ``` -$$\theta_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ +$$\theta_{b} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ #### `first` @@ -391,7 +393,7 @@ northern: expression: slack <= load ``` -$$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ +$$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ #### `always` @@ -445,7 +447,7 @@ p: bounds: { lower: p_min, upper: p_max } ``` -$$p^{\mathrm{min}}_{g} \le p_{t,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( p^{\mathrm{max}}_{g} > 0 \wedge \neg \mathit{is\_flexible}_{g} \vee p^{\mathrm{min}}_{g} > 0 \right)$$ +$$\mathrm{p}^{\mathrm{min}}_{g} \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{p}^{\mathrm{max}}_{g} > 0 \wedge \neg \mathrm{is\_flexible}_{g} \vee \mathrm{p}^{\mathrm{min}}_{g} > 0 \right)$$ #### `spill` @@ -542,7 +544,7 @@ headroom: bounds: { lower: 0 } ``` -$$\mathit{headroom} \ge 0 \qquad \text{where } \mathit{budget} \text{ is defined}$$ +$$\mathit{headroom} \ge 0 \qquad \text{where } \mathrm{budget} \text{ is defined}$$ #### `void` @@ -584,8 +586,8 @@ notation: latex names: economies_of_scale_lam: "\\lambda" economies_of_scale_seg: "\\delta" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" ``` ```yaml @@ -598,9 +600,9 @@ economies_of_scale: $$\sum_{b \in \mathcal{B}} \lambda_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot x_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\mathit{shipment}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{x}_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ -$$\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot y_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ +$$\mathit{scaled}_{p,m} = \sum_{b \in \mathcal{B}} \lambda_{p,m,b} \cdot \mathrm{y}_{b} \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ $$\sum_{b \in \mathcal{B}} \delta_{p,m,b} = 1 \qquad \forall\thinspace p \in \mathcal{P},\enspace m \in \mathcal{M}$$ @@ -621,8 +623,8 @@ notation: latex names: cost_curve_lam: "\\lambda" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" ``` ```yaml @@ -636,9 +638,9 @@ cost_curve: $$\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot y_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ $$0 \le \lambda_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ @@ -655,8 +657,8 @@ notation: latex names: cost_curve_lam: "\\lambda" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" ``` ```yaml @@ -670,9 +672,9 @@ cost_curve: $$\sum_{b \in \mathcal{B}} \lambda_{t,g,b} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ -$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot y_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\mathit{op\_cost}_{t,g} = \sum_{b \in \mathcal{B}} \lambda_{t,g,b} \cdot \mathrm{y}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ $$0 \le \lambda_{t,g,b} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B}$$ @@ -686,8 +688,8 @@ Rendered with the sidecar symbol table `examples/symbols/piecewise_lp.yaml`, whi notation: latex names: - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" ``` ```yaml @@ -699,11 +701,11 @@ cost_curve: method: lp ``` -$$\mathit{op\_cost}_{t,g} \cdot \left( x_{g,b} - x_{g,b \boxminus_{0} 1} \right) \ge \left( y_{g,b} - y_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - x_{g,b} \right) + y_{g,b} \cdot \left( x_{g,b} - x_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ +$$\mathit{op\_cost}_{t,g} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \ge \left( \mathrm{y}_{g,b} - \mathrm{y}_{g,b \boxminus_{0} 1} \right) \cdot \left( p_{t,g} - \mathrm{x}_{g,b} \right) + \mathrm{y}_{g,b} \cdot \left( \mathrm{x}_{g,b} - \mathrm{x}_{g,b \boxminus_{0} 1} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b \neq \mathrm{index}(\mathcal{B}, 0)$$ -$$p_{t,g} \ge x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ +$$p_{t,g} \ge \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, 0)$$ -$$p_{t,g} \le x_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ +$$p_{t,g} \le \mathrm{x}_{g,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G},\enspace b \in \mathcal{B} \thinspace:\thinspace b = \mathrm{index}(\mathcal{B}, -1)$$ ### Sets carried to the solver diff --git a/examples/symbols/piecewise.yaml b/examples/symbols/piecewise.yaml index 4eedf972..de0cfcce 100644 --- a/examples/symbols/piecewise.yaml +++ b/examples/symbols/piecewise.yaml @@ -11,5 +11,5 @@ notation: latex names: cost_curve_lam: "\\lambda" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/examples/symbols/piecewise_lp.yaml b/examples/symbols/piecewise_lp.yaml index 4ce464ea..4a1362b5 100644 --- a/examples/symbols/piecewise_lp.yaml +++ b/examples/symbols/piecewise_lp.yaml @@ -5,5 +5,5 @@ notation: latex names: - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/examples/symbols/sos.yaml b/examples/symbols/sos.yaml index 5cc12e90..4bf58603 100644 --- a/examples/symbols/sos.yaml +++ b/examples/symbols/sos.yaml @@ -11,5 +11,5 @@ notation: latex names: cost_curve_lam: "\\lambda" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/examples/symbols/transport_pwl.yaml b/examples/symbols/transport_pwl.yaml index 32b536ad..c420239c 100644 --- a/examples/symbols/transport_pwl.yaml +++ b/examples/symbols/transport_pwl.yaml @@ -12,5 +12,5 @@ notation: latex names: economies_of_scale_lam: "\\lambda" economies_of_scale_seg: "\\delta" - bp_x: x - bp_y: y + bp_x: "\\mathrm{x}" + bp_y: "\\mathrm{y}" diff --git a/src/math_spec/typeset/__init__.py b/src/math_spec/typeset/__init__.py index c756a5fb..bff51c9e 100644 --- a/src/math_spec/typeset/__init__.py +++ b/src/math_spec/typeset/__init__.py @@ -112,6 +112,7 @@ def typeset( blocks = [fmt.note(fmt.escape(schema.description))] if schema.description else [] if legend: blocks += [fmt.glossary(group.title, group.entries) for group in walk.glossaries()] + blocks += [fmt.note(text) for text in walk.convention_notes()] blocks += [fmt.note(text) for text in walk.translation_notes()] return fmt.document([*blocks, *rendered], standalone=standalone) diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 408f1f48..7359ee3b 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -53,19 +53,31 @@ ) # fmt: skip -def _word(name: str, fmt: Format) -> str: - """One name as one symbol: a letter stays a letter, a word is set italic. - - A name that *is* a Greek letter is set as the letter, which is what the - author meant by writing it out — the alphabet a model has, not one it is - kept out of. +def _word(name: str, fmt: Format, *, given: bool) -> str: + """One name as one symbol, upright where it is *given*. + + **Upright is what the data supplies; italic is what the solver chooses.** + Which of the two a symbol is, is the distinction a linear model cannot + afford to leave to a legend — quote one equation on a slide and a reader + has to know which side of it the solver is on — and it completes a system + the page was already three-quarters of the way through: script for index + sets, upright for the maps and qualifiers a model is handed, italic for + quantities. The cut this adds is *inside* italic. + + A name that *is* a Greek letter is set as the letter either way, which is + what the author meant by writing it out. The distinction does not reach + those: LaTeX sets lower-case Greek italic and has no upright form in the + two-package preamble the render gate holds itself to, so a Greek-named + parameter is one the legend has to place. """ if name in _GREEK: return fmt.greek(name) + if given: + return fmt.upright(name) return name if len(name) == 1 else fmt.italic(name) -def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format) -> str: +def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format, *, given: bool = False) -> str: r"""``p`` → ``p``; ``load`` → ``\mathit{load}``; ``p_max`` → ``p^{\mathrm{max}}``. An underscore is a **qualifier** only when what precedes it is a symbol in @@ -85,8 +97,8 @@ def _derive_name_symbol(name: str, declared: frozenset[str], fmt: Format) -> str """ head, _, tail = name.partition('_') if tail and (len(head) == 1 or head in _GREEK or head in declared): - return fmt.superscript(_word(head, fmt), fmt.upright(tail.replace('_', ','))) - return _word(name, fmt) + return fmt.superscript(_word(head, fmt, given=given), fmt.upright(tail.replace('_', ','))) + return _word(name, fmt, given=given) class Symbols: @@ -99,6 +111,11 @@ class Symbols: single-letter name symbols are kept off the index letters, a ``\mathit{load}`` never colliding with a ``t``. + Which now means **variables**, since a parameter is upright: a dimension + may take ``p`` beside a parameter ``p``, because ``\mathrm{p}`` and ``p`` + are not the same symbol on the page. The guard shrank to exactly the + collisions that are still collisions. + Raises: SchemaError: If *table* is written in a notation *fmt* does not read. """ @@ -115,7 +132,9 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: declared = frozenset({*schema.parameters, *schema.variables}) self.name: dict[str, str] = { - name: table.names[name] if name in table.names else _derive_name_symbol(name, declared, fmt) + 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) } spoken_for = {s for s in self.name.values() if len(s) == 1} diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 2cd7991e..834af765 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -723,6 +723,28 @@ def _coords(self, dim: str) -> str: clauses.append(f' carrying label{plural} {self.format.math(named)}') return ''.join(clauses) + def convention_notes(self) -> list[str]: + """What the two faces mean, said once, with the model's own symbols. + + A nomenclature table already splits the legend into parameters and + variables, and that is the convention a paper in this field uses. It is + a *lookup* rather than a reading, though: quote one equation on a slide + and the distinction is gone. So the symbols carry it, and this is where + the page says which face is which. + + Only where the model has both, since a note explaining a contrast the + page does not draw is the dead end the translation notes avoid. + """ + if not (self.schema.parameters and self.schema.variables): + return [] + given = self.format.math(self.symbols.name[next(iter(self.schema.parameters))]) + chosen = self.format.math(self.symbols.name[next(iter(self.schema.variables))]) + return [ + f'Upright is what the model is given {self.format.dash} a parameter such as {given}, a coordinate ' + f'map, a label {self.format.dash} and italic is what the solver chooses, such as {chosen}. ' + f'An index is italic too, being what a quantifier chooses, and a set is script.' + ] + def translation_notes(self) -> list[str]: """A sentence for each translation symbol the model actually printed. diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index 5f665f77..1f5f060d 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -19,17 +19,17 @@ \paragraph{Parameters} \begin{description} -\item[$p^{\mathrm{max}}$] \texttt{p\_max} over $\mathcal{G}$ -\item[$p^{\mathrm{min}}$] \texttt{p\_min} over $\mathcal{G}$ -\item[$\mathit{cost}$] \texttt{cost} over $\mathcal{G}$ -\item[$\mathit{load}$] \texttt{load} over $\mathcal{T} \times \mathcal{B}$ -\item[$\mathit{is\_flexible}$] \texttt{is\_flexible} over $\mathcal{G}$ -\item[$\mathit{zone\_cap}$] \texttt{zone\_cap} over $\mathcal{Z}$ -\item[$\mathit{tech\_cap}$] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ -\item[$\mathit{min\_up}$] \texttt{min\_up} over $\mathcal{G}$ -\item[$\mathit{lead}$] \texttt{lead} over $\mathcal{G}$ -\item[$\mathit{budget}$] \texttt{budget} (scalar) -\item[$\mathit{growth}$] \texttt{growth} (scalar) +\item[$\mathrm{p}^{\mathrm{max}}$] \texttt{p\_max} over $\mathcal{G}$ +\item[$\mathrm{p}^{\mathrm{min}}$] \texttt{p\_min} over $\mathcal{G}$ +\item[$\mathrm{cost}$] \texttt{cost} over $\mathcal{G}$ +\item[$\mathrm{load}$] \texttt{load} over $\mathcal{T} \times \mathcal{B}$ +\item[$\mathrm{is\_flexible}$] \texttt{is\_flexible} over $\mathcal{G}$ +\item[$\mathrm{zone\_cap}$] \texttt{zone\_cap} over $\mathcal{Z}$ +\item[$\mathrm{tech\_cap}$] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ +\item[$\mathrm{min\_up}$] \texttt{min\_up} over $\mathcal{G}$ +\item[$\mathrm{lead}$] \texttt{lead} over $\mathcal{G}$ +\item[$\mathrm{budget}$] \texttt{budget} (scalar) +\item[$\mathrm{growth}$] \texttt{growth} (scalar) \end{description} \paragraph{Variables} @@ -47,6 +47,8 @@ \item[$\mathit{weight}$] \texttt{weight} over $\mathcal{T} \times \mathcal{G}$ \end{description} +\noindent Upright is what the model is given --- a parameter such as $\mathrm{p}^{\mathrm{max}}$, 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. + \noindent $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (\texttt{roll}). Plain $t-k$ (\texttt{shift}) has no wraparound --- terms translated past the edge are simply absent. \noindent $t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (\texttt{shift(edge=v)}), so the row at that boundary is built and carries $v$ rather than being dropped. @@ -55,32 +57,32 @@ \paragraph{Objective} \begin{align} - && \max & \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom} + && \max & \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom} \end{align} \paragraph{Subject to} \begin{align} -\text{balance} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} & = \mathit{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} + 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} + p^{\mathrm{max}}_{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} \\ \text{ahead} && p_{t,g} & \le p_{t \oplus 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{composed} && p_{t - 2,g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{uncomposed} && p_{\left( t - 1 \right) - \mathit{lead},g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{crossed} && p_{t \ominus 1,g + 1} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{lead\_time} && p_{t \boxminus_{0} \mathit{lead},g} & \le p^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{composed} && p_{t - 2,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{uncomposed} && p_{\left( t - 1 \right) - \mathrm{lead},g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{crossed} && p_{t \ominus 1,g + 1} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{lead\_time} && p_{t \boxminus_{0} \mathrm{lead},g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{in\_season} && p_{t,g} & \le p_{t \ominus^{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{held\_in\_season} && p_{t,g} & \le p_{t \boxminus_{0}^{\mathrm{season\_of}(t)} 1,g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{window} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t - t' < 3} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{history} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t \ominus t' < \mathit{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ -\text{pullback} && \mathit{spill}_{t} & \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ -\text{grouped\_twice} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} & \le \mathit{tech\_cap}_{b,e} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B},\ e \in \mathcal{E} \\ -\text{pulled\_back\_twice} && \mathit{units}_{g} & \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} && \forall\, g \in \mathcal{G} \\ -\text{arithmetic} && \sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) & \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 && \forall\, t \in \mathcal{T} \\ -\text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathit{budget} \\ -\text{scalar} && \mathit{units}_{g} & \le \mathit{budget} && \forall\, g \in \mathcal{G} \,:\, \mathit{cost}_{g} \text{ is defined} \\ -\text{running} && \theta_{b} & \le \mathit{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ +\text{history} && \sum_{t' \in \mathcal{T} \,:\, 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} & \le \mathit{units}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ +\text{pullback} && \mathit{spill}_{t} & \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \\ +\text{grouped\_twice} && \sum_{g \in \mathcal{G} \,:\, \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} & \le \mathrm{tech\_cap}_{b,e} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B},\ e \in \mathcal{E} \\ +\text{pulled\_back\_twice} && \mathit{units}_{g} & \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} && \forall\, g \in \mathcal{G} \\ +\text{arithmetic} && \sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathrm{cost}_{g} \right) & \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 && \forall\, t \in \mathcal{T} \\ +\text{total} && \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} & \le \mathrm{budget} \\ +\text{scalar} && \mathit{units}_{g} & \le \mathrm{budget} && \forall\, g \in \mathcal{G} \,:\, \mathrm{cost}_{g} \text{ is defined} \\ +\text{running} && \theta_{b} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ \text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \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{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \wedge \mathit{spill}_{t} \text{ exists} \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot @@ -88,7 +90,7 @@ \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) \\ +\text{p} && \mathrm{p}^{\mathrm{min}}_{g} \le p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( \mathrm{p}^{\mathrm{max}}_{g} > 0 \wedge \neg \mathrm{is\_flexible}_{g} \vee \mathrm{p}^{\mathrm{min}}_{g} > 0 \right) \\ \text{spill} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{slack} && \mathit{slack}_{t} & \le 100 && \forall\, t \in \mathcal{T} \\ \text{theta} && \theta_{b} & \in \mathbb{R} && \forall\, b \in \mathcal{B} \\ @@ -96,7 +98,7 @@ \text{units} && 0 \le \mathit{units}_{g} & \le 10, \mathit{units}_{g} \in \mathbb{Z} && \forall\, g \in \mathcal{G} \\ \text{spare} && \mathit{spare}_{g} & \in \mathbb{Z} && \forall\, g \in \mathcal{G} \\ \text{reserve} && \mathit{reserve} & \ge 0 \\ -\text{headroom} && \mathit{headroom} & \ge 0 && \text{where } \mathit{budget} \text{ is defined} \\ +\text{headroom} && \mathit{headroom} & \ge 0 && \text{where } \mathrm{budget} \text{ is defined} \\ \text{void} && \infty \le \mathit{void}_{b} & \le -\infty && \forall\, b \in \mathcal{B} \\ \text{weight} && 0 \le \mathit{weight}_{t,g} & \le 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{weight sos} && \left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} & \in \mathrm{SOS}2 && \forall\, t \in \mathcal{T} diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index a541781c..35af2976 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -17,17 +17,17 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | Symbol | Meaning | |---|---| -| $p^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ | -| $p^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ | -| $\mathit{cost}$ | `cost` over $\mathcal{G}$ | -| $\mathit{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | -| $\mathit{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | -| $\mathit{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | -| $\mathit{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | -| $\mathit{min\_up}$ | `min_up` over $\mathcal{G}$ | -| $\mathit{lead}$ | `lead` over $\mathcal{G}$ | -| $\mathit{budget}$ | `budget` (scalar) | -| $\mathit{growth}$ | `growth` (scalar) | +| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ | +| $\mathrm{p}^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ | +| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ | +| $\mathrm{load}$ | `load` over $\mathcal{T} \times \mathcal{B}$ | +| $\mathrm{is\_flexible}$ | `is_flexible` over $\mathcal{G}$ | +| $\mathrm{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | +| $\mathrm{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | +| $\mathrm{min\_up}$ | `min_up` over $\mathcal{G}$ | +| $\mathrm{lead}$ | `lead` over $\mathcal{G}$ | +| $\mathrm{budget}$ | `budget` (scalar) | +| $\mathrm{growth}$ | `growth` (scalar) | #### Variables @@ -45,6 +45,8 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | $\mathit{void}$ | `void` over $\mathcal{B}$ | | $\mathit{weight}$ | `weight` over $\mathcal{T} \times \mathcal{G}$ | +Upright is what the model is given — a parameter such as $\mathrm{p}^{\mathrm{max}}$, 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 \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound — terms translated past the edge are simply absent. $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. @@ -53,21 +55,21 @@ $t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the grou #### Objective -$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathit{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g} \cdot \mathit{growth}^{\mathit{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ +$$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot p_{t,g} \cdot \mathrm{cost}_{g} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g} \cdot \mathrm{growth}^{\mathrm{lead}_{g}} + \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} - \mathit{reserve} - \mathit{headroom}$$ #### Subject to **`balance`** -$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} + \mathit{spill}_{t} - \mathit{slack}_{t} = \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\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}$$ **`ramp`** -$$p_{t,g} - p_{t \ominus 1,g} \le p_{t - 1,g} + p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t,g} - p_{t \ominus 1,g} \le p_{t - 1,g} + \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`edges`** -$$p_{t \boxminus_{0} 1,g} \le p_{t \boxplus_{0} 1,g} + p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \boxminus_{0} 1,g} \le p_{t \boxplus_{0} 1,g} + \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`ahead`** @@ -75,19 +77,19 @@ $$p_{t,g} \le p_{t \oplus 1,g} \qquad \forall\thinspace t \in \mathcal{T},\enspa **`composed`** -$$p_{t - 2,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t - 2,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`uncomposed`** -$$p_{\left( t - 1 \right) - \mathit{lead},g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{\left( t - 1 \right) - \mathrm{lead},g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`crossed`** -$$p_{t \ominus 1,g + 1} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \ominus 1,g + 1} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`lead_time`** -$$p_{t \boxminus_{0} \mathit{lead},g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$p_{t \boxminus_{0} \mathrm{lead},g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`in_season`** @@ -103,35 +105,35 @@ $$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t - t' < 3} \mathit{on}_{ **`history`** -$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathit{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ +$$\sum_{t' \in \mathcal{T} \thinspace:\thinspace 0 \le t \ominus t' < \mathrm{min\_up}} \mathit{on}_{t',g} \le \mathit{units}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ **`pullback`** -$$\mathit{spill}_{t} \le \mathit{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ +$$\mathit{spill}_{t} \le \mathrm{zone\_cap}_{\mathrm{zone\_of}(b)} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$$ **`grouped_twice`** -$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathit{tech\_cap}_{b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$$ +$$\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathrm{tech\_cap}_{b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$$ **`pulled_back_twice`** -$$\mathit{units}_{g} \le \mathit{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\thinspace g \in \mathcal{G}$$ +$$\mathit{units}_{g} \le \mathrm{tech\_cap}_{\mathrm{gen\_bus}(g),\mathrm{gen\_tech}(g)} \qquad \forall\thinspace g \in \mathcal{G}$$ **`arithmetic`** -$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathit{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ +$$\sum_{g \in \mathcal{G}} \left( \frac{p_{t,g}}{2} - \mathrm{cost}_{g} \right) \ge -\left( \sum_{g \in \mathcal{G}} p_{t,g} \right) \cdot 3 \qquad \forall\thinspace t \in \mathcal{T}$$ **`total`** -$$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$$ +$$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathrm{budget}$$ **`scalar`** -$$\mathit{units}_{g} \le \mathit{budget} \qquad \forall\thinspace g \in \mathcal{G} \thinspace:\thinspace \mathit{cost}_{g} \text{ is defined}$$ +$$\mathit{units}_{g} \le \mathrm{budget} \qquad \forall\thinspace g \in \mathcal{G} \thinspace:\thinspace \mathrm{cost}_{g} \text{ is defined}$$ **`running`** -$$\theta_{b} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ +$$\theta_{b} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \theta_{b} \text{ exists} \wedge t \ge 3$$ **`first`** @@ -139,7 +141,7 @@ $$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \i **`northern`** -$$\mathit{slack}_{t} \le \mathit{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ +$$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ **`always`** @@ -157,7 +159,7 @@ $$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace **`p`** -$$p^{\mathrm{min}}_{g} \le p_{t,g} \le p^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( p^{\mathrm{max}}_{g} > 0 \wedge \neg \mathit{is\_flexible}_{g} \vee p^{\mathrm{min}}_{g} > 0 \right)$$ +$$\mathrm{p}^{\mathrm{min}}_{g} \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G} \thinspace:\thinspace \left( \mathrm{p}^{\mathrm{max}}_{g} > 0 \wedge \neg \mathrm{is\_flexible}_{g} \vee \mathrm{p}^{\mathrm{min}}_{g} > 0 \right)$$ **`spill`** @@ -189,7 +191,7 @@ $$\mathit{reserve} \ge 0$$ **`headroom`** -$$\mathit{headroom} \ge 0 \qquad \text{where } \mathit{budget} \text{ is defined}$$ +$$\mathit{headroom} \ge 0 \qquad \text{where } \mathrm{budget} \text{ is defined}$$ **`void`** diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index ade7a439..247cf490 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -12,17 +12,17 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $cal(E)$: index $e$ --- `technology` == Parameters -/ $p^(upright("max"))$: `p_max` over $cal(G)$ -/ $p^(upright("min"))$: `p_min` over $cal(G)$ -/ $italic("cost")$: `cost` over $cal(G)$ -/ $italic("load")$: `load` over $cal(T) times cal(B)$ -/ $italic("is_flexible")$: `is_flexible` over $cal(G)$ -/ $italic("zone_cap")$: `zone_cap` over $cal(Z)$ -/ $italic("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ -/ $italic("min_up")$: `min_up` over $cal(G)$ -/ $italic("lead")$: `lead` over $cal(G)$ -/ $italic("budget")$: `budget` (scalar) -/ $italic("growth")$: `growth` (scalar) +/ $upright("p")^(upright("max"))$: `p_max` over $cal(G)$ +/ $upright("p")^(upright("min"))$: `p_min` over $cal(G)$ +/ $upright("cost")$: `cost` over $cal(G)$ +/ $upright("load")$: `load` over $cal(T) times cal(B)$ +/ $upright("is_flexible")$: `is_flexible` over $cal(G)$ +/ $upright("zone_cap")$: `zone_cap` over $cal(Z)$ +/ $upright("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ +/ $upright("min_up")$: `min_up` over $cal(G)$ +/ $upright("lead")$: `lead` over $cal(G)$ +/ $upright("budget")$: `budget` (scalar) +/ $upright("growth")$: `growth` (scalar) == Variables / $p$: `p` over $cal(T) times cal(G)$ @@ -37,6 +37,8 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $italic("void")$: `void` over $cal(B)$ / $italic("weight")$: `weight` over $cal(T) times cal(G)$ +Upright is what the model is given --- a parameter such as $upright("p")^(upright("max"))$, 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 minus.o k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound --- terms translated past the edge are simply absent. $t minus.square_(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. @@ -45,38 +47,38 @@ $t minus.o^(upright("lookup")(t)) k$ denotes a translation counted inside the gr == Objective #set math.equation(numbering: "(1)") -$ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot italic("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot italic("cost")_(g) dot italic("growth")^(italic("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p^(upright("max"))_(g) - italic("reserve") - italic("headroom") $ +$ & max & sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot p_(t,g) dot upright("cost")_(g) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("cost")_(g) dot upright("growth")^(upright("lead")_(g)) + sum_(t in cal(T), g in cal(G)) p_(t,g) dot upright("p")^(upright("max"))_(g) - italic("reserve") - italic("headroom") $ == 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) & = italic("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) + 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) + p^(upright("max"))_(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) \ - upright("composed") & p_(t - 2,g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ - upright("uncomposed") & p_((t - 1) - italic("lead"),g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ - upright("crossed") & p_(t minus.o 1,g + 1) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ - upright("lead_time") & p_(t minus.square_(0) italic("lead"),g) & <= p^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ + upright("composed") & p_(t - 2,g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ + upright("uncomposed") & p_((t - 1) - upright("lead"),g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ + upright("crossed") & p_(t minus.o 1,g + 1) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ + upright("lead_time") & p_(t minus.square_(0) upright("lead"),g) & <= upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("in_season") & p_(t,g) & <= p_(t minus.o^(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ upright("held_in_season") & p_(t,g) & <= p_(t minus.square_(0)^(upright("season_of")(t)) 1,g) & forall t in cal(T), g in cal(G) \ upright("window") & sum_(t' in cal(T) colon 0 <= t - t' < 3) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ - upright("history") & sum_(t' in cal(T) colon 0 <= t minus.o t' < italic("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ - upright("pullback") & italic("spill")_(t) & <= italic("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ - upright("grouped_twice") & sum_(g in cal(G) colon upright("gen_bus")(g) = b and upright("gen_tech")(g) = e) p_(t,g) & <= italic("tech_cap")_(b,e) & forall t in cal(T), b in cal(B), e in cal(E) \ - upright("pulled_back_twice") & italic("units")_(g) & <= italic("tech_cap")_(upright("gen_bus")(g),upright("gen_tech")(g)) & forall g in cal(G) \ - upright("arithmetic") & sum_(g in cal(G)) (frac(p_(t,g), 2) - italic("cost")_(g)) & >= -(sum_(g in cal(G)) p_(t,g)) dot 3 & forall t in cal(T) \ - upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= italic("budget") \ - upright("scalar") & italic("units")_(g) & <= italic("budget") & forall g in cal(G) colon italic("cost")_(g) upright(" is defined") \ - upright("running") & theta_(b) & <= italic("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ + upright("history") & sum_(t' in cal(T) colon 0 <= t minus.o t' < upright("min_up")) italic("on")_(t',g) & <= italic("units")_(g) & forall t in cal(T), g in cal(G) \ + upright("pullback") & italic("spill")_(t) & <= upright("zone_cap")_(upright("zone_of")(b)) & forall t in cal(T), b in cal(B) \ + upright("grouped_twice") & sum_(g in cal(G) colon upright("gen_bus")(g) = b and upright("gen_tech")(g) = e) p_(t,g) & <= upright("tech_cap")_(b,e) & forall t in cal(T), b in cal(B), e in cal(E) \ + upright("pulled_back_twice") & italic("units")_(g) & <= upright("tech_cap")_(upright("gen_bus")(g),upright("gen_tech")(g)) & forall g in cal(G) \ + upright("arithmetic") & sum_(g in cal(G)) (frac(p_(t,g), 2) - upright("cost")_(g)) & >= -(sum_(g in cal(G)) p_(t,g)) dot 3 & forall t in cal(T) \ + upright("total") & sum_(t in cal(T), g in cal(G)) p_(t,g) & <= upright("budget") \ + upright("scalar") & italic("units")_(g) & <= upright("budget") & forall g in cal(G) colon upright("cost")_(g) upright(" is defined") \ + upright("running") & theta_(b) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (t = upright("index")(cal(T), 0) or t = upright("index")(cal(T), 0, upright("season_of")(t))) \ - 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("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top and italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ == 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) \ +$ 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) \ upright("spill") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("slack") & italic("slack")_(t) & <= 100 & forall t in cal(T) \ upright("theta") & theta_(b) & in RR & forall b in cal(B) \ @@ -84,7 +86,7 @@ $ upright("p") & p^(upright("min"))_(g) <= p_(t,g) & <= p^(upright("max"))_(g) & upright("units") & 0 <= italic("units")_(g) & <= 10, italic("units")_(g) in ZZ & forall g in cal(G) \ upright("spare") & italic("spare")_(g) & in ZZ & forall g in cal(G) \ upright("reserve") & italic("reserve") & >= 0 \ - upright("headroom") & italic("headroom") & >= 0 & upright("where ") italic("budget") upright(" is defined") \ + upright("headroom") & italic("headroom") & >= 0 & upright("where ") upright("budget") upright(" is defined") \ upright("void") & infinity <= italic("void")_(b) & <= -infinity & forall b in cal(B) \ upright("weight") & 0 <= italic("weight")_(t,g) & <= 1 & forall t in cal(T), g in cal(G) \ upright("weight sos") & (italic("weight")_(t,g))_(g in cal(G)) & in upright("SOS")2 & forall t in cal(T) $ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index d764ce4f..e581f4b8 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -167,7 +167,7 @@ def test_a_negated_boolean_mask_negates_the_predicate_alone(fmt: Format): grouping to the one the model builds. """ text = typeset(_masked('bool'), fmt, legend=False) - negated = f'{fmt.operators["not"]} {fmt.subscript(fmt.italic("flag"), ["g"])}' + negated = f'{fmt.operators["not"]} {fmt.subscript(fmt.upright("flag"), ["g"])}' assert negated in text, 'the negation has to land on the predicate itself' assert f'{negated} {fmt.prose(" is defined")}' not in text, ( 'the negation must not scope over prose it cannot bracket' @@ -437,6 +437,22 @@ def test_an_underscore_is_only_a_qualifier_when_its_head_is_a_symbol(name: str, assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX) == expected +@pytest.mark.parametrize( + ('name', 'expected'), + [ + pytest.param('cost', r'\mathrm{cost}', id='a-word'), + pytest.param('p', r'\mathrm{p}', id='and-a-single-letter-too'), + pytest.param('p_max', r'\mathrm{p}^{\mathrm{max}}', id='the-head-of-a-qualifier-with-it'), + pytest.param('theta', r'\theta', id='but-not-a-greek-letter-latex-has-no-upright-form-for'), + ], +) +def test_a_given_quantity_is_upright(name: str, expected: str): + """Upright is what the data supplies, and it reaches the single letters: + `p^max` beside a variable `p` is exactly the pair a reader has to be able + to tell apart.""" + assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, given=True) == expected + + @EVERY_FORMAT def test_a_name_that_is_a_greek_letter_prints_as_the_letter(fmt: Format): """A variable called `theta` set as the italic word *theta* is the one @@ -445,6 +461,34 @@ def test_a_name_that_is_a_greek_letter_prints_as_the_letter(fmt: Format): assert fmt.greek('theta') in typeset(model, fmt) +@EVERY_FORMAT +def test_a_parameter_is_upright_and_a_variable_is_italic(fmt: Format): + """The one distinction a reader of a linear model cannot afford to guess. + + A nomenclature table already splits the legend, and that is the convention + the field uses — but it is a lookup rather than a reading, and an equation + quoted on its own takes the legend with it. So the symbols carry it: + upright is what the model is given, italic is what the solver chooses. + """ + text = typeset(DISPATCH, fmt, legend=False) + assert fmt.subscript(fmt.upright('load'), ['t']) in text, 'a parameter is given, so it is upright' + assert fmt.subscript(fmt.italic('load'), ['t']) not in text + assert fmt.subscript('p', ['t', 'g']) in text, 'a variable is chosen, so it stays italic' + + +@EVERY_FORMAT +def test_the_legend_states_the_convention_only_where_it_draws_it(fmt: Format): + """A note explaining a contrast the page does not draw is a dead end, the + same reason the translation notes are gated on their symbol printing.""" + assert 'Upright is what the model is given' in typeset(DISPATCH, fmt) + parameterless = { + 'dimensions': {'snapshot': {'dtype': 'int'}}, + 'variables': {'p': {'foreach': ['snapshot'], 'bounds': {'lower': 0}}}, + 'objective': {'sense': 'minimize', 'expression': 'sum(p)'}, + } + assert 'Upright is what the model is given' not in typeset(parameterless, fmt) + + @EVERY_FORMAT def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(fmt: Format): """`zone_cap` is a capacity *indexed by* zone, not a zone qualified by cap. @@ -458,8 +502,8 @@ def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(fmt: Format): **{'dimensions.zone': {'dtype': 'str'}, 'parameters.zone_cap': {'dims': ['zone']}}, ) text = typeset(model, fmt) - assert fmt.italic('zone_cap') in text - assert fmt.superscript(fmt.italic('zone'), fmt.upright('cap')) not in text + assert fmt.upright('zone_cap') in text + assert fmt.superscript(fmt.upright('zone'), fmt.upright('cap')) not in text @EVERY_FORMAT @@ -504,17 +548,17 @@ def test_a_subtracted_summation_keeps_the_sign_outside_it(fmt: Format): 'fragment', [ pytest.param('p_{t,g}', id='symbols-follow-the-names-variable'), - pytest.param(r'\mathit{load}_{t}', id='symbols-follow-the-names-parameter'), - pytest.param(r'p^{\mathrm{max}}_{g}', id='symbols-follow-the-names-qualifier'), + pytest.param(r'\mathrm{load}_{t}', id='symbols-follow-the-names-parameter'), + pytest.param(r'\mathrm{p}^{\mathrm{max}}_{g}', id='symbols-follow-the-names-qualifier'), pytest.param( - r'\sum_{g \in \mathcal{G}} p_{t,g} & = \mathit{load}_{t}', + r'\sum_{g \in \mathcal{G}} p_{t,g} & = \mathrm{load}_{t}', id='sum-binds-the-dimension-it-reduces', ), pytest.param( - r'\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g}', + r'\sum_{t \in \mathcal{T},\ g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}', id='a-sum-naming-no-dim-puts-them-all-in-its-domain', ), - pytest.param(r'0 \le p_{t,g} & \le p^{\mathrm{max}}_{g}', id='bounds-become-a-domain-line'), + pytest.param(r'0 \le p_{t,g} & \le \mathrm{p}^{\mathrm{max}}_{g}', id='bounds-become-a-domain-line'), pytest.param(r'\text{power\_balance}', id='names-are-escaped-in-text-mode'), ], ) @@ -600,7 +644,7 @@ def test_typst_uses_its_own_grouping_and_set_notation(): typ = to_typst(DISPATCH, legend=False) assert 'p_(t,g)' in typ assert 'sum_(g in cal(G))' in typ - assert 'italic("load")_(t)' in typ + assert 'upright("load")_(t)' in typ def test_typst_sum_renders_the_coordinate_map(): @@ -958,7 +1002,7 @@ def test_the_table_overrides_and_the_rest_is_still_derived(): tex = to_latex(WITH_MARGINAL_COST, symbols=SYMBOLS, legend=False) assert r'\pi_{t,u}' in tex, 'both the symbol and its subscripts were overridden' assert r'c^{\mathrm{marg}}_{u}' in tex - assert r'\mathit{load}_{t}' in tex, 'untouched, so still derived' + assert r'\mathrm{load}_{t}' in tex, 'untouched, so still derived' assert r'u \in \mathcal{U}' in tex From 4cd223d8db18e37b93c89cf48a35bfbe688be7e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:08:30 +0000 Subject: [PATCH 3/5] fix: the convention admits no exception, Greek included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `upgreek` is what LaTeX needs for an upright lower-case Greek letter, and taking it costs two things this repository holds on purpose: - **A two-package preamble.** `latex.py` loads amsmath and amssymb, and the CI render gate exists partly to assert the preamble stays installable from a small TeX. - **Markdown that renders the same in both places.** `docs/static/mathjax.js` says it outright — "a block pasted out of `to_markdown` should render the same here as it does on GitHub" — and GitHub's MathJax takes no configuration, so an extension the docs site can load is one GitHub cannot. `\uptheta` would render there as an error. So the letter yields to the rule rather than the rule to the letter: a Greek name prints as the letter where it is **chosen**, and upright as the word where it is **given**. An italic `\eta` that might be either is worse than an upright `\mathrm{eta}` that is one — the whole point of the distinction being that a reader can tell without the legend. The escape hatch costs nothing and is already there: a symbol table entry is printed verbatim, so an author whose own preamble loads `upgreek` writes `eta: "\\upeta"` and gets it. The fixture gains the case, which the page now shows in one legend beside the other: `eta` given prints $\mathrm{eta}$, `theta` chosen prints $\theta$. --- docs/reference/notation.md | 14 ++++++++++++++ src/math_spec/typeset/symbols.py | 21 +++++++++++++-------- tests/typeset/golden/latex.out | 2 ++ tests/typeset/golden/markdown.out | 5 +++++ tests/typeset/golden/model.yaml | 4 ++++ tests/typeset/golden/typst.out | 2 ++ tests/typeset/test_typeset.py | 14 ++++++++++---- 7 files changed, 50 insertions(+), 12 deletions(-) diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 35358871..db12de1c 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -63,6 +63,7 @@ parameters: zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } + eta: { dims: [generator] } # a Greek name that is *given*, so the rule wins and it prints as the word lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column @@ -91,6 +92,7 @@ parameters: | $\mathrm{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | | $\mathrm{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | | $\mathrm{min\_up}$ | `min_up` over $\mathcal{G}$ | +| $\mathrm{eta}$ | `eta` over $\mathcal{G}$ | | $\mathrm{lead}$ | `lead` over $\mathcal{G}$ | | $\mathrm{budget}$ | `budget` (scalar) | | $\mathrm{growth}$ | `growth` (scalar) | @@ -395,6 +397,18 @@ northern: $$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ +#### `efficiency` + +a Greek-named parameter, which is given — so the convention wins and it prints as the word + +```yaml +efficiency: + foreach: [snapshot, generator] + expression: p <= eta * p_max +``` + +$$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + #### `always` a mask that is only the constant true, which the language says is no mask at all — so none prints diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 7359ee3b..279adf03 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -54,7 +54,7 @@ def _word(name: str, fmt: Format, *, given: bool) -> str: - """One name as one symbol, upright where it is *given*. + r"""One name as one symbol, upright where it is *given*. **Upright is what the data supplies; italic is what the solver chooses.** Which of the two a symbol is, is the distinction a linear model cannot @@ -64,16 +64,21 @@ def _word(name: str, fmt: Format, *, given: bool) -> str: sets, upright for the maps and qualifiers a model is handed, italic for quantities. The cut this adds is *inside* italic. - A name that *is* a Greek letter is set as the letter either way, which is - what the author meant by writing it out. The distinction does not reach - those: LaTeX sets lower-case Greek italic and has no upright form in the - two-package preamble the render gate holds itself to, so a Greek-named - parameter is one the legend has to place. + A name that *is* a Greek letter is set as the letter **where it is + chosen**, which is what the author meant by writing it out. Where it is + given, the rule wins and the name prints upright as the word: LaTeX has no + upright lower-case Greek without ``upgreek``, and taking that dependency + would break two things this repository holds — a preamble installable from + a two-package TeX, and `to_markdown` output that renders the same on GitHub + as on the docs site, GitHub's MathJax being unconfigurable. An italic + ``\eta`` that might be either is worse than an upright ``\mathrm{eta}`` + that is one; a table entry is how an author whose own preamble loads + ``upgreek`` writes ``\upeta`` instead. """ - if name in _GREEK: - return fmt.greek(name) if given: return fmt.upright(name) + if name in _GREEK: + return fmt.greek(name) return name if len(name) == 1 else fmt.italic(name) diff --git a/tests/typeset/golden/latex.out b/tests/typeset/golden/latex.out index 1f5f060d..ddaf395f 100644 --- a/tests/typeset/golden/latex.out +++ b/tests/typeset/golden/latex.out @@ -27,6 +27,7 @@ \item[$\mathrm{zone\_cap}$] \texttt{zone\_cap} over $\mathcal{Z}$ \item[$\mathrm{tech\_cap}$] \texttt{tech\_cap} over $\mathcal{B} \times \mathcal{E}$ \item[$\mathrm{min\_up}$] \texttt{min\_up} over $\mathcal{G}$ +\item[$\mathrm{eta}$] \texttt{eta} over $\mathcal{G}$ \item[$\mathrm{lead}$] \texttt{lead} over $\mathcal{G}$ \item[$\mathrm{budget}$] \texttt{budget} (scalar) \item[$\mathrm{growth}$] \texttt{growth} (scalar) @@ -83,6 +84,7 @@ \text{running} && \theta_{b} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \theta_{b} \text{ exists} \wedge t \ge 3 \\ \text{first} && \mathit{on}_{t,g} & = 1 && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \left( t = \mathrm{index}(\mathcal{T}, 0) \vee t = \mathrm{index}(\mathcal{T}, 0, \mathrm{season\_of}(t)) \right) \\ \text{northern} && \mathit{slack}_{t} & \le \mathrm{load}_{t,b} && \forall\, t \in \mathcal{T},\ b \in \mathcal{B} \,:\, \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined} \\ +\text{efficiency} && p_{t,g} & \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} && \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \\ \text{always} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \\ \text{redundant} && \mathit{spill}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \top \wedge \mathit{spill}_{t} \text{ exists} \\ \text{never} && \mathit{slack}_{t} & \ge 0 && \forall\, t \in \mathcal{T} \,:\, \bot diff --git a/tests/typeset/golden/markdown.out b/tests/typeset/golden/markdown.out index 35af2976..c3334bab 100644 --- a/tests/typeset/golden/markdown.out +++ b/tests/typeset/golden/markdown.out @@ -25,6 +25,7 @@ every character a notation escapes, set as text: link_to, 100% & #1 costs $5 {ne | $\mathrm{zone\_cap}$ | `zone_cap` over $\mathcal{Z}$ | | $\mathrm{tech\_cap}$ | `tech_cap` over $\mathcal{B} \times \mathcal{E}$ | | $\mathrm{min\_up}$ | `min_up` over $\mathcal{G}$ | +| $\mathrm{eta}$ | `eta` over $\mathcal{G}$ | | $\mathrm{lead}$ | `lead` over $\mathcal{G}$ | | $\mathrm{budget}$ | `budget` (scalar) | | $\mathrm{growth}$ | `growth` (scalar) | @@ -143,6 +144,10 @@ $$\mathit{on}_{t,g} = 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \i $$\mathit{slack}_{t} \le \mathrm{load}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B} \thinspace:\thinspace \mathrm{zone\_of}(b) = \text{north} \wedge \mathrm{zone\_of}(b) \neq \mathrm{area\_of}(b) \wedge \mathrm{zone\_of}(b) \text{ is defined}$$ +**`efficiency`** + +$$p_{t,g} \le \mathrm{eta}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$ + **`always`** $$\mathit{spill}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T}$$ diff --git a/tests/typeset/golden/model.yaml b/tests/typeset/golden/model.yaml index 0ed0e697..477f2862 100644 --- a/tests/typeset/golden/model.yaml +++ b/tests/typeset/golden/model.yaml @@ -37,6 +37,7 @@ parameters: zone_cap: { dims: [zone] } tech_cap: { dims: [bus, technology] } min_up: { dims: [generator], dtype: int } + eta: { dims: [generator] } # a Greek name that is *given*, so the rule wins and it prints as the word lead: { dims: [generator], dtype: int } budget: { dims: [] } # scalar: the legend says so rather than printing an empty product growth: { dims: [] } # the base of a power; the exponent is `lead`, a column @@ -157,6 +158,9 @@ constraints: foreach: [snapshot, bus] where: "zone_of == 'north' AND zone_of != area_of AND zone_of" expression: slack <= load + efficiency: # a Greek-named parameter, which is given — so the convention wins and it prints as the word + foreach: [snapshot, generator] + expression: p <= eta * p_max always: # a mask that is only the constant true, which the language says is no mask at all — so none prints foreach: [snapshot] where: "true" diff --git a/tests/typeset/golden/typst.out b/tests/typeset/golden/typst.out index 247cf490..ca55d692 100644 --- a/tests/typeset/golden/typst.out +++ b/tests/typeset/golden/typst.out @@ -20,6 +20,7 @@ every character a notation escapes, set as text: link\_to, 100% & \#1 costs \$5 / $upright("zone_cap")$: `zone_cap` over $cal(Z)$ / $upright("tech_cap")$: `tech_cap` over $cal(B) times cal(E)$ / $upright("min_up")$: `min_up` over $cal(G)$ +/ $upright("eta")$: `eta` over $cal(G)$ / $upright("lead")$: `lead` over $cal(G)$ / $upright("budget")$: `budget` (scalar) / $upright("growth")$: `growth` (scalar) @@ -72,6 +73,7 @@ $ upright("balance") & sum_(g in cal(G) colon upright("gen_bus")(g) = b) p_(t,g) upright("running") & theta_(b) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon theta_(b) upright(" exists") and t >= 3 \ upright("first") & italic("on")_(t,g) & = 1 & forall t in cal(T), g in cal(G) colon (t = upright("index")(cal(T), 0) or t = upright("index")(cal(T), 0, upright("season_of")(t))) \ upright("northern") & italic("slack")_(t) & <= upright("load")_(t,b) & forall t in cal(T), b in cal(B) colon upright("zone_of")(b) = upright("north") and upright("zone_of")(b) != upright("area_of")(b) and upright("zone_of")(b) upright(" is defined") \ + upright("efficiency") & p_(t,g) & <= upright("eta")_(g) dot upright("p")^(upright("max"))_(g) & forall t in cal(T), g in cal(G) \ upright("always") & italic("spill")_(t) & >= 0 & forall t in cal(T) \ upright("redundant") & italic("spill")_(t) & >= 0 & forall t in cal(T) colon top and italic("spill")_(t) upright(" exists") \ upright("never") & italic("slack")_(t) & >= 0 & forall t in cal(T) colon bot $ diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index e581f4b8..cae270ea 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -443,13 +443,19 @@ def test_an_underscore_is_only_a_qualifier_when_its_head_is_a_symbol(name: str, pytest.param('cost', r'\mathrm{cost}', id='a-word'), pytest.param('p', r'\mathrm{p}', id='and-a-single-letter-too'), pytest.param('p_max', r'\mathrm{p}^{\mathrm{max}}', id='the-head-of-a-qualifier-with-it'), - pytest.param('theta', r'\theta', id='but-not-a-greek-letter-latex-has-no-upright-form-for'), + pytest.param('eta', r'\mathrm{eta}', id='and-a-greek-name-the-rule-beating-the-letter'), ], ) def test_a_given_quantity_is_upright(name: str, expected: str): - """Upright is what the data supplies, and it reaches the single letters: - `p^max` beside a variable `p` is exactly the pair a reader has to be able - to tell apart.""" + r"""Upright is what the data supplies, and it admits no exception. + + Not for single letters — `p^max` beside a variable `p` is exactly the pair + a reader has to be able to tell apart — and not for a Greek name, where + LaTeX has no upright lower-case form without `upgreek`, and taking that + dependency would cost both a two-package preamble and markdown that + renders the same on GitHub as on the docs site. An italic `\eta` that + might be either is worse than an upright `\mathrm{eta}` that is one. + """ assert _derive_name_symbol(name, frozenset({'p', 'soc'}), LATEX, given=True) == expected From 8aedffc5ab25191b6fde8b7d6e038b89f1ad905c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:22:21 +0000 Subject: [PATCH 4/5] fix: a page never states a rule its own symbols are not keeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commenting `examples/symbols/dispatch.yaml` — a table whose three parameter entries are italic, which the convention says they should not be — turned up two things worth more than the comment. **The homepage was stale, and nothing was holding it.** `docs/index.md` and `README.md` carry `examples/dispatch.yaml` rendered two ways, written by `tools/home_math.py`, and no test or workflow ran its `--check`. The upright convention changed how a parameter prints and the page kept the old math with every test green — the same failure `tools/notation.py` had until #41, on the page a reader arrives at first. It has the same guard now, and the guard was checked against a perturbed page rather than assumed. **The note contradicted itself there.** It quotes the model's own first parameter, and on that page the first parameter is `\bar p` — italic, because the table says so — under a sentence claiming a parameter is upright. So the note now quotes only symbols the *derivation* produced, and is suppressed where a table has taken over the ones it would have quoted. A table is printed verbatim and is the author's to write; a symbol it supplies is not one the note governs. Which is what the comment says, in the file that earns it: entries are the reader's notation to choose, and the same property is what lets an author whose preamble loads `upgreek` write `\upeta` for a parameter the derivation has to spell out. --- examples/symbols/dispatch.yaml | 9 +++++++++ src/math_spec/typeset/symbols.py | 7 +++++++ src/math_spec/typeset/walk.py | 14 +++++++++----- tests/test_docs.py | 21 ++++++++++++++++++++- tests/typeset/test_typeset.py | 15 +++++++++++++++ 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/examples/symbols/dispatch.yaml b/examples/symbols/dispatch.yaml index 8e1e56ac..0c046f9f 100644 --- a/examples/symbols/dispatch.yaml +++ b/examples/symbols/dispatch.yaml @@ -5,6 +5,15 @@ # The sidecar symbol table for `examples/dispatch.yaml` — not a model, so # nothing here is checked against the schema. `tools/render_tex.py` picks it up # by filename, and the homepage prints the same model with and without it. +# +# `c`, `\ell` and `\bar p` name three *parameters* and all three are italic, which +# is not what the derivation would print: upright is what the model is given. +# That is the point of a table rather than a hole in the rule. Entries are +# printed verbatim and a reader's notation is theirs to choose — the same +# property that lets an author whose preamble loads `upgreek` write `\upeta` +# for a parameter the derivation has to spell out. The convention note is +# suppressed wherever a table has taken over the symbols it would quote, so a +# page never states a rule its own symbols are not keeping. notation: latex dimensions: diff --git a/src/math_spec/typeset/symbols.py b/src/math_spec/typeset/symbols.py index 279adf03..48ba994a 100644 --- a/src/math_spec/typeset/symbols.py +++ b/src/math_spec/typeset/symbols.py @@ -136,6 +136,13 @@ def __init__(self, schema: Buildable, fmt: Format, table: SymbolTable) -> None: # head a qualifier may hang off declared = frozenset({*schema.parameters, *schema.variables}) + #: Names whose symbol came from the table rather than the derivation. + #: The convention note quotes only the others: a table is printed + #: verbatim and is the author's to write, so a symbol it supplies is + #: not one the note governs — the homepage's own table maps two + #: parameters to italic symbols, and the note quoting one of those + #: contradicted itself on the page. + self.overridden = frozenset(table.names) & {*schema.parameters, *schema.variables} self.name: dict[str, str] = { name: table.names[name] if name in table.names diff --git a/src/math_spec/typeset/walk.py b/src/math_spec/typeset/walk.py index 834af765..cc159461 100644 --- a/src/math_spec/typeset/walk.py +++ b/src/math_spec/typeset/walk.py @@ -732,13 +732,17 @@ def convention_notes(self) -> list[str]: and the distinction is gone. So the symbols carry it, and this is where the page says which face is which. - Only where the model has both, since a note explaining a contrast the - page does not draw is the dead end the translation notes avoid. + Only where the model has both, and only quoting symbols the + *derivation* produced: a table is printed verbatim and is the author's + to write, so a symbol it supplies is not one this note governs. """ - if not (self.schema.parameters and self.schema.variables): + derived = [ + next((n for n in names if n not in self.symbols.overridden), None) + for names in (self.schema.parameters, self.schema.variables) + ] + if not all(derived): return [] - given = self.format.math(self.symbols.name[next(iter(self.schema.parameters))]) - chosen = self.format.math(self.symbols.name[next(iter(self.schema.variables))]) + given, chosen = (self.format.math(self.symbols.name[n]) for n in derived if n is not None) return [ f'Upright is what the model is given {self.format.dash} a parameter such as {given}, a coordinate ' f'map, a label {self.format.dash} and italic is what the solver chooses, such as {chosen}. ' diff --git a/tests/test_docs.py b/tests/test_docs.py index c1a34e4c..c99a5c54 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -17,7 +17,7 @@ import pytest from math_spec.model import PIECEWISE_METHODS -from tools import gallery, notation +from tools import gallery, home_math, notation @pytest.mark.parametrize('page', gallery.pages()) @@ -45,6 +45,25 @@ def test_the_notation_page_is_current(): ) +def test_the_homepage_math_is_current(): + """The third generated page, and the one that proved the guard was needed. + + `docs/index.md` and `README.md` carry `examples/dispatch.yaml` rendered two + ways, and nothing was holding either to the renderer: a change to how a + parameter prints left the homepage stale and every test green. Same claim + as the two above, for the page a reader arrives at first. + """ + stale = [ + path.name + for path, updated in ( + (home_math.PAGE, home_math.rendered_page(home_math.PAGE.read_text())), + (home_math.README, home_math.rendered_readme(home_math.README.read_text())), + ) + if updated != path.read_text() + ] + assert not stale, f'{", ".join(stale)} no longer matches the model shown — run `pixi run python -m tools.home_math`' + + def test_every_piecewise_method_has_a_model_on_the_notation_page(): """What the page's `_curves()` claims: one row per `method:`, all of them. diff --git a/tests/typeset/test_typeset.py b/tests/typeset/test_typeset.py index cae270ea..fc7b7b00 100644 --- a/tests/typeset/test_typeset.py +++ b/tests/typeset/test_typeset.py @@ -495,6 +495,21 @@ def test_the_legend_states_the_convention_only_where_it_draws_it(fmt: Format): assert 'Upright is what the model is given' not in typeset(parameterless, fmt) +@EVERY_FORMAT +def test_the_convention_note_quotes_only_what_the_derivation_chose(fmt: Format): + """A table is printed verbatim and is the author's to write, so a symbol it + supplies is not one the note governs. + + `examples/symbols/dispatch.yaml` maps three parameters to italic symbols, + and the homepage renders through it — so the note quoting one of those said + "a parameter such as $\\bar p$" under a sentence claiming a parameter is + upright, contradicting itself on the page a reader arrives at first. + """ + table = {'notation': fmt.notation, 'names': {'load': 'x', 'cost': 'c', 'p_max': 'm'}} + assert 'Upright is what the model is given' not in typeset(DISPATCH, fmt, symbols=table) + assert 'Upright is what the model is given' in typeset(DISPATCH, fmt), 'derived, so the note applies' + + @EVERY_FORMAT def test_a_dimension_is_not_a_head_a_qualifier_hangs_off(fmt: Format): """`zone_cap` is a capacity *indexed by* zone, not a zone qualified by cap. From 1e6bcd1e23509ba225c9c4dc2abdd9a8086f42fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 12:29:24 +0000 Subject: [PATCH 5/5] test: a tool that can tell a page is stale is one a test has to ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asking whether anything would catch the next divergence turned up a third stale page. `docs/reference/language/operators.md` carries the operator table `tools/spec_math.py` writes, its `--check` was called by no test and no workflow, and the upright convention had left sixteen of its rows behind. It is in the same trap the other two were: the generator writes unpadded tables, prettier pads them, and the committed block has been prettier's shape and never the generator's since the day it was written — which is why no guard could exist. So it joins `.prettierignore` under the rule `docs/examples/` set, and the entry was checked to be load-bearing rather than decorative. `tools/home_math.py` is the other way out of the same conflict and needs no entry: it emits the blank lines around its markers that prettier wants, so `--check` and the formatter already agree. Padding a table is a harder shape for a generator to match than a blank line, which is why three pages take the generator-wins route and one does not — now said out loud in `.prettierignore` rather than rediscovered per page. The guards are a table now, one row per committed page, and `test_every_ generator_is_asked` holds that table to the tools: a `tools/*.py` that can detect drift and has no row fails the suite. That is the part that catches the *next* one — three pages have now gone stale with everything green, each time because the tool knew and nothing asked it. Verified by making a tool look like a generator and watching the test fail. --- .prettierignore | 13 ++++ docs/reference/language/operators.md | 32 ++++----- tests/test_docs.py | 98 +++++++++++++++------------- 3 files changed, 83 insertions(+), 60 deletions(-) diff --git a/.prettierignore b/.prettierignore index 300de51b..56297914 100644 --- a/.prettierignore +++ b/.prettierignore @@ -29,3 +29,16 @@ docs/examples/operators.md # by the hook and the generator's output has never been what the file holds — # which is why nothing could compare them. docs/reference/notation.md + +# `tools/spec_math.py` writes the operator table on this page, and it is the +# third page in the same trap — the committed block has been prettier's shape +# and never the generator's since the day it was written. The prose and the +# hand-written tables above the markers give up formatting with it, which is +# the price of the file being the unit `.prettierignore` works in. +# +# `tools/home_math.py` is the other way out of this and needs no entry: it +# emits the blank lines around its markers that prettier wants, so `--check` +# and the formatter agree on `docs/index.md` and `README.md`. Padding a table +# is a harder shape to match than a blank line, which is why these three take +# the generator-wins route instead. +docs/reference/language/operators.md diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 2a877686..aa07dddd 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -345,27 +345,27 @@ language is rendered the same way, on one page: [Every construct, as math](../notation.md). - -| Operator | Renders as | -| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `sum(array)` | $\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$ | -| `sum(array, over=dim)` | $\sum_{g \in \mathcal{G}} p_{t,g} \le \mathit{limit}_{t} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `sum(array, by=lookup)` | $\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathit{limit}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$ | -| `sum(array, by=[lookup, …])` | $\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathit{limit}_{t,b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$ | -| `at(array, by=lookup)` | $p_{t} \le \mathit{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `shift(array, over=dim, offset=n)` | $p_{t} \le p_{t - 1} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `shift(array, over=dim, offset=n, edge='wrap')` | $p_{t} \le p_{t \ominus 1} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `shift(array, over=dim, offset=n, edge=v)` | $p_{t} \le p_{t \boxminus_{0} 1} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `shift(array, over=dim, offset=p, edge=…)` | $\mathit{order}_{t,m \boxminus_{0} \mathit{lead}} \ge \mathit{demand}_{t,m} \qquad \forall\thinspace t \in \mathcal{T},\enspace m \in \mathcal{M}$ | -| `shift(array, over=dim, offset=n, by=lookup)` | $p_{t} \le p_{t \ominus_{\mathrm{season\_of}(t)} 1} \qquad \forall\thinspace t \in \mathcal{T}$ | -| `sum_back(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | -| `sum_back(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | -| `sum_back(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathit{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| Operator | Renders as | +|---|---| +| `sum(array)` | $\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathrm{budget}$ | +| `sum(array, over=dim)` | $\sum_{g \in \mathcal{G}} p_{t,g} \le \mathrm{limit}_{t} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `sum(array, by=lookup)` | $\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b} p_{t,g} \le \mathrm{limit}_{t,b} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B}$ | +| `sum(array, by=[lookup, …])` | $\sum_{g \in \mathcal{G} \thinspace:\thinspace \mathrm{gen\_bus}(g) = b \wedge \mathrm{gen\_tech}(g) = e} p_{t,g} \le \mathrm{limit}_{t,b,e} \qquad \forall\thinspace t \in \mathcal{T},\enspace b \in \mathcal{B},\enspace e \in \mathcal{E}$ | +| `at(array, by=lookup)` | $p_{t} \le \mathrm{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `shift(array, over=dim, offset=n)` | $p_{t} \le p_{t - 1} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `shift(array, over=dim, offset=n, edge='wrap')` | $p_{t} \le p_{t \ominus 1} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `shift(array, over=dim, offset=n, edge=v)` | $p_{t} \le p_{t \boxminus_{0} 1} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `shift(array, over=dim, offset=p, edge=…)` | $\mathit{order}_{t,m \boxminus_{0} \mathrm{lead}} \ge \mathrm{demand}_{t,m} \qquad \forall\thinspace t \in \mathcal{T},\enspace m \in \mathcal{M}$ | +| `shift(array, over=dim, offset=n, by=lookup)` | $p_{t} \le p_{t \ominus^{\mathrm{season\_of}(t)} 1} \qquad \forall\thinspace t \in \mathcal{T}$ | +| `sum_back(array, over=dim, within=n)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < 3} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_back(array, over=dim, within=p)` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h - h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | +| `sum_back(array, over=dim, within=p, edge='wrap')` | $\sum_{h' \in \mathcal{H} \thinspace:\thinspace 0 \le h \ominus h' < \mathrm{min\_up}} \mathit{started}_{u,h'} \le \mathit{on}_{u,h} \qquad \forall\thinspace u \in \mathcal{U},\enspace h \in \mathcal{H}$ | $t \ominus k$ denotes cyclic translation: index $t-k$ taken modulo the size of the dimension (`roll`). Plain $t-k$ (`shift`) has no wraparound — terms translated past the edge are simply absent. $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. +$t \ominus^{\mathrm{lookup}(t)} k$ denotes a translation counted inside the group a lookup puts $t$ in (`shift(by=lookup)`), so a term never crosses out of its own group. Regenerate with `pixi run python -m tools.spec_math`. diff --git a/tests/test_docs.py b/tests/test_docs.py index c99a5c54..e51894bd 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -4,66 +4,76 @@ """The generated half of the documentation, held to what generates it. -`docs/examples/` shows each model beside the math the typesetter prints from -it. Written by hand, that math would be a claim nothing checks — on a site -whose subject is the math a file means, and in a repository that owns the -renderer which would have caught it. So the block is generated, and this is -what makes "generated" true of the committed file rather than of a script -nobody runs. +The site shows models beside the math the typesetter prints from them. Written +by hand, that math would be a claim nothing checks — on a site whose subject is +the math a file means, and in a repository that owns the renderer which would +have caught it. So the blocks are generated, and this is what makes +"generated" true of the committed files rather than of scripts nobody runs. + +Three of the five pages below were stale at some point with the whole suite +green, which is why the last test in this module exists: a tool that can tell +a page has drifted is one a test has to *ask*. """ from __future__ import annotations +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING + import pytest from math_spec.model import PIECEWISE_METHODS -from tools import gallery, home_math, notation - - -@pytest.mark.parametrize('page', gallery.pages()) -def test_the_gallery_math_is_current(page: str): - path = gallery.PAGES / page +from tools import gallery, home_math, notation, spec_math + +if TYPE_CHECKING: + from collections.abc import Callable + +ROOT = Path(__file__).resolve().parent.parent + +#: Every committed page a generator writes: how to re-render it, and which +#: tool rewrites it. Adding a generator means adding a row here, which +#: `test_every_generator_is_asked` is what says out loud. +GENERATED: list[tuple[str, Path, Callable[[str], str], str]] = [ + *( + (f'gallery:{name}', gallery.PAGES / name, partial(gallery.rendered, name), 'gallery') + for name in gallery.pages() + ), + ('notation', notation.PAGE, notation.rendered_page, 'notation'), + ('operators', spec_math.PAGE, spec_math.rendered, 'spec_math'), + ('home:index', home_math.PAGE, home_math.rendered_page, 'home_math'), + ('home:readme', home_math.README, home_math.rendered_readme, 'home_math'), +] + + +@pytest.mark.parametrize( + ('path', 'render', 'tool'), + [row[1:] for row in GENERATED], + ids=[row[0] for row in GENERATED], +) +def test_the_generated_page_is_current(path: Path, render: Callable[[str], str], tool: str): text = path.read_text() - assert gallery.rendered(page, text) == text, ( - f'docs/examples/{page} no longer matches the model it shows — run `pixi run python -m tools.gallery`' + assert render(text) == text, ( + f'{path.relative_to(ROOT)} no longer matches what it is generated from — run `pixi run python -m tools.{tool}`' ) -def test_the_notation_page_is_current(): - """The same claim, for the page that shows every construct at once. +def test_every_generator_is_asked(): + """A tool that can say a page is stale needs a test that asks it. - It went unmade for longer, and cost more: four of the models the page - renders from were left behind when the language was extracted, so - `tools/notation.py` raised `FileNotFoundError` on the curve section and - the committed page kept showing math no model here produced. Nothing - failed, because nothing ran it. + Three of the pages above were stale at some point with the suite green: the + notation page for the whole life of the extraction, because the models it + renders stayed behind and nothing ran `--check` (#41); then the homepage and + the operator reference, under a change to how a parameter prints. Each time + the tool knew and nothing asked it, so the next generator lands with its row + or this fails — which is cheaper than a reader finding the drift. """ - text = notation.PAGE.read_text() - assert notation.rendered_page(text) == text, ( - 'docs/reference/notation.md no longer matches the fixture it is generated from — ' - 'run `pixi run python -m tools.notation`' + detects_drift = {path.stem for path in (ROOT / 'tools').glob('*.py') if '--check' in path.read_text()} + assert detects_drift == {tool for *_, tool in GENERATED}, ( + 'a tool that can detect a stale page has no row in GENERATED, or a row names a tool that cannot' ) -def test_the_homepage_math_is_current(): - """The third generated page, and the one that proved the guard was needed. - - `docs/index.md` and `README.md` carry `examples/dispatch.yaml` rendered two - ways, and nothing was holding either to the renderer: a change to how a - parameter prints left the homepage stale and every test green. Same claim - as the two above, for the page a reader arrives at first. - """ - stale = [ - path.name - for path, updated in ( - (home_math.PAGE, home_math.rendered_page(home_math.PAGE.read_text())), - (home_math.README, home_math.rendered_readme(home_math.README.read_text())), - ) - if updated != path.read_text() - ] - assert not stale, f'{", ".join(stale)} no longer matches the model shown — run `pixi run python -m tools.home_math`' - - def test_every_piecewise_method_has_a_model_on_the_notation_page(): """What the page's `_curves()` claims: one row per `method:`, all of them.