Skip to content

specsolve

Self-documenting optimisation models — at any scale.

Write the math in YAML, attach data at runtime, solve.

PyPI License: MIT Python 3.12+ Ruff

Run a model Browse the examples


  • Declarative math


    Readable without knowing the implementation, and self-contained: no Python state changes what a file means. It diffs cleanly in review and travels as a research artefact.

  • Sparse by construction


    A mask is an absent row, never a NaN in a dense array — a model pays for the variables it has, not for its coordinate product. Labels are the solver's own row and column indices.

  • Fail early, fail loud


    Every expression, where string and uncalled macro template is parsed and name-checked before a single source is attached. Errors name the problem and its rewrite.

  • A finite language, with no escape hatch


    The ceiling is relational, and locality prices a new operator rather than barring it. Math the language cannot express is a gap in the language, and a gap closes as a macro, a primitive or a formulation.

  • Straight to the solver


    YAML and data in, a populated solver out, no LP file in between: 2–4x faster than linopy on four of five benchmark cases, lower peak memory on all five. The numbers

  • Checked against somebody else


    Every ported model matches an optimum this project did not compute — GAMS, PyPSA, OSeMOSYS, OR-Library, TSPLIB — objectives and, where the reference records them, shadow prices. The corpus

flowchart LR
    Y["YAML + data"] --> AST["core AST"]
    AST --> R{"inside the<br/>language?"}
    R -->|"no"| ERR["load error<br/>naming the construct + rewrite"]
    R -->|"yes"| S["relational engine<br/>polars"]
    S --> OUT["solver (batched) / LP file"]
    R -->|"yes, and you asked<br/>for a linopy.Model"| E["specsolve.linopy"]
    E --> LS["linopy.Model → solve"]

    classDef stream fill:#f0f7f0,stroke:#3a7d44,stroke-width:2px,color:#111
    classDef linopylane fill:#eef1fb,stroke:#4a5fc1,stroke-width:2px,color:#111
    class S,OUT stream
    class E,LS linopylane
    class ERR err
    classDef err fill:#fdf3e7,stroke:#b7791f,color:#111

The whole thing, in one model

# dispatch.yaml
dimensions:
  snapshot: {dtype: int}
  generator: {dtype: str}
parameters:
  p_max: {dims: [generator]}
  load:  {dims: [snapshot]}
  cost:  {dims: [generator]}
variables:
  p:
    dims: [snapshot, generator]
    where: "p_max > 0"
    bounds: {lower: 0, upper: p_max}
constraints:
  power_balance:
    dims: [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, with no data and no solver. 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},\ 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\, s \in \mathcal{S} \]

Variable domains

p

\[ 0 \le p_{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{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 mathspec 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',
    },
}

ms.to_latex('dispatch.yaml', symbols=symbols)  # amsmath align
ms.to_typst('dispatch.yaml')  # compiles without a TeX toolchain
ms.to_markdown('dispatch.yaml')  # 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 mathspec latex dispatch.yaml --symbols dispatch.symbols.yaml
python -m mathspec typst dispatch.yaml --standalone -o dispatch.typ

The renderer is mathspec's, and reads the same file this page solves.

Then you solve it

import specsolve as sps, polars as pl

generators = ['wind', 'solar', 'gas']
sources = {
    'p_max': pl.DataFrame({'generator': generators, 'value': [100.0, 60.0, 200.0]}),
    'cost': pl.DataFrame({'generator': generators, 'value': [1.0, 2.0, 50.0]}),
    'load': pl.DataFrame({'snapshot': range(6), 'value': [80.0, 120.0, 150.0, 180.0, 140.0, 100.0]}),
    'snapshot': range(6),
    'generator': generators,
}

result = sps.solve('dispatch.yaml', sources)
print(result.objective)  # 1920.0
print(result.primal('p'))  # a tidy table: (snapshot, generator, value)
print(result.dual('power_balance'))  # the price at each snapshot

Sources can also be pandas or pyarrow objects, or parquet paths — anything exposing the Arrow PyCapsule protocol is accepted, and the recogniser imports none of them. Results come back as tables, so nothing has to be released and no dataframe library is a dependency: result.to_pandas('p'), .to_dataarray('p') and .to_parquet(dir) are the bridges out, each named for what it costs.

Where to next

  • Run a model


    A file and your tables to an answer you can read back, in five steps: install, check, attach, solve, read.

    The guide

  • Your data


    The recipe from the files an instance arrives in to one table per parameter, and the contract for what attaching accepts and refuses.

    Preparing the data · The contract

  • Models


    Every model in the repo, what each exercises, and which ones are checked against an optimum from elsewhere.

    The gallery

  • Language reference


    What a YAML file may contain, and what it means — ten rules, ten declaration keys, one closed set of operators.

    The language

  • Python API


    Attach data, build, solve and read the answer back. Sweep the same model over scenarios or a rolling horizon.

    The API · Sweeps · Typeset

  • Why it is shaped this way


    The hard rules, the expressive ceiling, the measured cost, the module map, and what will never be built.

    About

pip install specsolve  # the relational engine (polars, highspy)
pip install "specsolve[linopy]"  # adds linopy + xarray + pandas: the lane, the
                              # oracle, and to_pandas / to_dataarray
pip install "specsolve[gurobi]"  # adds the gurobi sink: solver_name='gurobi'
pip install "specsolve[xpress]"  # adds the xpress sink: solver_name='xpress'

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 — every entry links the PR that describes the break, and a retired spelling fails at load naming its rewrite rather than drifting on silently. What exists is tested: real models round-trip through solve, differentially verified against linopy. It is the surface that is not yet frozen, not the behaviour.