lpspec¶
Self-documenting optimisation models — at any scale.
Write the math in YAML, bind 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 even uncalled macro template is parsed and name-checked before a single source is bound. Errors name the problem and its rewrite. -
A finite language, with a priced way out
The ceiling is a closure (affine ∩ relational ∩ local), not a feature race. Genuinely unsayable math goes in an
escape:island — visible in the file, billed before it runs. -
Straight to the solver
YAML and data in, a populated solver out, no LP file in between: 2–4x faster than the eager lane on four of five benchmark cases, lower peak memory on all five. The numbers
-
Checked against somebody else
Eleven models in the gallery match an optimum this project did not compute — GAMS, PyPSA, OR-Library, TSPLIB — objectives and shadow prices. The corpus
flowchart LR
Y["YAML + data"] --> AST["core AST"]
AST --> R{"inside the<br/>language?"}
R -->|"yes"| S["relational engine<br/>polars"]
S --> OUT["solver (batched) / LP file"]
R -->|"no"| ERR["load error<br/>naming the construct + rewrite"]
AST -.->|"opt-in shim: same language,<br/>for models already in memory"| 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: {values: [wind, solar, gas]}
parameters:
p_max: {dims: [generator]}
load: {dims: [snapshot]}
cost: {dims: [generator]}
variables:
p:
foreach: [snapshot, generator]
where: "p_max > 0"
bounds: {lower: 0, upper: p_max}
constraints:
power_balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
objectives:
total_cost:
sense: minimize
expression: 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.
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 generator \(g\) in snapshot \(s\) |
Objective¶
total_cost
Subject to¶
power_balance
Variable domains¶
p
\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 generator $g$ in snapshot $s$
\end{description}
\paragraph{Objective}
\begin{align}
\text{total\_cost} && \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 lpspec as lps
symbols = {
'dimensions': {
'snapshot': {'index': 's', 'set': '\\mathcal{S}'},
'generator': {'index': 'g', 'set': '\\mathcal{G}'},
},
'names': {
'cost': 'c',
'load': '\\ell',
'p_max': '\\bar p',
},
'descriptions': {
'snapshot': 'dispatch periods',
'generator': 'generating units',
'p': 'output of generator $g$ in snapshot $s$',
'cost': 'marginal cost',
'load': 'demand to be met',
'p_max': 'installed capacity',
},
}
lps.to_latex('dispatch.yaml', symbols=symbols) # amsmath align
lps.to_typst('dispatch.yaml') # compiles without a TeX toolchain
lps.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.
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:
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]}),
}
result = lps.solve('dispatch.yaml', sources, coords={'snapshot': range(6)})
print(result.objective) # 1920.0
print(result.primal('p')) # a tidy frame: (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 frames, 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¶
-
Writing a model
Five ideas — dimensions, absence, topology,
roll, the dim algebra — each shown in a model that runs, and what the language will not do. -
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.
-
Why it is shaped this way
The hard rules, the expressive ceiling, the module map — and what we have decided never to build.
pip install lpspec # the relational engine (polars, highspy)
pip install "lpspec[linopy]" # adds linopy + xarray + pandas: the shim, the
# oracle, and to_pandas / to_dataarray
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 — breaking commits are marked !,
and every one names the rewrite. What exists is tested; both lanes round-trip
real models through solve, differentially verified against linopy. It is the
surface that is not yet frozen, not the behaviour.