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 the product path never imports it packaging
The oracle how we know the answers are right testing
The shim an opt-in way to put YAML math on a linopy.Model a narrow, real use case

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 shim below, and the to_pandas / to_dataarray bridges out of a result.

The last leak is closed. The public exception tree used to be rooted at LinopyYamlError, left over from when this package was called linopy-yaml, so every traceback on the pure-polars path named a library that path never loads. It is LpspecError now, with no alias (#389).

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 ARCHITECTURE. 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/models/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. The shim

For math that belongs on a linopy.Model already in memory — a PyPSA network, say, where the model is built by something else and you want to add declared constraints to it.

from lpspec import linopy as lpspec_linopy

m = lpspec_linopy.build('model.yaml', data={...}, coords={...})  # -> linopy.Model
lpspec_linopy.extend(m, 'ramp.yaml', data={...})  # mutates m in place

Both are pure producers: YAML in, model 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_schema.

extend may reference variables already on the model (they come from the model argument, not from Python-side history), while the YAML must still declare every parameter and dimension it uses — the declaration is required, the values: are not, since they can come from the model. Coords precedence for extend: the coords= kwarg, then coords inferred from the model's variables, then values: in the YAML, then error. A values: contradicting the model's existing coordinate is an error, not a silent override.

The same language, different data inputs

The shim accepts exactly the same language — that equality is what makes the oracle an oracle, and a construct outside the language is a load error naming the rewrite, never a redirection to the other path.

What differs is what each will take as data, which is a wart rather than a design (#60):

product path (sources=) shim (data= / coords=)
dimension labels sources, then coords=, then values:, then derived from the parameter tables coords=, then values:, then error — no derivation
a parameter parquet path, or any table exporting the Arrow PyCapsule protocol; int/float for 0-D int/float (broadcasts freely), dict / pd.Series for 1-D, pd.DataFrame for 2-D, xr.DataArray directly
unnamed index levels bind positionally to the declared dims; named levels bind by name

The derivation row is the one that bites: on the product path a dimension some parameter already spans needs no second declaration, but it costs the declared order, which shift reads positionally — so pass an explicit index whenever order matters. A dimension declaring coords cannot be derived at all, since derivation reads index columns only.

What we deliberately do not take

Array operations (merge, reindex, stack), the Python modeling API, and the solver layer. The first is data prep (SPEC §11), 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.

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