From 931d17371f0467111e6544af4bd017f534853782 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 07:19:50 +0000 Subject: [PATCH] fix: the notation page is generated again, and something says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tools/notation.py` could not run in this repository. Its `PIECEWISE` map names four models that stayed behind in lpspec when the language was extracted (#17), so `_curves()` raised `FileNotFoundError` and the only way to touch the page was by hand — which is how a generated page ends up showing math no model here produces. Nothing caught it because nothing ran the tool: `--check` has never been called by a test or a workflow, in this repository or the one it came from. So the page has been drifting since the extraction with the guard it claims in its own docstring — `tests/test_docs_site.py` — not existing here at all. - **The four models come back**: `examples/piecewise.yaml`, `examples/sos.yaml`, `examples/piecewise_lp.yaml` and `examples/ports/transport_pwl.yaml`, one per `method:`, each carrying the licence header this repository's examples carry. They render against this language unchanged, and the page they regenerate is byte-identical to the curve rows already committed — so what they restore is the ability to check the page, not the page. - **The page is prettier-ignored**, the rule `docs/examples/` already follows: the generator writes unpadded legend tables, prettier pads them, and each undoes the other, so the committed file could never satisfy both. It has been losing that argument silently since the tables were first generated — the bytes here are what the generator writes, which is why the guard can exist. - **Two guards** in `tests/test_docs.py`, beside the one `docs/examples/` already has: the page matches `rendered_page()`, and `PIECEWISE` covers `PIECEWISE_METHODS`, which is the claim the docstring makes about it. The docstring's other two claims were stale as well: four methods, not three, and a section showing one of them is a quarter of the construct rather than a third. `tools/render_tex.py` globs `examples/**/*.yaml`, so the LaTeX gate now renders and compiles nineteen models rather than fifteen. --- .prettierignore | 7 +++ docs/reference/notation.md | 68 ++++++++++++------------- examples/piecewise.yaml | 66 ++++++++++++++++++++++++ examples/piecewise_lp.yaml | 71 ++++++++++++++++++++++++++ examples/ports/transport_pwl.yaml | 84 +++++++++++++++++++++++++++++++ examples/sos.yaml | 67 ++++++++++++++++++++++++ tests/test_docs.py | 29 ++++++++++- tools/notation.py | 6 +-- 8 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 examples/piecewise.yaml create mode 100644 examples/piecewise_lp.yaml create mode 100644 examples/ports/transport_pwl.yaml create mode 100644 examples/sos.yaml diff --git a/.prettierignore b/.prettierignore index 741ead2b..300de51b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,3 +22,10 @@ CHANGELOG.md # hand. `index.md` is not listed — it carries no generated block. docs/examples/dispatch.md docs/examples/operators.md + +# `tools/notation.py` writes this page's block, and it is the same trap: the +# legend tables it emits are the same unpadded ones. The page was not listed +# when the rule was written, so every regeneration since has been padded back +# 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 diff --git a/docs/reference/notation.md b/docs/reference/notation.md index 7c04ebbd..2400c306 100644 --- a/docs/reference/notation.md +++ b/docs/reference/notation.md @@ -33,7 +33,6 @@ $\ell_t$. A [symbol table](typeset.md#symbol-tables) replaces them wholesale and changes nothing else on this page. - ### The legend A dimension, a lookup and a parameter declare no equation; what they print is the legend every model opens with. @@ -71,46 +70,46 @@ parameters: #### Sets -| Symbol | Meaning | -| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | +| Symbol | Meaning | +|---|---| +| $\mathcal{T}$ | index $t$ — `snapshot` with $\mathrm{season\_of}: \mathcal{T} \to \mathcal{S}$ | | $\mathcal{G}$ | index $g$ — `generator` with $\mathrm{gen\_bus}: \mathcal{G} \to \mathcal{B},\enspace \mathrm{gen\_tech}: \mathcal{G} \to \mathcal{E}$ carrying label $\mathrm{tech}$ | -| $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | -| $\mathcal{Z}$ | index $z$ — `zone` | -| $\mathcal{S}$ | index $s$ — `season` | -| $\mathcal{E}$ | index $e$ — `technology` | +| $\mathcal{B}$ | index $b$ — `bus` with $\mathrm{zone\_of}: \mathcal{B} \to \mathcal{Z},\enspace \mathrm{area\_of}: \mathcal{B} \to \mathcal{Z}$ | +| $\mathcal{Z}$ | index $z$ — `zone` | +| $\mathcal{S}$ | index $s$ — `season` | +| $\mathcal{E}$ | index $e$ — `technology` | #### 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}^{\mathrm{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) | +| 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}^{\mathrm{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) | #### Variables -| Symbol | Meaning | -| ------------------- | ---------------------------------------------- | -| $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}$ | -| $\mathit{on}$ | `on` over $\mathcal{T} \times \mathcal{G}$ | -| $\mathit{units}$ | `units` over $\mathcal{G}$ | -| $\mathit{spare}$ | `spare` over $\mathcal{G}$ | -| $\mathit{reserve}$ | `reserve` (scalar) | -| $\mathit{headroom}$ | `headroom` (scalar) | -| $\mathit{void}$ | `void` over $\mathcal{B}$ | -| $\mathit{weight}$ | `weight` over $\mathcal{T} \times \mathcal{G}$ | +| Symbol | Meaning | +|---|---| +| $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}$ | +| $\mathit{on}$ | `on` over $\mathcal{T} \times \mathcal{G}$ | +| $\mathit{units}$ | `units` over $\mathcal{G}$ | +| $\mathit{spare}$ | `spare` over $\mathcal{G}$ | +| $\mathit{reserve}$ | `reserve` (scalar) | +| $\mathit{headroom}$ | `headroom` (scalar) | +| $\mathit{void}$ | `void` over $\mathcal{B}$ | +| $\mathit{weight}$ | `weight` over $\mathcal{T} \times \mathcal{G}$ | $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. @@ -661,5 +660,4 @@ adjacent: ``` $$\left( \mathit{weight}_{t,g} \right)_{g \in \mathcal{G}} \in \mathrm{SOS}2 \qquad \forall\thinspace t \in \mathcal{T}$$ - diff --git a/examples/piecewise.yaml b/examples/piecewise.yaml new file mode 100644 index 00000000..a63168bc --- /dev/null +++ b/examples/piecewise.yaml @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + Least-cost dispatch where each generator's cost curve is piecewise-linear in + its output, expanded into a lambda formulation. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + p_max: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + p: + description: dispatched power + foreach: [snapshot, generator] + bounds: + lower: 0 + upper: p_max + op_cost: + description: operating cost, piecewise-linear in dispatch + foreach: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve — convex, so the weights need no + binaries to keep them on one segment + over: bp + links: + - [p, bp_x] + - [op_cost, bp_y] + method: convex + +constraints: + balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) diff --git a/examples/piecewise_lp.yaml b/examples/piecewise_lp.yaml new file mode 100644 index 00000000..7eea5c4b --- /dev/null +++ b/examples/piecewise_lp.yaml @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + The same least-cost dispatch as `piecewise.yaml`, with each generator's cost + curve stated as the lines its segments lie on rather than interpolated + between its breakpoints. The curve is convex and the objective pushes the + cost down, so a cost above every segment line settles on the curve — which + needs no interpolation weights, and so declares no auxiliary variable at all. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + p_max: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + p: + description: dispatched power + foreach: [snapshot, generator] + bounds: + lower: 0 + upper: p_max + op_cost: + description: operating cost, held above every segment of the generator's curve + foreach: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost bounded below by the curve — the `>=` is what says which side of the + lines the cost sits on, and the curvature has to match it: lines that + envelope a convex curve would cut a concave one, and the solve comes back + optimal either way + over: bp + links: + - [p, bp_x] + - [op_cost, bp_y, ">="] + method: lp + +constraints: + balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) diff --git a/examples/ports/transport_pwl.yaml b/examples/ports/transport_pwl.yaml new file mode 100644 index 00000000..6e3dfde0 --- /dev/null +++ b/examples/ports/transport_pwl.yaml @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + Dantzig's transportation problem with economies of scale — GAMS model library + trnspwl. Shipping cost grows as the square root of the consignment rather + than linearly, so a big consignment is cheaper per unit. Optimum + 8.786852757777865, from linopy's own piecewise formulation. + +dimensions: + plant: + description: canning plants, with limited capacity + values: [seattle, san-diego] + market: + description: markets, with demand to be met + values: [new-york, chicago, topeka] + bp: + description: breakpoints of the discretised square-root curve + dtype: int + +parameters: + capacity: + description: capacity of each plant + dims: [plant] + demand: + description: demand at each market + dims: [market] + distance: + description: distance from plant to market + dims: [plant, market] + freight: + description: freight rate per case per unit distance + dims: [] + bp_x: + description: >- + breakpoint shipment levels — one curve, the same on every route, so it + carries the breakpoint dimension alone and broadcasts across the pairs + dims: [bp] + bp_y: + description: the curve's value at each breakpoint + dims: [bp] + +variables: + shipment: + description: cases shipped from a plant to a market + foreach: [plant, market] + bounds: + lower: 0 + scaled: + description: >- + what the objective is charged on — the square root of the shipment, read + off the curve rather than computed + foreach: [plant, market] + bounds: + lower: 0 + +piecewise: + economies_of_scale: + description: >- + the shipment priced through the discretisation GAMS publishes, on segment + binaries and deliberately not the convex method: the curve is concave and + this is a + minimisation, so the convex-hull relaxation would let the solver ride the + chord underneath the true curve and buy transport cheaper than the model + allows. The binaries are what make the answer right — and what make this + port a MILP. + over: bp + links: + - [shipment, bp_x] + - [scaled, bp_y] + +constraints: + within_capacity: + foreach: [plant] + expression: sum(shipment, over=market) <= capacity + meet_demand: + foreach: [market] + expression: sum(shipment, over=plant) >= demand + +objective: + sense: minimize + description: total freight, charged on the scaled consignment rather than on the shipment + expression: sum(scaled * distance * freight / 1000) diff --git a/examples/sos.yaml b/examples/sos.yaml new file mode 100644 index 00000000..9e1f1b85 --- /dev/null +++ b/examples/sos.yaml @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +description: >- + A piecewise-linear cost curve stated as a special-ordered set, so the solver + is handed the adjacency restriction rather than binaries that encode it. + +dimensions: + snapshot: + description: dispatch periods + dtype: int + generator: + description: dispatchable units + dtype: str + bp: + description: breakpoints of the cost curve + dtype: int + +parameters: + p_max: + description: maximum dispatch + dims: [generator] + load: + description: demand to be met + dims: [snapshot] + bp_x: + description: breakpoint dispatch levels, one curve per generator + dims: [generator, bp] + bp_y: + description: cost at each breakpoint, one curve per generator + dims: [generator, bp] + +variables: + p: + description: dispatched power + foreach: [snapshot, generator] + bounds: + lower: 0 + upper: p_max + op_cost: + description: operating cost, piecewise-linear in dispatch + foreach: [snapshot, generator] + bounds: + lower: 0 + +piecewise: + cost_curve: + description: >- + cost read off the generator's curve, with at most two adjacent weights + non-zero — the restriction the default method builds out of binaries, + declared as a set instead + over: bp + links: + - [p, bp_x] + - [op_cost, bp_y] + method: sos2 + +constraints: + balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + +objective: + sense: minimize + description: total operating cost, taken off the curves rather than from a marginal rate + expression: sum(op_cost) diff --git a/tests/test_docs.py b/tests/test_docs.py index 76675115..c1a34e4c 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -16,7 +16,8 @@ import pytest -from tools import gallery +from math_spec.model import PIECEWISE_METHODS +from tools import gallery, notation @pytest.mark.parametrize('page', gallery.pages()) @@ -26,3 +27,29 @@ def test_the_gallery_math_is_current(page: str): assert gallery.rendered(page, text) == text, ( f'docs/examples/{page} no longer matches the model it shows — run `pixi run python -m tools.gallery`' ) + + +def test_the_notation_page_is_current(): + """The same claim, for the page that shows every construct at once. + + 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. + """ + 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`' + ) + + +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. + + `PIECEWISE_METHODS` is the closed set, so a method added to the language + lands here as a missing key rather than as a section quietly showing three + of four formulations. + """ + assert set(notation.PIECEWISE) == set(PIECEWISE_METHODS) diff --git a/tools/notation.py b/tools/notation.py index c0bdaaf5..86c0cb9f 100644 --- a/tools/notation.py +++ b/tools/notation.py @@ -41,9 +41,9 @@ PAGE = ROOT / 'docs' / 'reference' / 'notation.md' MODEL = ROOT / 'tests' / 'typeset' / 'golden' / 'model.yaml' -#: One model per ``method:``, because the three expand to three different -#: formulations and a section showing one of them would be showing a third of -#: the construct. ``tests/test_docs_site.py`` holds these keys to +#: One model per ``method:``, because the four expand to four different +#: formulations and a section showing one of them would be showing a quarter +#: of the construct. ``tests/test_docs.py`` holds these keys to #: :data:`math_spec.model.PIECEWISE_METHODS`, so a method added to the #: language arrives here or the page stops claiming to be all of them. #: