diff --git a/.prettierignore b/.prettierignore index 98ad7d87..741ead2b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,14 @@ # The generator wins. Nobody edits this file by hand, so the formatting is not # anyone's to prefer. CHANGELOG.md + +# `tools/gallery.py` writes the body of these pages: the model, verbatim, and +# the document the typesetter prints from it. Prettier pads the legend tables +# it emits, the generator writes them unpadded, and each undoes the other — so +# the committed file could never satisfy both, and `tests/test_docs.py` compares +# it to the generator byte for byte. +# +# Same rule as CHANGELOG.md above: the generator wins where nobody edits by +# hand. `index.md` is not listed — it carries no generated block. +docs/examples/dispatch.md +docs/examples/operators.md diff --git a/docs/examples/dispatch.md b/docs/examples/dispatch.md new file mode 100644 index 00000000..997178d0 --- /dev/null +++ b/docs/examples/dispatch.md @@ -0,0 +1,88 @@ + + +# Least-cost dispatch + +The smallest file that is a whole model: generators with a capacity, an hourly +load to meet, and a cost to minimise. It is the model on the +[home page](../index.md) and in the README, and the one the language reference +varies when it needs a base to change one thing in. + +Two things worth reading for. The `where:` on `p` deletes the rows where a +generator has no capacity — [absence](../reference/language/absence.md) is a +declaration, not a runtime check. And `sum(p, over=generator)` names the +dimension it reduces, so the constraint's frame is what remains. + + +```yaml +description: Least-cost dispatch of a generator fleet against an hourly load. + +dimensions: + snapshot: { dtype: int, description: dispatch periods } + generator: { values: [wind, solar, gas], description: generating units } + +parameters: + p_max: { dims: [generator], description: installed capacity } + load: { dims: [snapshot], description: demand to be met } + cost: { dims: [generator], description: marginal cost } + +variables: + p: + description: output of a generator in a snapshot + foreach: [snapshot, generator] + where: "p_max > 0" + bounds: { lower: 0, upper: p_max } + +constraints: + power_balance: + foreach: [snapshot] + expression: sum(p, over=generator) == load + +objective: + sense: minimize + expression: sum(p * cost) +``` + +Least-cost dispatch of a generator fleet against an hourly load. + +#### Sets + +| Symbol | Meaning | +|---|---| +| $\mathcal{T}$ | index $t$ — `snapshot` — dispatch periods | +| $\mathcal{G}$ | index $g$ — `generator` — generating units | + +#### Parameters + +| 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 | + +#### Variables + +| Symbol | Meaning | +|---|---| +| $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot | + +#### Objective + +$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathit{cost}_{g}$$ + +#### Subject to + +**`power_balance`** + +$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathit{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$$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 00000000..64fa49fc --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,23 @@ + + +# Examples + +Whole models, each shown as the file and as the math it prints. The reference +pages take the language a construct at a time; these take it a **model** at a +time, which is the form anyone writing one actually needs. + +Every model here is a real file under `examples/` in the repository, not a +fragment written for the page. They are the same files the test suite loads and +the LaTeX gate compiles, so a model that stopped being valid — or that started +printing different math — fails CI rather than going stale here. + +- [Least-cost dispatch](dispatch.md) — the smallest model that is a model: a + balance, a bound, and a cost to minimise. +- [One construct per model](operators.md) — the operator probes: the smallest + file that declares each built-in, beside the equation it renders. + +The math on these pages is written by the typesetter, from the file above it — +see [Typeset the math](../reference/typeset.md) for how to print your own. diff --git a/docs/examples/operators.md b/docs/examples/operators.md new file mode 100644 index 00000000..0b953aff --- /dev/null +++ b/docs/examples/operators.md @@ -0,0 +1,432 @@ + + +# One construct per model + +The probes: for each built-in [operator](../reference/language/operators.md), +the smallest model that declares it, beside the equation it renders. The +reference page shows the same equations as a table — what each operator _looks +like_, side by side. This page shows the **file** that produced each one. + +They are models rather than fragments on purpose. A probe whose operator +changed shape stops loading, in CI, in the run that would otherwise have +shipped the old math. + + +### `sum(array)` + +`examples/operators/sum_all.yaml` + +```yaml +description: Every dimension at once — `sum(array)` names none of them and takes them all. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + +parameters: + budget: { dims: [] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + fleet_budget: + foreach: [] + expression: sum(p) <= budget + +objective: { sense: minimize, expression: sum(p) } +``` + +$\sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \le \mathit{budget}$ + +### `sum(array, over=dim)` + +`examples/operators/sum.yaml` + +```yaml +description: The plain reduction — `sum(array, over=dim)` collapses one dimension. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + +parameters: + limit: { dims: [snapshot] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + fleet_total: + foreach: [snapshot] + expression: sum(p, over=generator) <= limit + +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(array, by=lookup)` + +`examples/operators/sum_by.yaml` + +```yaml +description: >- + The membership reduction — `sum(array, by=lookup)` lands the result on the + dimension the lookup maps into, which is what makes topology data rather than + structure. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + bus: { dtype: str } + +lookups: + gen_bus: { over: generator, into: bus } + +parameters: + limit: { dims: [snapshot, bus] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + bus_total: + foreach: [snapshot, bus] + expression: sum(p, by=gen_bus) <= limit + +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(array, by=[lookup, …])` + +`examples/operators/sum_by_lookups.yaml` + +```yaml +description: >- + Grouping through several maps at once — `sum(array, by=[lookup, …])` lands + the result on every dimension the lookups map into, which is one grouping + rather than a composition of two: the generator dimension is consumed once. + +dimensions: + snapshot: { dtype: int } + generator: { dtype: str } + bus: { dtype: str } + technology: { dtype: str } + +lookups: + gen_bus: { over: generator, into: bus } + gen_tech: { over: generator, into: technology } + +parameters: + limit: { dims: [snapshot, bus, technology] } + +variables: + p: + foreach: [snapshot, generator] + bounds: { lower: 0 } + +constraints: + bus_technology_total: + foreach: [snapshot, bus, technology] + expression: sum(p, by=[gen_bus, gen_tech]) <= limit + +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}$ + +### `at(array, by=lookup)` + +`examples/operators/at.yaml` + +```yaml +description: >- + The adjoint of the membership reduction — `at(array, by=lookup)` reads one + coarse value once per fine label pointing at it. + +dimensions: + snapshot: { dtype: int } + period: { dtype: int } + +lookups: + period_of: { over: snapshot, into: period } + +parameters: + cap: { dims: [period] } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + within_cap: + foreach: [snapshot] + expression: p <= at(cap, by=period_of) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le \mathit{cap}_{\mathrm{period\_of}(t)} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n)` + +`examples/operators/shift.yaml` + +```yaml +description: >- + Translation with no edge policy — the vacated position is absent, so the row + it would have fed is not built. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t - 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n, edge='wrap')` + +`examples/operators/shift_wrap.yaml` + +```yaml +description: >- + Cyclic translation — the horizon closed on itself, so the first position + reads the last and nothing is vacated. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge='wrap') + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t \ominus 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=n, edge=v)` + +`examples/operators/shift_edge.yaml` + +```yaml +description: >- + Translation with a value at the edge — the vacated position contributes the + number instead of being absent, so the row survives. + +dimensions: + snapshot: { dtype: int } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge=0) + +objective: { sense: minimize, expression: sum(p) } +``` + +$p_{t} \le p_{t \boxminus_{0} 1} \qquad \forall\thinspace t \in \mathcal{T}$ + +### `shift(array, over=dim, offset=p, edge=…)` + +`examples/operators/shift_by_parameter.yaml` + +```yaml +description: >- + Translation by an offset that differs per entity — `by:` names an integer + parameter, so each technology is reached by its own lead time rather than by + one the file had to fix. + +dimensions: + technology: { dtype: str } + month: { dtype: int } + +parameters: + lead: { dims: [technology], dtype: int } + demand: { dims: [technology, month] } + +variables: + order: + foreach: [technology, month] + bounds: { lower: 0 } + +constraints: + arrives_after_its_lead: + foreach: [technology, month] + expression: shift(order, over=month, offset=lead, edge=0) >= demand + +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}$ + +### `shift(array, over=dim, offset=n, by=lookup)` + +`examples/operators/shift_partitioned.yaml` + +```yaml +description: >- + Translation inside a group — each season closed on itself, so a season's first + snapshot reads that season's last and no level crosses the boundary. + +dimensions: + snapshot: { dtype: int } + season: { dtype: str } + +lookups: + season_of: { over: snapshot, into: season } + +variables: + p: + foreach: [snapshot] + bounds: { lower: 0 } + +constraints: + no_faster_than_before_in_season: + foreach: [snapshot] + expression: p <= shift(p, over=snapshot, offset=1, edge='wrap', by=season_of) + +objective: { sense: minimize, expression: sum(p) } +``` + +$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)` + +`examples/operators/sum_back.yaml` + +```yaml +description: >- + A trailing window of a fixed width: a unit that started in the last three + hours is still on. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=3) <= on + +objective: { sense: minimize, expression: sum(on) } +``` + +$\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)` + +`examples/operators/sum_back_by_parameter.yaml` + +```yaml +description: >- + A trailing window whose width is data — `within:` names an integer parameter, + so a unit stays up for its *own* minimum time rather than one the file fixed. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=min_up) <= on + +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_back(array, over=dim, within=p, edge='wrap')` + +`examples/operators/sum_back_wrap.yaml` + +```yaml +description: >- + A trailing window on a representative period that repeats, so the window at + the first hour reaches back into the last. + +dimensions: + unit: { dtype: str } + hour: { dtype: int } + +parameters: + min_up: { dims: [unit], dtype: int } + +variables: + started: + foreach: [unit, hour] + domain: binary + on: + foreach: [unit, hour] + domain: binary + +constraints: + stays_up_its_own_time: + foreach: [unit, hour] + expression: sum_back(started, over=hour, within=min_up, edge='wrap') <= on + +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}$ + + +Regenerate with `pixi run python -m tools.gallery`. diff --git a/docs/reference/language/operators.md b/docs/reference/language/operators.md index 7c1a2e75..2a877686 100644 --- a/docs/reference/language/operators.md +++ b/docs/reference/language/operators.md @@ -339,7 +339,10 @@ The three `shift` rows are the ones to read together: they differ only at the boundary, and that difference is the whole of the identity rule in this position. -The rest of the language is rendered the same way, on one page: [Every construct, as math](../notation.md). +Each row comes from a model of its own; they are on +[One construct per model](../../examples/operators.md). The rest of the +language is rendered the same way, on one page: +[Every construct, as math](../notation.md). diff --git a/mkdocs.yml b/mkdocs.yml index 91d91bc7..9a98cfbd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,10 @@ validation: nav: - Home: index.md - Installation: installation.md + - Examples: + - examples/index.md + - Least-cost dispatch: examples/dispatch.md + - One construct per model: examples/operators.md - Reference: - Language: - reference/language/index.md diff --git a/tests/test_docs.py b/tests/test_docs.py new file mode 100644 index 00000000..76675115 --- /dev/null +++ b/tests/test_docs.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""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. +""" + +from __future__ import annotations + +import pytest + +from tools import gallery + + +@pytest.mark.parametrize('page', gallery.pages()) +def test_the_gallery_math_is_current(page: str): + path = gallery.PAGES / page + 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`' + ) diff --git a/tools/gallery.py b/tools/gallery.py new file mode 100644 index 00000000..f2471f85 --- /dev/null +++ b/tools/gallery.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: math-spec Contributors +# +# SPDX-License-Identifier: MIT + +"""The example gallery: each model in `examples/`, beside the math it prints. + + pixi run python -m tools.gallery # rewrite the pages' blocks + pixi run python -m tools.gallery --check # fail if one has drifted + +The reference pages show what a *construct* prints; nothing showed a **model**. +A reader could see the equation `sum(by=)` renders as and never see a file that +declares one, which is the wrong way round for a language whose pitch is that +the file and the math are the same thing. + +So each page is one model, in full, followed by the document the typesetter +prints from it. Generated for the reason the other two blocks are: math typed +into a page is math nothing checks, and this project has the renderer that +would have caught it. + +The prose above each block is the page's own — what the model is for, and which +construct it is here to show. Only the fenced model and the math below it are +written from here. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from math_spec.typeset import to_markdown +from tools.spec_math import OPERATORS, rendered_probe + +ROOT = Path(__file__).resolve().parent.parent +PAGES = ROOT / 'docs' / 'examples' +BEGIN, END = '', '' + +#: Page -> the model it shows. One model per page, because a gallery of +#: fragments is what the reference pages already are. +MODELS = { + 'dispatch.md': ROOT / 'examples' / 'dispatch.yaml', +} + +#: The probe page shows every model under `examples/operators/`, keyed by the +#: signature it demonstrates — :data:`tools.spec_math.OPERATORS` is that map, +#: and reusing it is what keeps the two pages naming the same probes. +PROBES = ROOT / 'examples' / 'operators' + + +def source(path: Path) -> str: + """The model as written, without the licence header a reader did not ask for.""" + return re.sub(r'\A(#[^\n]*\n)+\n', '', path.read_text()).strip() + + +def model_block(path: Path) -> str: + """One model, then the whole document the typesetter prints from it.""" + return f'```yaml\n{source(path)}\n```\n\n{to_markdown(path, numbered=False).strip()}' + + +def probe_block() -> str: + """Every operator probe: the model, then the one equation it renders.""" + parts = [] + for signature, name in OPERATORS.items(): + equation, _ = rendered_probe(name) + parts.append( + f'### `{signature}`\n\n' + f'`examples/operators/{name}.yaml`\n\n' + f'```yaml\n{source(PROBES / f"{name}.yaml")}\n```\n\n' + f'{equation}' + ) + return '\n\n'.join(parts) + + +def block(page: str) -> str: + return probe_block() if page == 'operators.md' else model_block(MODELS[page]) + + +def rendered(page: str, text: str) -> str: + i, j = text.index(BEGIN) + len(BEGIN), text.index(END) + return text[:i] + '\n' + block(page) + '\n' + text[j:] + + +def pages() -> list[str]: + return [*MODELS, 'operators.md'] + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--check', action='store_true', help='fail if a committed block has drifted') + opts = ap.parse_args(argv) + + stale = [] + for page in pages(): + path = PAGES / page + text = path.read_text() + updated = rendered(page, text) + if opts.check: + if updated != text: + stale.append(page) + continue + path.write_text(updated) + print(f'wrote {path.relative_to(ROOT)}') + + if stale: + names = ', '.join(stale) + print(f'{names} stale — run `pixi run python -m tools.gallery`', file=sys.stderr) + return 1 + if opts.check: + print(f'{len(pages())} page(s) match their models') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main())