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 LP 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 loaded Model), 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 lps.load_model.
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.
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 lower_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 one construct parts them: 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.
#894 holds the gap, the
alternatives, and the clear refusal that is to replace the upstream error.
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, and
printing a built row. Both pages say so.
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.