math-spec#
The language an optimisation model is written in, and the math it means.
Write the math in YAML. Everything decidable without data is decided at load, and the file prints as the math it stands for.
-
Declarative math
One file declares the axes, the data, the decisions and the rules. You can read it without knowing what builds it, and no Python state changes what it means. It diffs in review, and it travels as a research artefact.
-
Decided before the data
Every expression, every
wherestring and every macro template, called or not, is parsed and name-checked at load. A repository of models compiles in CI with no data bound to any of them. -
Fail early, fail loud
Nothing is guessed and nothing falls back silently. Where a file does not decide the answer, loading fails, and the message names the construct and its rewrite.
-
A closed language
The operators are a fixed set, and nothing can register another one. A composition of them is a macro. Math the language cannot express is refused, with the rewrite named.
-
The file is the document
LaTeX, Typst or Markdown, printed from the file alone. No data, no solver, and no second source of truth. It answers does this YAML say what I meant before anything is bound or solved.
-
One answer per question
An engine, a renderer and a checker read the same file. Wherever they could disagree about what it means, the language decides once, and all three read the answer. What each solver can take, each engine decides for itself.
flowchart LR
Y["model.yaml"] --> S["schema<br/>closed at every level"]
S --> AST["syntax tree<br/>two grammars"]
AST --> Q{"inside the<br/>language?"}
Q -->|"no"| ERR["load error<br/>naming the construct + rewrite"]
Q -->|"yes"| M["Spec<br/>what the file says"]
M -->|"to_program"| P["Program<br/>names, dimensions and operators resolved"]
P --> ENG["an engine that builds → solver"]
M --> T["to_latex / to_typst / to_markdown"]
classDef spec fill:#f0f7f0,stroke:#3a7d44,stroke-width:2px,color:#111
classDef consumer fill:#eef1fb,stroke:#4a5fc1,stroke-width:2px,color:#111
classDef err fill:#fdf3e7,stroke:#b7791f,color:#111
class S,AST,M,P spec
class ENG,T consumer
class ERR err
The whole thing, in one model#
description: Least-cost dispatch of a generator fleet against an hourly load.
dimensions:
snapshot: { dtype: int, description: dispatch periods }
generator: { description: generating units }
parameters:
capacity: { dims: [generator], description: installed capacity }
load: { dims: [snapshot], description: demand to be met }
cost: { dims: [generator], description: marginal cost }
variables:
dispatch:
description: output of a generator in a snapshot
dims: [snapshot, generator]
where: "capacity > 0"
bounds: { lower: 0, upper: capacity }
constraints:
power_balance:
dims: [snapshot]
expression: sum(dispatch, over=generator) == load
objective:
sense: minimize
expression: sum(dispatch * cost)
The math it prints#
Generated from the YAML above, with no data and no solver. Only the notation is a choice, and How shows the one made here.
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#
Subject to#
power_balance
Variable domains#
dispatch
\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*}
import math_spec as ms
symbols = {
'notation': 'latex',
'dimensions': {
'snapshot': {'index': 's', 'set': '\\mathcal{S}'},
'generator': {'index': 'g', 'set': '\\mathcal{G}'},
},
'names': {
'cost': 'c',
'load': '\\ell',
'capacity': '\\bar p',
},
}
spec = ms.to_spec('dispatch.yaml') # read and checked once, then printed three ways
ms.to_latex(spec, symbols=symbols) # amsmath align
ms.to_typst(spec) # compiles without a TeX toolchain
ms.to_markdown(spec) # renders as-is on GitHub
symbols gives every name its conventional spelling. Pass a dict, a YAML path
or a SymbolTable. It is optional: drop it and the same model prints from the
names in the file, as \(\mathrm{load}_t\) and \(\mathrm{capacity}_g\).
Or from a shell, where the table is that same YAML on disk. --standalone emits
a document that compiles, rather than a fragment to \input:
python -m math_spec latex dispatch.yaml --symbols dispatch.symbols.yaml
python -m math_spec typst dispatch.yaml --standalone -o dispatch.typ
Typeset the math documents the three functions, their options and symbol tables. Each reads the same file every other page here loads.
Spec and Program#
Whatever is wrong with a model is wrong when it loads, not when it solves:
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.
Reading a loaded model says what an engine, a renderer or a checker gets when it loads a model.
Where to next#
-
The language
What a YAML file may contain, and what it means: ten rules, ten declaration keys, one closed set of operators.
-
Every construct, as math
All of it at once, beside the notation the typesetter gives it, so the notation can be read as one system.
-
Typeset the math
LaTeX, Typst and Markdown, the options each takes, and how a symbol table turns derived symbols into conventional ones.
-
Reading a loaded model
What an engine, a renderer or a checker gets when it loads a model, and which of the two objects each should read.
-
What may enter the language
The test a new operator has to pass, why a solver's own limits stay out of the language, and what has been refused and why.
-
Who decides what
Which decisions the language makes for every tool that reads a file, and which each engine makes for itself.
Install it#
git clone https://github.com/energy-models/math-spec
cd math-spec
pixi run pre-commit-install
pixi run test
Or as a dependency, once the project leaves the alpha stream. See installation for every package manager.
Alpha, pre-1.0
Breaking changes land without a deprecation cycle. When a construct is named wrong, a default is wrong, or a permissive input hides a silent wrong answer, it is fixed rather than aliased. A compatibility shim for every earlier spelling would defeat the point of a small language.
Pin an exact version if you depend on this, and read the changelog before upgrading. What exists is tested: every construct the language has round-trips through the schema, the parsers and all three typeset formats, and the LaTeX is compiled rather than eyeballed. It is the accepted YAML that is not yet frozen, not the behaviour.