specsolve¶
Solve an optimisation model written in YAML. Attach your data as tables, and keep the solver loaded for quick updates and warm starts.
specsolve builds and solves math-spec
models. The file states the math, and math-spec checks it before any data
exists. specsolve attaches your tables, builds the model on polars, and hands it
to HiGHS, Gurobi or Xpress. The same file can also build a linopy.Model
(linopy).
What it is for¶
- Tables in, tables out. Pass any Arrow table, such as polars, pandas or DuckDB, or a parquet path. Results come back as tables, and an archive keeps the model, its data and its results as parquet, ready for queries, plots or BI. Your data →
- Decomposition built in. One call runs scenario sweeps, rolling horizons and myopic pathways over the same model. Each window is checked against how the model couples before it runs. Sweeps →
- Fast, and hard to get wrong. Tables hold only the rows that exist, so a model's topology does not change its cost. The API is a handful of verbs, and a sweep keeps the solver loaded between runs. There is nothing to tune. Benchmarks →
- Validated against PyPSA. PyPSA's model is one file here, grown rung by rung through storage, unit commitment, multi-period and stochastic runs. All 16 rungs match PyPSA's objective, and 12 match its duals row for row. The PyPSA ladder →
A model is one file¶
# 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)
The math it states¶
Printed from the file above, with no data and no solver. How shows the call.
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',
},
}
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 math_spec latex dispatch.yaml --symbols dispatch.symbols.yaml
python -m math_spec typst dispatch.yaml --standalone -o dispatch.typ
The renderer is math-spec's, and reads the same file this page solves.
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'))
print(result.dual('power_balance'))
Where to next¶
- Run a model: a file and your tables to an answer, in five steps.
- Your data: from the files an instance arrives in to one table per parameter, and what attaching refuses.
- Python API: attach, build, solve and read back, and sweep one model over scenarios.
- The language: what a file may contain, on math-spec's site.
- About: the architecture, the measured cost, and what will never be built.
Install it¶
Installation lists the extras.
Alpha, pre-1.0
Breaking changes land without a deprecation cycle. Pin an exact version if you depend on this, and read the changelog before upgrading. A retired spelling fails at load and names its rewrite. Real models round-trip through solve and are tested against linopy. The accepted surface is not yet frozen.