Skip to content

Extend a built model with spec layers that bind existing variables - #954

Open
FabianHofmann wants to merge 3 commits into
spec-builderfrom
incremental-spec
Open

Extend a built model with spec layers that bind existing variables#954
FabianHofmann wants to merge 3 commits into
spec-builderfrom
incremental-spec

Conversation

@FabianHofmann

@FabianHofmann FabianHofmann commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #922. I try to introduce a logic for add_spec for an initialized linopy model from a non-math lane. Note the approach does not need things to change in math-spec for now, even though a stricter logic like a given tag would be welcome.

You could ask why that is needed and the answer is avoiding breaking changes and migration. At this stage it is not feasible to replace the whole optimization routine in PyPSA with math-spec and merge components and custom constraints there via a merge logic in the math spec lane. For an early partial adoption of a math-spec in parts of PyPSA, I would therefore vote to introduce this feature.

Note

The following content was generated by AI.

Changes proposed in this Pull Request

A math-spec can be layered onto an already-built linopy model, for example a PyPSA model with hand-built variables, to add custom constraints, caps and named expressions that read the existing variables.

Binding through sources. A declared variable whose sources entry is a linopy Variable is bound to it instead of built. The declaration must match the model variable: same dimension names, default bounds, no where:, same domain. Everything else in sources stays ordinary data. The empty-model guard is replaced by collision guards: a spec that introduces a variable, constraint, SOS or named expression the model already holds is refused before anything is built ("bind it or rename it"), and so is an objective on a model that already has one.

Layers. model.spec is a ModelSpec over ordered, named Layers, one per add_spec (name=, else the file stem, else "spec"). model.spec[name] reads a layer; model.spec.expressions is the union across layers; typesetting joins the layers and standalone=True needs a single layer. The single-layer passthroughs (program, text, parameters, coords, lookups) raise on several layers and point to model.spec[name]. The Model repr says "extended by math-spec layer(s) ..." and tags items per layer. unspecified reports what no layer declares; the objective is tracked by objective_owner, cleared when the objective is replaced by hand.

netCDF. Each layer is written under its own spec-{name} prefix with the spec text and the binding map as layer-named attributes; layer order, whole and the objective owner are model-level attributes. Files written before this change read back as one layer named spec. Model.copy() carries every layer; assert_model_equal compares every layer's text, bindings and parameters including dtypes.

Dimension reconciliation. A dimension's master index comes from sources, else from an earlier layer, else from the bound variables spanning it, which must all agree; a disagreeing claimant raises "the same dimension name means the same axis". A bound variable that spans a strict subset of the master in master order is reindexed onto it lazily, with absent slots at labels == -1; labels the master does not hold are refused. SOS declarations always act on the model-owned variable.

Docs. Notebook section 11 "Extending a hand-built model", spec.Layer in the API reference, release notes.

Design plan and its review live in dev-scripts/incremental-spec-plan.md on the branch history. Deferred small items from the review round: Layer exposes model and attached as fields, Layer has no _repr_markdown_, restore() is a thin alias, and to_netcdf tests _spec twice.

Verification (e7461dd)
uv run pytest test/test_spec_*.py test/test_io.py test/test_repr.py -q -k "not xpress"
804 passed, 240 skipped
uv run pytest -q --ignore=benchmarks --ignore=test/remote -k "not xpress"   (before the review fixes)
6920 passed, 834 skipped
uv run mypy linopy benchmarks
Success: no issues found in 82 source files
jupyter nbconvert --execute examples/building-models-from-specs.ipynb   (all cells run)

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.
  • I consent to the release of this PR's code under the MIT license.

…variables

A Variable in sources binds a declared variable instead of building it.
model.spec holds ordered named Layers, each round-tripping netCDF under
its own prefix; dims reconcile across sources, layers and bound variables.
Notebook section on extending a hand-built model, release notes, API docs.
@github-actions

github-actions Bot commented Sep 10, 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.06376 (1.07)   │   15.03 (1.00) 
 (v1)        0.05959 (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.07292 (1.07)   │   12.04 (1.00) 
 (v1)        0.06816 (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.02778 (1.08)   │   15.20 (1.0) 
 (v1)         0.0258 (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.04336 (1.05)   │   44.93 (1.0) 
 (v1)        0.04118 (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.03116 (1.06)   │   11.51 (1.0) 
 (v1)         0.0293 (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.07192 (1.05)   │   24.34 (1.06) 
 (v1)        0.06868 (1.0)   │    23.04 (1.0) 

benchmarks/drivers/test_build.py::test_build[expression_arithmetic-n=250]
                  time (s)         peak (MiB) 
 name                 mean   │           mean 
──────────────────────────────────────────────
 (legacy)   0.08189 (1.06)   │   16.12 (1.00) 
 (v1)        0.07744 (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.01727 (1.06)   │   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.01679 (1.05)   │   3.12 (1.33) 
 (v1)        0.01602 (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.05804 (1.33)   │   126.16 (1.44) 
 (v1)        0.04357 (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.05828 (1.33)   │   126.16 (1.44) 
 (v1)        0.04368 (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.06052 (1.33)   │   126.16 (1.44) 
 (v1)        0.04546 (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.03892 (1.00)   │    715.12 (1.0) 
 (v1)        0.03887 (1.0)   │   787.73 (1.10) 

benchmarks/drivers/test_build.py::test_build[masked-n=10]
                 time (s)        peak (KiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.0377 (1.08)   │   4.54 (1.27) 
 (v1)       0.03492 (1.0)   │    3.57 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=0]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.2713 (1.04)   │   704.12 (1.09) 
 (v1)        0.2616 (1.0)   │    643.85 (1.0) 

benchmarks/drivers/test_build.py::test_build[merge_balance-severity=100]
                 time (s)        peak (MiB) 
 name                mean   │          mean 
────────────────────────────────────────────
 (legacy)   0.2855 (1.03)   │   18.34 (1.0) 
 (v1)         0.276 (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.2818 (1.03)   │   9.54 (1.0) 
 (v1)        0.2738 (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.05393 (1.07)   │   3.77 (1.12) 
 (v1)        0.05023 (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.05413 (1.07)   │   216.59 (1.10) 
 (v1)        0.05038 (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.02756 (1.07)   │   938.49 (1.0) 
 (v1)        0.02577 (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.03061 (1.11)   │   9.66 (1.0) 
 (v1)        0.02748 (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.02874 (1.07)   │   5.32 (1.0) 
 (v1)        0.02678 (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.01578 (1.00)   │   1.47 (1.0) 
 (v1)        0.01576 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=100]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.01573 (1.01)   │   1.47 (1.0) 
 (v1)        0.01564 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[nodal_balance_sparse-severity=50]
                  time (s)       peak (MiB) 
 name                 mean   │         mean 
────────────────────────────────────────────
 (legacy)   0.01608 (1.02)   │   1.47 (1.0) 
 (v1)        0.01572 (1.0)   │   1.47 (1.0) 

benchmarks/drivers/test_build.py::test_build[piecewise-n=1000]
                 time (s)          peak (KiB) 
 name                mean   │            mean 
──────────────────────────────────────────────
 (legacy)   0.1362 (1.03)   │   946.85 (1.06) 
 (v1)        0.1318 (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.1337 (1.01)   │   12.01 (1.00) 
 (v1)        0.1318 (1.0)   │    11.99 (1.0) 

benchmarks/drivers/test_build.py::test_build[qp-n=1000]
                  time (s)          peak (KiB) 
 name                 mean   │            mean 
───────────────────────────────────────────────
 (legacy)   0.03424 (1.05)   │   147.70 (1.06) 
 (v1)        0.03253 (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.0341 (1.06)   │   2.60 (1.09) 
 (v1)       0.03223 (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.02898 (1.10)   │   696.75 (1.03) 
 (v1)        0.02639 (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.07636 (1.02)   │   137.97 (1.0) 
 (v1)        0.07473 (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.04814 (1.03)   │   69.22 (1.0) 
 (v1)        0.04673 (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.03282 (1.10)   │   402.33 (1.00) 
 (v1)        0.02994 (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.03201 (1.09)   │   3.19 (1.19) 
 (v1)        0.02948 (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.03556 (1.03)   │   29.00 (1.54) 
 (v1)        0.03468 (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.04715 (1.11)   │   37.95 (1.43) 
 (v1)        0.04266 (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.068 (1.0)   │    410.93 (1.0) 
 (v1)       0.06991 (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.07522 (1.0)   │     9.94 (1.0) 
 (v1)       0.0783 (1.04)   │   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.

Narrow NamedExpression.expression to LinearExpression before reading
its attributes, and type the typeset format parameter as FormatName.
Layer.variables maps declared names through the bindings, so a hand-built
variable sharing a bound spec name is no longer hidden from unspecified.
Drop the restore() alias and the builder's separate bound argument.
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