Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ docs/reference/notation.md
# 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.
# `tools/home_math.py` needs no entry, and takes both of the other ways out:
# it emits the blank lines around its markers that prettier wants, and the
# block of tables it writes into `README.md` sits inside a
# `<!-- prettier-ignore-start -->` range there. A range is what a file whose
# generated part is a block among hand-written prose wants — the pages above
# are generated nearly end to end, so listing them is the shorter answer.
docs/reference/language/operators.md
240 changes: 205 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ with no data and no solver.**

A math-spec file declares four things: the axes the model runs over, such as
`snapshot` and `generator`; the data it expects, such as `load` and `cost`; the
decisions the solver makes, such as `dispatch`; and the rules those decisions obey, such
as `sum(dispatch, consume=generator) == load`. The file [below](#example) is a complete
model.
decisions the solver makes, such as `dispatch`; and the rules those decisions
obey, such as `sum(dispatch, over=generator) == load`. The file
[below](#example) is a complete model.

math-spec reads that file, checks everything that can be checked without data,
and hands the result on: to an engine that builds and solves the model, or to the
Expand Down Expand Up @@ -79,69 +79,213 @@ dimensions:
generator: { description: generating units }

parameters:
p_max: { dims: [generator], description: installed capacity }
capacity: { dims: [generator], description: installed capacity }
load: { dims: [snapshot], description: demand to be met }
cost: { dims: [generator], description: marginal cost }

variables:
p:
dispatch:
description: output of a generator in a snapshot
dims: [snapshot, generator]
where: "p_max > 0"
bounds: { lower: 0, upper: p_max }
where: "capacity > 0"
bounds: { lower: 0, upper: capacity }

constraints:
power_balance:
dims: [snapshot]
expression: sum(p, over=generator) == load
expression: sum(dispatch, over=generator) == load

objective:
sense: minimize
expression: sum(p * cost)
expression: sum(dispatch * cost)
```

<!--- --8<-- [end:model] -->

That file is a complete model. Nothing outside it changes what it means, and
everything about it that can be wrong is wrong at load:
That file is a complete model. Nothing outside it changes what it means.

<!--- --8<-- [start:load] -->
### The math it prints

```python
import math_spec as ms
Here is that model as math, printed from the file above and nothing else. No
data, no solver, and no second copy of the equations to keep in step. Markdown
is one of three formats, so GitHub renders it here.

spec = ms.to_spec('dispatch.yaml') # schema, names, dims, degree — all checked here
sorted(spec.variables) # ['dispatch']
<!-- Prettier pads the legend tables that the generator emits unpadded, so the
two would rewrite each other forever. The range keeps this file formatted
and the block below byte-for-byte what the typesetter printed. -->
<!-- prettier-ignore-start -->
<!-- readme-math:begin -->

program = ms.to_program(spec) # curves expanded, names typed, operators resolved to nodes
sorted(program.constraints) # ['power_balance']
Least-cost dispatch of a generator fleet against an hourly load.

#### Objective

```math
\min \sum_{t \in \mathcal{T},\ g \in \mathcal{G}} \mathit{dispatch}_{t,g} \cdot \mathrm{cost}_{g}
```

Neither needs data or a solver, so a repository of models compiles in CI with
nothing bound to any of them. **A `Spec` holds the file as written, and a
`Program` holds the model it builds**, with every macro expanded and every curve
turned into its variables and constraints. An engine reads the second.
#### Subject to

<!--- --8<-- [end:load] -->
**`power_balance`**

[Reading a loaded model](docs/reference/language/reading.md) says what a tool
gets from each.
```math
\sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
```

#### Variable domains

**`dispatch`**

```math
0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
```

<details>
<summary>The whole document: a symbol table, and the legend it prints</summary>

The same `spec` prints as math. It is read and checked once, then printed three
ways:
Least-cost dispatch of a generator fleet against an hourly load.

#### Sets

| Symbol | Meaning |
|---|---|
| $`\mathcal{S}`$ | index $`s`$ — `snapshot` — dispatch periods |
| $`\mathcal{G}`$ | index $`g`$ — `generator` — generating units |

#### Parameters

| Symbol | Meaning |
|---|---|
| $`\bar p`$ | `capacity` over $`\mathcal{G}`$ — installed capacity |
| $`\ell`$ | `load` over $`\mathcal{S}`$ — demand to be met |
| $`c`$ | `cost` over $`\mathcal{G}`$ — marginal cost |

#### Variables

| Symbol | Meaning |
|---|---|
| $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |

#### Objective

```math
\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}
```

#### Subject to

**`power_balance`**

```math
\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}
```

#### Variable domains

**`dispatch`**

```math
0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0
```

</details>

<details>
<summary>The same document as LaTeX</summary>

```latex
\noindent Least-cost dispatch of a generator fleet against an hourly load.

\paragraph{Sets}
\begin{description}
\item[{$\mathcal{S}$}] index $s$ --- \texttt{snapshot} --- dispatch periods
\item[{$\mathcal{G}$}] index $g$ --- \texttt{generator} --- generating units
\end{description}

\paragraph{Parameters}
\begin{description}
\item[{$\bar p$}] \texttt{capacity} over $\mathcal{G}$ --- installed capacity
\item[{$\ell$}] \texttt{load} over $\mathcal{S}$ --- demand to be met
\item[{$c$}] \texttt{cost} over $\mathcal{G}$ --- marginal cost
\end{description}

\paragraph{Variables}
\begin{description}
\item[{$\mathit{dispatch}$}] \texttt{dispatch} over $\mathcal{S} \times \mathcal{G}$ --- output of a generator in a snapshot
\end{description}

\paragraph{Objective}
\begin{align*}
&& \min & \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}
\end{align*}

\paragraph{Subject to}
\begin{align*}
\text{power\_balance} && \sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} & = \ell_{s} && \forall\, s \in \mathcal{S}
\end{align*}

\paragraph{Variable domains}
\begin{align*}
\text{dispatch} && 0 \le \mathit{dispatch}_{s,g} & \le \bar p_{g} && \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0
\end{align*}
```

</details>

<details>
<summary>The same document as Typst, printed with no symbol table</summary>

```typst
Least-cost dispatch of a generator fleet against an hourly load.

== Sets
/ $cal(T)$: index $t$ --- `snapshot` --- dispatch periods
/ $cal(G)$: index $g$ --- `generator` --- generating units

== Parameters
/ $upright("capacity")$: `capacity` over $cal(G)$ --- installed capacity
/ $upright("load")$: `load` over $cal(T)$ --- demand to be met
/ $upright("cost")$: `cost` over $cal(G)$ --- marginal cost

== Variables
/ $italic("dispatch")$: `dispatch` over $cal(T) times cal(G)$ --- output of a generator in a snapshot

Upright is what the model is given --- a parameter such as $upright("capacity")$, a coordinate map, a label --- and italic is what the solver chooses, such as $italic("dispatch")$. An index is italic too, being what a quantifier chooses, and a set is script.

== Objective
$ & min & sum_(t in cal(T), g in cal(G)) italic("dispatch")_(t,g) dot upright("cost")_(g) $

== Subject to
$ upright("power_balance") & sum_(g in cal(G)) italic("dispatch")_(t,g) & = upright("load")_(t) & forall t in cal(T) $

== Variable domains
$ upright("dispatch") & 0 <= italic("dispatch")_(t,g) & <= upright("capacity")_(g) & forall t in cal(T), g in cal(G) colon upright("capacity")_(g) > 0 $
```

</details>

<!-- readme-math:end -->
<!-- prettier-ignore-end -->

Each format is one call, and the file is read and checked once:

```python
symbols = 'dispatch.symbols.yaml' # optional: a dict, a path, or a SymbolTable
import math_spec as ms

ms.to_latex(spec, symbols=symbols) # amsmath align
spec = ms.to_spec('dispatch.yaml')

ms.to_markdown(spec) # renders as-is on GitHub, as above
ms.to_latex(spec) # amsmath align
ms.to_typst(spec) # compiles without a TeX toolchain
ms.to_markdown(spec) # renders as-is on GitHub
```

Drop the symbol table, and the same model prints as $\mathit{load}_t$ and
$dispatch^{\mathrm{max}}_g$, with no setup. Every spelling in a table is printed as
written, a key naming nothing in the model is an error, and nothing in a table
changes what the file means.
Those symbols are the file's own names: `load` prints as $`\mathrm{load}_t`$,
and `capacity` as $`\mathrm{capacity}_g`$. Nothing had to be set up for
that. Pass `symbols='dispatch.symbols.yaml'` and the typesetter prints
$`\ell_t`$ and $`\bar p_g`$ instead, above a legend that defines them. The
first folded block shows it. The table can be a dict, a `SymbolTable`, or a
path to YAML. A key that names nothing in the model is an error, and nothing
in a table changes what the file means.

Or from a shell, beside `pdflatex` in a Makefile:

Expand All @@ -151,6 +295,32 @@ python -m math_spec typst dispatch.yaml --standalone -o dispatch.typ
python -m math_spec markdown dispatch.yaml
```

### `Spec` and `Program`

<!--- --8<-- [start:load] -->

Whatever is wrong with a model is wrong when it loads, not when it solves:

```python
import math_spec as ms

spec = ms.to_spec('dispatch.yaml') # schema, names, dimensions, degree: all checked here
sorted(spec.variables) # ['dispatch']

program = ms.to_program(spec) # curves expanded, names typed, operators resolved to nodes
sorted(program.constraints) # ['power_balance']
```

Neither needs data or a solver, so a repository of models compiles in CI with
nothing bound to any of them. **A `Spec` holds the file as written, and a
`Program` holds the model it builds**, with every macro expanded and every curve
turned into its variables and constraints. An engine reads the `Program`.

<!--- --8<-- [end:load] -->

[Reading a loaded model](docs/reference/language/reading.md) says what a tool
gets from each.

## Why

- **Declarative math.** A file is readable without knowing any implementation,
Expand Down Expand Up @@ -211,7 +381,7 @@ through are a dependency rather than one engine's internals. The keys themselves
which are YAML math, a block per component, `dims:` and a `where:` string,
come from [Calliope](https://github.com/calliope-project/calliope).
[linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
`sum(consume=)` and the dimension rules are named against. Issue numbers in these
`sum(over=)` and the dimension rules are named against. Issue numbers in these
pages point at lpspec, where the arguments happened.

## Status
Expand Down
Loading