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. #: