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 at load, 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. 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 where string 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#

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:
  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#

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

Subject to#

power_balance

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

Variable domains#

dispatch

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

    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 one system.

    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


    What an engine, a renderer or a checker gets when it loads a model, and which of the two objects each should read.

    Reading a loaded model · Python API

  • 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.

    The limits

  • Who decides what


    Which decisions the language makes for every tool that reads a file, and which each engine makes for itself.

    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 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.