Skip to content

Relationship to linopy

Everything about linopy in one place, because it is otherwise the kind of thing that gets mentioned everywhere and explained nowhere. Three separate relationships, and conflating them is what made the rest of the docs noisy:

What Where it matters
Not a dependency solving a model never imports it packaging
The oracle how we know the answers are right testing
The lane the second thing a file can be built as what a caller chooses

1. It is not a runtime dependency

lps.solve, lps.build, lps.write and lps.check go YAML → polars → HiGHS or file, and import nothing from linopy, xarray or pandas. CI proves it: the bare-install job runs the whole suite with none of them present.

pip install "lpspec[linopy]" adds linopy, xarray and pandas, which buys two things and nothing else — the lane below, and the to_pandas / to_dataarray bridges out of a result. The lane is a peer, not a fallback: nothing routes to it, and a bare install is a complete one.

Nothing a bare install can reach names linopy, including in a traceback. The public exception tree is rooted at LpspecError, with no alias (#389) — a name from this extra has no business reaching a caller who never installed it.

2. It is the oracle

Correctness here is not "the tests pass"; it is the same YAML, built both ways, produces the same model. The differential suite builds a model through the relational engine and through linopy, and compares.

That is only meaningful because both paths consume the same resolved AST and neither may hold its own opinion about what a name means — the narrow waist in the architecture notes. If they resolved names independently, the suite would be comparing two dialects rather than checking one language.

It also has a known blind spot, which is why the model gallery exists: a shared misreading passes the differential suite green. Only an outside published optimum catches that, and docs/examples/index.md is where those live.

Where a concept is already linopy's, we copy its name — solve statuses, status / termination_condition as two axes with is_ok as the rollup, the shape of a result. Our audience arrives from linopy and PyPSA, and a second vocabulary for one fact is a tax on all of them. But copy it, do not import it: the engine may not import linopy, so the tables live here and a test imports linopy to assert the copy still matches. A copy nobody checks is a copy that rots.

3. It is a lane

The same file, built as a linopy.Model instead of bound relationally — the caller picks the lane by an import, and the call is the one lps.build takes: same first argument (a path, a mapping, or a model the language has already read), same sources, same index sources.

from lpspec import linopy as lpspec_linopy

m = lpspec_linopy.build('model.yaml', {...})  # -> linopy.Model
m.solve(...)
lpspec_linopy.expression(m, 'model.yaml', 'co2', {...})  # a named quantity, read back

Both are pure: YAML in, a model or a value out, nothing retained. build returns a plain linopy.Model — no accessor, no attached schema, no patched attributes — so nothing is lost across pickle, deepcopy or to_netcdf. To inspect the math, re-read the file with to_spec. expression is the reader the same purity forces to take sources again: it evaluates a declared named expression (named expressions) on the solved model and hands back linopy's native .solution — the eager half of result.expression(name), so the differential suite can hold the two lanes to one answer.

This lane constructs; it does not attach. Math for a linopy.Model something else built — a PyPSA network, say — had a verb here and no longer does (#845): it was the one file allowed to reference names it did not declare, and paying for that exception across the whole language layer bought one use case. Build a second model and merge it.

What a construct becomes

The whole translation, in one place — what lpspec.linopy.build calls for each thing a file can say. linopy/builder.py is where each row lives, one section per group below.

Declaration linopy
variables: Model.add_variables(lower, upper, coords, name, mask, binary, integer)
sos: Model.add_sos_constraints(variable, sos_type, sos_dim, big_m) — the block handed over, not a formulation rebuilt
constraints: Model.add_constraints(lhs, sign, rhs, name, mask), one rule per declaration
objective: Model.add_objective(expr, sense), each additive term summed over the dims it carries
expressions: evaluated on the solved model, and linopy's own .solution handed back
In an expression linopy or xarray
x — a variable Model.variables['x'], .fillna(0) under absence: zero
p — a parameter its xr.DataArray, .fillna(0.0) where it stands as a coefficient
+ - * / the Python operators linopy overloads
sum(x, over=t) .sum('t')
sum(x, by=lk) the lookup attached as a coordinate, then .groupby(), reindexed onto the target dimension's declared labels — one key per lookup, so by=[lk1, lk2] groups by both at once
at(p, by=lk) .sel({into: lookup}) — xarray's vectorised selection is the pullback, and one entry per lookup reads a tuple of labels at once
shift(x, over=t, offset=n) .shift({t: n}); .roll({t: n}) under edge: wrap; a .sel() gather where the offset differs per entity or by= groups it
sum_back(x, over=t, within=w) a sum of w scalar gathers, each unreachable position contributing zero; under by= each gather reads inside the group, so the window stops at its edge
A where: linopy
on a declaration the mask= argument — a mask that excludes nothing is passed as None
defined(x) Model.variables['x'].labels != -1, linopy's own marker for an absent slot
a comparison the Python comparison operators element-wise, absence reading as false

Absence is the one thing with no single row: it is positional, so a missing parameter row is zero in a coefficient, an error in bounds:, and false in a where operand. linopy/absence.py holds all four spellings together, and the builder calls them qualified — absence.coefficient(...) — so a reader meets the name at the call rather than only at the definition.

The same language, and the same data

The lane accepts exactly the same language — that equality is what makes the oracle an oracle, and it is now structural: both run the same to_program gate, so a construct one refuses the other refuses in the same sentence, never with a redirection to the other lane.

Accepting is not building, and two constructs part them — one in each direction. Neither is a language limit: both files pass check, and each is built by the lane the other cannot.

The first is this lane's: an objective carrying a constant. linopy.Objective's expression setter rejects any expression whose const is nonzero — "Constant values in objective function not supported." — and there is no slot to put one in, which is why PyPSA carries n.objective_constant out of band. So a model like examples/ports/osemosys_utopia.yaml, whose objective owes a fixed cost on capacity that already stood in 1990, builds relationally and raises linopy's ValueError on this lane. Dropping the constant is the one repair that must not happen: the lane is the oracle, so a quietly shortened objective would recalibrate every differential test on such a model to the wrong number. Adding it back as a variable pinned to [1, 1] reaches the right answer and was refused too — it puts a column on the caller's model that the other lane does not have. So the lane says it in its own words: builder.py checks for a constant before linopy is asked and raises LaneError, naming the wall and the route that does build the model. tests/test_corpus_parity.py carries the strict xfail, typed to that error rather than to any ValueError, so the day linopy grows a slot it XPASSes and the check comes out with it (#894).

The second is the relational lane's, and it is the mirror: an operator acting along a dimension a constant part does not carry, beside a term that does — say sum(x * k + d, over=t) where d is a scalar. That lane compiles a constant part as its own frame, so a fragment with no rows for t has no slots for the operator to act on, and under a mask which slots those are is known only to the rows. This lane has no such split — the operand is one masked expression, so the constant is dropped wherever the term is — and so it builds the file as written.

It is one wall, reached by all four operators that act along a dimension (sum(over=), sum(by=), shift, sum_back), which is why they share a refusal rather than each wording its own: a fix for one that left the others would be a fix for a symptom. The relational lane names the rewrite that reaches the same number — declare the parameter over the dimension and supply it there (#1137).

Finding that wall is what turned up a real disagreement behind it: sum_back read a constant at a slot the variable was absent from, where every other operator drops it, so the two lanes answered 2.5 and 3.0 on a file neither refused. A reduction consumes its operand before any row exists, so absence has to be pushed into the operand first — sum and sum(by=) did that and the window did not. Fixed by giving the window the same pass, with a differential test over every operator that moves along a dimension (#1142).

Both are worth reading twice, because the shape is easy to mistake for a language limit and is not one: a LaneError names the wall and the route around it, which is the difference between the two classes.

It takes the same data too, which it did not always (#60). A parameter is a parquet path, any table exporting the Arrow PyCapsule protocol, a pd.Series carrying its dims in an index, a dict or a sequence over one dimension, or one number spread over the coordinates it covers. Neither reads an xr.DataArray: this package reads tables and hands arrays back. A dimension index is any of those tables too, under the dimension's own key in sources, and labels come from sources or from what the file declares — exactly one of the two, since a dimension the file declares and the caller also supplies is refused by both lanes in the same sentence. A dimension with none of the three has no index, and is refused in the same sentence again rather than derived from the parameters that span it: a parameter carries a label, never the set of labels that exist, nor what a label maps to. The index is also what fixes the order, so pass one wherever order matters.

So one sources mapping goes to either, and which lane builds a file is decided by an import and nothing else.

What we deliberately do not take

Array operations (merge, reindex, stack), the Python modeling API, and the solver layer. The first is data prep (the limits), the second is hard rule 5 — the model is the file you review and diff — and the third is #106, where we adopt linopy's design for declared solver capabilities without adopting its code.

The modeling API is the one a reader arriving from linopy misses first, and what replaces it is two notebook pages: Change a model for the loops — rebind for new numbers, a longer table for more rows, a patched dict for new math — and Fix, relax, remove for the verbs, which are the same loops aimed at fix, relax and remove_constraints. What neither replaces is the debugging: an IIS. Both pages say so — a built row is read with row, in linopy's own form.

Where linopy is genuinely ahead, and why none of it is a ceiling question, is the honest snapshot in the roadmap.

What is owed to linopy rather than merely true of it — and the same for Calliope, whose math language this surface is derived from — is prior art and credit.