Skip to content

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"

variables:
  p:
    foreach: [snapshot, generator]
    where: "p_max > 0"

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

- expression: soc == roll(soc, snapshot=1) + charge * 0.9 - discharge

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

constraints:
  power_balance:
    foreach: [snapshot]
    expression: sum(p, over=generator) == load

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_max is fine; upper: -rating is 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