lpspec¶
Self-documenting optimisation models — at any scale.
Write the math in YAML, attach data at runtime, solve.
-
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,
wherestring 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["lpspec.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¶
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.
Then you solve it¶
import lpspec as lps, 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 = lps.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.
-
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.
-
Models
Every model in the repo, what each exercises, and which ones are checked against an optimum from elsewhere.
-
Language reference
What a YAML file may contain, and what it means — ten rules, ten declaration keys, one closed set of operators.
-
Python API
Attach data, build, solve and read the answer back. Sweep the same model over scenarios or a rolling horizon.
-
Why it is shaped this way
The hard rules, the expressive ceiling, the measured cost, the module map, and what will never be built.
pip install lpspec # the relational engine (polars, highspy)
pip install "lpspec[linopy]" # adds linopy + xarray + pandas: the lane, the
# oracle, and to_pandas / to_dataarray
pip install "lpspec[gurobi]" # adds the gurobi sink: solver_name='gurobi'
pip install "lpspec[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.