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:
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)
What that file says#
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\) | p_max over \(\mathcal{G}\) — installed capacity |
| \(\ell\) | load over \(\mathcal{S}\) — demand to be met |
| \(c\) | cost over \(\mathcal{G}\) — marginal cost |
Variables#
| Symbol | Meaning |
|---|---|
| \(p\) | p over \(\mathcal{S} \times \mathcal{G}\) — output of a generator in a snapshot |
Objective#
Subject to#
power_balance
Variable domains#
p
\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{p\_max} 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[{$p$}] \texttt{p} 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}} p_{s,g} \cdot c_{g}
\end{align*}
\paragraph{Subject to}
\begin{align*}
\text{power\_balance} && \sum_{g \in \mathcal{G}} p_{s,g} & = \ell_{s} && \forall\, s \in \mathcal{S}
\end{align*}
\paragraph{Variable domains}
\begin{align*}
\text{p} && 0 \le p_{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',
'p_max': '\\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 is optional — drop it and the same model prints as
\(\mathit{load}_t\), \(p^{\mathrm{max}}_g\). A dict, a YAML path or a
SymbolTable; a key naming nothing in the model is an error, not a symbol that
silently never applies. Every spelling is printed verbatim — notation says
which language they are, and a render in the other one refuses.
Or from a shell, where the table is that same YAML on disk and --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
The renderer is the typesetter, and it reads the same file every other page here loads.
How a tool reads it#
import math_spec as ms
spec = ms.to_spec('dispatch.yaml') # schema, names, dims, 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 second.
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, eleven 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.