Skip to content

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 without data — and the file prints as the math it stands for.

CI conda-forge pypi-version python-version Documentation build status

Read the language See every construct as math


  • Declarative math


    One file declares the axes, the data, the decisions and the rules. Readable without knowing any implementation, and self-contained: no Python state changes what it means. It diffs cleanly in review and travels as a research artefact.

  • Decided before the data


    Every expression, every where string and even an uncalled macro template is parsed and name-checked at load. A repository of models compiles in CI with nothing bound to any of them.

  • Fail early, fail loud


    Nothing is guessed and nothing falls back silently. Where a file does not determine the answer, loading fails and the message names the construct and its rewrite.

  • A finite language, with a priced way out


    The ceiling is a closure — relational ∩ local — not a feature race. Genuinely unsayable math goes in an escape: island: visible in the file, billed before it runs.

  • The file is the document


    LaTeX, Typst or Markdown, printed from the file alone. No data, no solver, no second source of truth — the cheapest review tool there is for does this YAML say what I meant.

  • One rule per question


    A rule is language iff two consumers answering it separately would be a bug. That test is what decides who owns a question — the language, or the engine reading it.

flowchart LR
    Y["model.yaml"] --> S["schema<br/>closed at every level"]
    S --> AST["core AST<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, dims and operators resolved"]
    P --> ENG["a consumer → 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#

dispatch.yaml
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)

And that file says, exactly this#

Generated from the YAML above — no data, no solver, no second source of truth. Only the notation is a choice, and How shows the one that was 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#

\[\min \sum_{s \in \mathcal{S},\enspace g \in \mathcal{G}} p_{s,g} \cdot c_{g}\]

Subject to#

power_balance

\[\sum_{g \in \mathcal{G}} p_{s,g} = \ell_{s} \qquad \forall\thinspace s \in \mathcal{S}\]

Variable domains#

p

\[0 \le p_{s,g} \le \bar p_{g} \qquad \forall\thinspace s \in \mathcal{S},\enspace g \in \mathcal{G} \thinspace:\thinspace \bar p_{g} > 0\]
\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.

And a consumer reads it like this#

import math_spec as ms

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

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

Neither needs data or a solver: a repository of models can be compiled in CI with nothing bound to any of them. The two states are the whole seam — Spec is what the file says, Program is what it means — and a consumer that builds reads the second.

That seam is one page, and it is the whole of it: what a program gets when it loads a model, and nothing a program does changes what the file means.

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.

    The language

  • Every construct, as math


    All of it at once, beside the notation the typesetter gives it — so the notation can be read as the one system it has to be.

    The notation

  • Typeset the math


    LaTeX, Typst and Markdown, the options each takes, and how a symbol table turns derived symbols into conventional ones.

    Typeset

  • Reading a loaded model


    The contract between the language and anything that reads the AST — a solver backend, a renderer, a second front end.

    The seam · Python API

  • What may enter the language


    The test a candidate primitive has to pass, why capability is a second axis, and what has been refused and why.

    The ceiling

  • Who owns a rule


    A rule is language iff two consumers answering it separately would be a bug — and the sharp edge that keeps that from swallowing everything.

    What counts as language

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 turns out to hide a silent wrong answer, it gets fixed rather than aliased — carrying a compatibility shim for every earlier spelling would defeat the point of a small language.

In practice: 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 surface that is not yet frozen, not the behaviour.