Skip to content

Build models from math-spec programs, fold named expressions, round trip through netcdf - #922

Draft
FabianHofmann wants to merge 9 commits into
spec-binderfrom
spec-builder
Draft

Build models from math-spec programs, fold named expressions, round trip through netcdf#922
FabianHofmann wants to merge 9 commits into
spec-binderfrom
spec-builder

Conversation

@FabianHofmann

@FabianHofmann FabianHofmann commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Steps 3 and 4 of the plan in #919, stacked on #921: build an empty linopy model from a math-spec YAML plus data, read named expressions back numerically after solve, and carry the spec through netcdf and Model.copy().

Note

The following content was generated by AI.

Changes proposed in this Pull Request

lpspec's linopy lane (fluxopt/lpspec#1518) ported into linopy/spec/ on top of the binder from #921, typed, wired to Bound and SpecDataError.

  • Model.add_spec(spec, sources, retain="report") builds variables, SOS, constraints and objective into an empty model. spec is a path, YAML text, dict or math_spec.Spec, never a Program. Model.from_spec(spec, sources, retain=..., **model_kwargs) is sugar over Model(**model_kwargs).add_spec(...). Spec-built models are v1; add_spec raises under legacy semantics or on a non-empty model.
  • model.spec (ModelSpec) holds the Program, the spec text and reads coordinates, lookups and retained parameters back from model.parameters. model.spec.expressions[name] folds a named expression numerically after solve, substituting Variable.solution and retained parameters. model.spec.evaluate(name, sources) rebinds from fresh sources for retain="none", refusing labels the model was not built on.
  • Modules: builder (declarations, fold), operators (sum_over, grouped_sum, at, shift, sum_back), where (masks, bound lookups), coverage (bounds, constant sides and divisors must cover their rows), curves (piecewise derivation and validation on xarray), terms (absence: a missing row is 0 as a coefficient, refused as bound or divisor), context, nodes (node walk that also descends into Power, which math-spec alpha.73 skips), accessor, netcdf (encode/decode for persistence), testing (synthetic_sources for tests and benchmarks).
  • Not ported: the Dual node (waits for feat(language): an expression the math never reads may be nonlinear, a reported quantity energy-models/math-spec#287), lpspec's reads_off_the_solution grade predicate (linopy always folds), the polars loader (replaced by the binder). Variable divisors and degree-2 named expressions are refused by math-spec itself at lowering.
  • Rules pinned by tests: divisor coverage is checked on named-expression bodies; no unaligned operand reaches the fold (differing shared indexes raise SpecDataError); a hole an operator leaves beside a term drops the row; a constraint with its term on the right is swapped with a flipped sense.
  • import linopy still never imports math_spec; the model methods import it lazily.

Persistence (step 4):

  • to_netcdf writes the spec YAML text as the top-level attribute _linopy_spec, next to _linopy_version, following the relaxed-registry and piecewise-formulation precedent. read_netcdf re-lowers the Program from that text; a file without a spec loads without math-spec installed.
  • One extra sub-dataset under the prefix spec- carries what model.parameters cannot round-trip on its own: master coordinates as coords__<dim> data variables (an index coordinate no retained parameter reaches would otherwise be dropped on read), and lookups plus object-dtype parameters as pandas.factorize output, codes__<name> (int32, -1 for a hole) plus cats__<name>. Every parameter records its in-memory dtype in a variable attribute and is cast back on read. No netcdf type carries an object array of strings with NaN: netCDF4 returns <U and turns a hole into an empty string, scipy narrows int64 to int32. Codes plus categories round-trip str->str, str->int, int->str and int->int lookups, full and partial, exactly on both engines. This resolves the two lookup items left open in feat(spec): xarray data binder for math-spec programs #921.
  • Model.copy() carries the spec; the accessor is rebound to the copy. On read, the master coordinates are stamped onto every container, so one dimension has one dtype even after the scipy engine narrows int64 to int32. assert_model_equal compares the spec text and the parameter datasets including dtypes.
  • test/test_spec_io.py: round trips for retain="report"|"all"|"none" on both engines, a solved model whose named expression is identical before and after, evaluate on a restored retain="none" model, exact-dtype assertions per lookup kind, copy semantics, and math-spec's examples/pypsa.yaml with synthetic data when MATH_SPEC_EXAMPLES points at a math-spec checkout.
  • benchmarks/models/spec_pypsa.py: from_spec on examples/pypsa.yaml plus the two netcdf phases; skips without math-spec or MATH_SPEC_EXAMPLES. Running it in CI needs math-spec installed and the examples directory available in the codspeed workflow, which waits for a math-spec PyPI release.

Found upstream: math-spec alpha.73 program.children() does not descend into Power, so parameters_of/walk miss parameters under **. Worked around in linopy/spec/nodes.py.

Verification
uv run --with <math-spec> pytest test/test_spec_binder.py test/test_spec_builder.py test/test_spec_io.py test/test_io.py -q
465 passed, 155 skipped
MATH_SPEC_EXAMPLES=<math-spec>/examples ... -k every_math
26 passed (all shipped examples incl. pypsa.yaml build and solve)
coverage linopy/spec 95%
uv run --with <math-spec> mypy linopy benchmarks
Success: no issues found in 78 source files
uv run pytest test/test_spec_builder.py test/test_spec_io.py test/test_io.py -q   (without math-spec)
93 passed, 16 skipped
MATH_SPEC_EXAMPLES=<math-spec>/examples uv run --with <math-spec> --with pytest-benchmark pytest benchmarks -k spec --benchmark-disable
6 passed, 1 skipped

Checklist

  • AI-generated content is marked (see AGENTS.md).
  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Build cost — v1 vs legacy

v1 build peak & time relative to legacy, on this commit — not a comparison against master (that is CodSpeed).

peak — v1 / legacy time — v1 / legacy
peak v1/legacy time v1/legacy
Full table (time + peak, mean)
benchmarks/drivers/test_build.py::test_build[basic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.06567 (1.11)   │   15.03 (1.00) 
 (v1)        0.05933 (1.0)   │    15.00 (1.0) 

benchmarks/drivers/test_build.py::test_build[basic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.07335 (1.10)   │   12.04 (1.00) 
 (v1)        0.06647 (1.0)   │    12.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=0]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.02789 (1.08)   │   15.20 (1.0) 
 (v1)        0.02593 (1.0)   │   15.20 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=100]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04218 (1.11)   │   44.93 (1.0) 
 (v1)        0.03791 (1.0)   │   44.93 (1.0) 

benchmarks/drivers/test_build.py::test_build[cumsum-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03138 (1.07)   │   11.51 (1.0) 
 (v1)        0.02928 (1.0)   │   11.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.07219 (1.06)   │   24.34 (1.06) 
 (v1)        0.06782 (1.0)   │    23.05 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08142 (1.05)   │   16.12 (1.00) 
 (v1)        0.07761 (1.0)   │    16.12 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=10000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.01747 (1.07)   │   752.18 (1.10) 
 (v1)        0.01635 (1.0)   │    685.15 (1.0) 

benchmarks/drivers/test_build.py::test_build[knapsack-n=100]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.01709 (1.07)   │   3.12 (1.33) 
 (v1)        0.01599 (1.0)   │    2.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=0]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05579 (1.37)   │   126.16 (1.44) 
 (v1)        0.04085 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=100]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05574 (1.41)   │   126.16 (1.44) 
 (v1)        0.03959 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[kvl_cycles-severity=50]
                  time (s)          peak (MiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05564 (1.34)   │   126.16 (1.44) 
 (v1)        0.04138 (1.0)   │     87.71 (1.0) 

benchmarks/drivers/test_build.py::test_build[masked-n=100]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03925 (1.01)   │    715.14 (1.0) 
 (v1)        0.03891 (1.0)   │   787.74 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03832 (1.10)   │   4.55 (1.27) 
 (v1)        0.03498 (1.0)   │    3.59 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.2698 (1.03)   │   704.12 (1.09) 
 (v1)        0.2614 (1.0)   │    647.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.2879 (1.04)   │   18.34 (1.0) 
 (v1)        0.2763 (1.0)   │   18.34 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.2815 (1.04)   │   9.54 (1.0) 
 (v1)        0.2707 (1.0)   │   9.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.05433 (1.08)   │   3.77 (1.12) 
 (v1)        0.05036 (1.0)   │    3.37 (1.0) 

benchmarks/drivers/test_build.py::test_build[milp-n=50]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.05442 (1.07)   │   216.59 (1.10) 
 (v1)        0.05065 (1.0)   │    196.23 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=0]
                 time (s)         peak (KiB) 
 name                mean   │           mean 
─────────────────────────────────────────────
 (legacy)   0.0279 (1.09)   │   938.49 (1.0) 
 (v1)       0.02564 (1.0)   │   938.49 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02939 (1.08)   │   9.66 (1.0) 
 (v1)        0.02724 (1.0)   │   9.66 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.02883 (1.08)   │   5.32 (1.0) 
 (v1)        0.02666 (1.0)   │   5.32 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=0]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.0155 (1.00)   │   1.41 (1.0) 
 (v1)       0.01543 (1.0)   │   1.41 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.01591 (1.04)   │   1.41 (1.0) 
 (v1)        0.01531 (1.0)   │   1.41 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                 time (s)       peak (MiB) 
 name                mean   │         mean 
───────────────────────────────────────────
 (legacy)   0.0154 (1.00)   │   1.41 (1.0) 
 (v1)       0.01534 (1.0)   │   1.41 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1362 (1.04)   │   946.85 (1.06) 
 (v1)        0.1305 (1.0)   │    891.54 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=10]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.1338 (1.04)   │   12.01 (1.0) 
 (v1)        0.1289 (1.0)   │   12.01 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03509 (1.07)   │   147.70 (1.06) 
 (v1)        0.03279 (1.0)   │    139.87 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=10]
                  time (s)        peak (KiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.03442 (1.06)   │   2.60 (1.09) 
 (v1)         0.0325 (1.0)   │    2.38 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=0]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.02844 (1.08)   │   696.75 (1.03) 
 (v1)        0.02642 (1.0)   │    673.70 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=100]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.06995 (1.00)   │   137.97 (1.0) 
 (v1)        0.06986 (1.0)   │   137.97 (1.0) 

benchmarks/drivers/test_build.py::test_build[rolling-severity=50]
                  time (s)        peak (MiB) 
 name                 mean   │          mean 
─────────────────────────────────────────────
 (legacy)   0.04668 (1.10)   │   69.22 (1.0) 
 (v1)        0.04262 (1.0)   │   69.22 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03301 (1.08)   │   402.33 (1.00) 
 (v1)        0.03043 (1.0)   │    402.30 (1.0) 

benchmarks/drivers/test_build.py::test_build[sos-n=10]
                time (s)        peak (KiB) 
 name               mean   │          mean 
───────────────────────────────────────────
 (legacy)   0.033 (1.11)   │   3.19 (1.19) 
 (v1)       0.0297 (1.0)   │    2.69 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=10]
                  time (s)         peak (KiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.03587 (1.06)   │   29.00 (1.54) 
 (v1)        0.03371 (1.0)   │    18.84 (1.0) 

benchmarks/drivers/test_build.py::test_build[sparse_network-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.04545 (1.09)   │   37.95 (1.43) 
 (v1)        0.04156 (1.0)   │    26.51 (1.0) 

benchmarks/drivers/test_build.py::test_build[storage-n=10]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)    0.06788 (1.0)   │    410.93 (1.0) 
 (v1)       0.07005 (1.03)   │   427.84 (1.04) 

benchmarks/drivers/test_build.py::test_build[storage-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)     0.0746 (1.0)   │     9.94 (1.0) 
 (v1)       0.07796 (1.05)   │   10.22 (1.03) 

📊 Interactive plots + CSV: download the semantics-report-v1-vs-legacy artifact from this run.

Report-only · not a gate · refreshed on every push · obsolete once legacy is dropped.

@FabianHofmann FabianHofmann changed the title Build models from math-spec programs and fold named expressions Build models from math-spec programs, fold named expressions, round trip through netcdf Sep 3, 2026
…ssions

Port lpspec's linopy lane onto the binder: builder, where, operators,
coverage and curves, wired to Bound and SpecDataError. Add Model.add_spec,
Model.from_spec and the model.spec accessor with expressions and evaluate.
…en windows

Coverage and the retain closure now descend into a Power's operands;
evaluate() refuses sources labelled unlike the model; an all-null window
width is a window of nothing; cases fold through the aligned combine.
Persist the spec text, the master coordinates and the lookups alongside the
model, re-lowering the program from the text on read; math-spec is imported
only for a file that carries a spec. Lookups and arrays of labels are stored
as codes into a category table, so partial maps keep their holes and dtypes.
Write the in-memory dtype of every parameter and cast it back on read, and
stamp the master coordinates onto every container, so no engine leaves a
model disagreeing with itself. assert_model_equal now compares dataset
dtypes, and synthetic_sources moves to linopy/spec/testing.py for both users.
A missing parameter row was read as a silent zero when it stood as a
coefficient, while a bound, constant side or divisor already refused it.
Refuse it as a coefficient too, so every position behaves alike and a
hole is never filled without the modeller saying so: mask the coordinate
out with a where, or fill the value into the data.
A runnable, nbconvert-clean walkthrough of the spec feature: the dispatch
program, binding data, folding named expressions, retain and evaluate,
the uniform absence rule, lookups and grouped sums, temporal shift, and
the netCDF round trip.
m.spec.expressions[name] returns a NamedExpression bundling .node (the
lowered formula), .expression (the unsolved linopy expression) and
.solution (the fold over the model's solution). evaluate() returns the
same object. Add ModelSpec.to_latex/to_markdown/to_typst for whole-model
typesetting, rendered as Markdown in a notebook.
building-models-from-specs.ipynb imports math_spec, which the docs CI environment does not install, so the notebook job failed on import. Skip it like the other special-setup notebooks.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant