Writing a model¶
Five ideas carry the whole language. Each one is shown below in a model that lives in the repo and is run by the test suite, so nothing here is a snippet that only works on this page.
If you would rather see the machinery than read about it,
python examples/walkthrough.py prints every stage — YAML → schema → AST →
plan → frames → LP text → solution — for one small model.
1. A dimension is declared; its coordinates usually are not¶
dimensions:
snapshot: {dtype: int} # coordinates come from the data
generator: {values: [wind, solar, gas]} # coordinates are given here
A dimension is an axis. You either list its coordinates in the file, or leave
them to be read off whatever data binds — coords={"snapshot": range(6)} at
call time, or the union of what the parameters carry.
One master coordinate set per dimension, resolved before any data binds. Every parameter is reindexed onto it, so two tables that disagree about which snapshots exist is an error you get at load time rather than a silently truncated model.
2. Absence is how you say "sparse"¶
where does not zero a variable out — it means the variable has no column
there at all. A retired generator with p_max = 0 costs nothing to carry in
the data, and the built model is smaller than the coordinate product.
The same idea runs through data binding, with one distinction worth learning early: a variable the mask removed is absent, and a term carrying it takes its whole row with it — while a parameter row that is simply missing is a zero coefficient, and the row survives without it. Absence is a property of variables. → dispatch, SPEC §6
3. A dimension can carry coordinates, and that is your topology¶
dimensions:
generator: {dtype: str, coords: [bus]} # each generator sits on a bus
line: {dtype: str, coords: {from: bus, to: bus}} # both endpoints are buses
- expression: >-
group_sum(p, over=generator, by=bus)
+ group_sum(f, over=line, by=to)
- group_sum(f, over=line, by=from)
== load
group_sum sums along a coordinate, landing the result on the dimension that
coordinate points at. The same f is summed twice through two different
coordinates — once as inflow, once as outflow.
No adjacency matrix and no join written by hand: the network is data on the dimension. → transport
4. roll and shift reach along an axis¶
roll wraps, so the first snapshot reads the last — which is what makes a
battery cyclic without writing the boundary condition out. shift is the same
thing without the wrap: positions translated past the edge contribute nothing.
This is the only construct whose cost is not obviously linear in model size. → storage
5. The dims of an equation must equal its foreach¶
p has dims (snapshot, generator); summing over generator leaves
(snapshot); load has (snapshot). The union is (snapshot), which is what
foreach says — so it compiles.
Get it wrong and you are told at load time, not at solve time. A stray dim
would multiply rows and an unused foreach dim would repeat one row across
them; either way you would build a different model than the file reads as.
→ SPEC §5.2
Then: check, build, solve¶
import lpspec as lps
lps.check('model.yaml') # compiles? no data needed
sol = lps.solve('model.yaml', sources) # to an answer
sol.objective
sol.primal('p') # a polars.DataFrame
sol.dual('power_balance')
lps.check is the CI verb — it parses, expands, resolves and lowers without
binding anything, so a model repository can be validated on every commit
without shipping the data.
Sources accept polars, pandas, pyarrow, or parquet paths — anything exposing
the Arrow PyCapsule protocol, and the recogniser imports none of them.
Results come back as frames; to_pandas, to_dataarray and to_parquet are
the bridges out. → SPEC §8, §10
What it will not do¶
Worth knowing before you start, rather than after:
- Bounds take a name or a number, never arithmetic.
upper: p_maxis fine;upper: -ratingis not. This one has bitten a real port — #31, and the workaround is to ship the negated column as data. - Every expression is affine in the variables. Degree 1, always: no variable times variable. That is the ceiling the whole design is built around, not an unimplemented feature. → The ceiling
- Several plausible features are refused on purpose, with reasons. → ROADMAP
Where next¶
| Models | every model in the repo, and which constructs each exercises |
| SPEC | the reference — what a file may contain, exactly |
| Benchmarks | what it costs, measured against the eager lane |
| ARCHITECTURE | why it is shaped this way |