Repository navigation
Tracking: sparse/CSR path follow-ups after #961 #972
Description
Activity
- addedperformanceThis improves performance while not (meaningfully) altering behaviour for usersThis improves performance while not (meaningfully) altering behaviour for userssparseSparse / CSR-backed expressions and constraintsSparse / CSR-backed expressions and constraints
on Sep 23, 2026 - added a commit that references this issue
on Sep 24, 2026 Note
The following experiment results and reproduction were generated with AI assistance.
BirdFlow validation of the sparse metadata fix merged in #1003, plus one remaining fallback observed in the workload:
has_termsand auxiliary-coordinatedrop_varscaused three densifications in a 168-hour PyPSA core-model build. After the fix, one remains atSnapshotWindow.merge.- Three fresh-process repetitions per mode: median whole-process peak RSS was 1.959 GiB with pinned linopy 0.9.1 dense and 1.740 GiB with the fixed sparse branch; matrix export was 0.120 s versus 0.030 s. This includes imports, network loading, build/export and diagnostics, not solver peak memory. Build timings were close. Source version and arithmetic mode also differ, so these numbers do not isolate the metadata fix itself.
- A separate production-chunk experiment solved the same frozen 1008-hour network as six independent weeks in each mode. All twelve solves were optimal. Dense legacy and sparse v1 gave exactly identical objective, generator dispatch, Line/Link/Transformer flows, storage SOC and nodal prices; every compared output was finite. NTC runtime state and operative-limit sidecars were restored in both modes.
Remaining fallback:
SnapshotWindow.mergecallslinopy.merge(..., dim="snapshot", join="outer")._try_csr_mergecurrently supports term-wise merging, so coordinate concatenation densifies both operands and result. The reproduction below matches dense numerically. A sparse implementation must stack rows and preserve input order. Replacing concatenation with term-wise addition would change the operation. Overlapping labels, absent cells, constants and auxiliary coordinates need explicit coverage.Standalone fallback reproduction and solve provenance
import warnings import linopy import pandas as pd from linopy import Model, LinearExpression from linopy.csr import CSRLinearExpression from linopy.testing import assert_linequal linopy.options["semantics"] = "v1" linopy.options["warn_on_densify"] = True m = Model(sparse=True) x = m.add_variables(coords=[pd.Index([0, 1, 2], name="snapshot")], name="x") parts = [(2 * x).sel(snapshot=[0, 1]), (3 * x).sel(snapshot=[2])] expected = linopy.merge(parts, dim="snapshot", join="outer") sparse = [LinearExpression._from_csr(CSRLinearExpression.from_dense(e.data, m), m) for e in parts] with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") result = linopy.merge(sparse, dim="snapshot", join="outer") assert_linequal(result, expected) print([str(w.message) for w in caught]) print(result._csr is not None, [e._csr is not None for e in sparse]) # One coordinate-merge densification notice; False, [False, False].
The private conversion only constructs this reproduction's sparse operands.
Solve provenance: linopy
fa770b7cea1ffc89b0f01952775711e5230e20b9for both modes, installed PyPSA 1.3.0, HiGHS 1.15.1 (7e5e6228c84d7778bd632520de39899ab8f18b07), HiPO with crossover, 18 threads, blueprint reuse disabled. Frozen BirdFlow source:324e2aa26f0f4f0a797716522c8df6493cfd3b19. Source and input hashes were checked; solved networks and scorecards retained locally.Physical balance/cycle/thermal checks passed identically. The existing scenario health gate failed identically: 23.020316 GWh localized slack versus a 1 GWh threshold. This is numerical equivalence evidence, not healthy-scenario or full-year certification. Each week has independent storage boundaries. Production dependency pins were unchanged by this experiment.
Standalone SVG. The unchanged slack is attributable to thin-line limits in the local grid model and is unrelated to this linopy issue.
Note: AI-assisted (written with model identity unavailable).
Note
The following content was generated by AI.
Describe the feature you'd like to see
Tracking issue for the CSR follow-ups after #961 (umbrella #756 is closed). Each child names a place where a CSR-backed expression silently densifies, or a gap in the frozen-constraint API. This issue fixes the order in which to address them.
Key observations
Line references in this section point to the code before PR 1.
LinearExpression.data(linopy/expressions.py:2358) is where a CSR-backed expression densifies on read. Other paths drop the backing without passing through it:@returns dense when the input was dense andsparse_groupbyis off (expressions.py:2609),CSRConstraint.to_dense()/mutable(), the rebuild atmodel.py:1345, and thereturn Nonefallbacks in_sparse_matmul,_try_csr_merge,_alignedandcsr_rhs, which lose the reason.Gridholds only per-dim indexes. Aux coords live next to it inCSRLinearExpression.coordsand are dropped byCSRConstraint.from_csr(constraints.py:1401), which is CSRConstraint drops auxiliary coordinates when a sparse expression becomes a constraint #941.sum,groupby, selection), so sparse and dense give the same terms up to order; only@/dotprunes them. Cell activeness is carried byconstalone. Export culls zeros regardless; Zero culling forces model rebuilding #925 is an export/persistent-diff question and does not block this series.Plan
Four pull requests, grouped by shared design rather than one per issue:
Grid, and all operations that keep the backing. Merged.soften/penalty=on frozen constraints, so the model key in PR 4 needs no special case for softening. Merged.shape/sizes/coords/dims/isnullfrom the CSR store..is_sparse, repr marker. Opt-inwarn_on_densifythrough one_densify_notice(reason)helper, called atdata, at eachreturn Nonefallback and inCSRConstraint.to_dense.datastays a one-way conversion (no cached dense copy next to_csr, the setters would make it stale). Move aux coords intoGrid.scaled_by/shiftedfor non-scalar constants, aligned through_matmul_operand_to_matrixsum(dim)by merging rows directly (CSRLinearExpression.aggregated), keeping explicit zeros. Chainedgroupbyfrom a CSR source; fix the gate atexpressions.py:613to usecoord_dimsinstead ofself.data.dims.mask=part of #970selthroughreindexed,iselas row gather. Keep_try_csr_mergesparse when aux coords differ across grids.CSRConstraint(trivial once inGrid), incl. netcdf round trip (io.py:1260). Explanatory errors forloc/update/from_rulenaming.mutable(). Expression rhs moved to lhs.flat/to_polarsfrom the CSR store, modelled onCSRConstraint.to_polars. Sparse objective acrossobjective.py,matrices.py,io.py,persistent/diff.py; decide where the objective name lives (CSRLinearExpressionhas noattrs).CSRConstraintnatively (one slack term per active row), drop thepenalty+freezeerrorsemantics="v1",sparse=/sparse_groupby,freeze_constraints), as proposed in #976 (under discussion). Replaces the earlier plan of per-callsparse=on@/dot/merge; removes the coupling of@tooptions["sparse_groupby"].Ordering notes:
constraints.pyso fix: support adding/removing variables after adding frozen constraint #806 can land before PR 2.test/test_csr.py(assert_frozen_equal,assert_terms_equal,assert_cells_equal,assert_contracted_equal) with a "backing kept" assertion instead of adding a new fixture.signinto_constraintand non-scalarfillnastill densify. groupby-sum pads every group to the largest group size #745 is likely covered by sparsegroupby; re-check and close or narrow.Phase 2: performance and remaining densification
Note
The following section was generated by AI.
Phase 1 made the sparse path complete and observable. A PyPSA-like benchmark on
f665a260shows where the time and memory now go. It has 200 buses x 720 snapshots, 2.09M variables, 1.73M constraints and 4.2M nonzeros.supply + storage + flow @ incidenceadd_constraints(balance)m.matricesto_highspy+to_file(LP)Findings:
groupbywith uneven group sizes,@, and merges of their results. Fromadd_constraintson, a sparse model and a frozen dense model run the same code.shift,roll,diff,merge(dim=...), multiplication by a DataArray with a new dimension,rolling,cumsumand quadratic products. For an unevengroupbyresult, this costs about 250 ms and 1.4 GB.Plan: four pull requests, then the docs.
Model.matriceswhen all constraints are frozen, skip scaling lookups without scaling, skipeliminate_zerosfor frozen blocks. Add the benchmark underbenchmark/.CSRConstraint.from_dense, sort only whennterm > 1aggregatedshift/roll/diffandmerge(dim=...)(see theSnapshotWindow.mergecomment below),warn_on_densifyon by default in a sparse modelOrdering notes:
G @ csrforsum(same speed as COO), and a lazy expression graph (it does not reduce the export costs, and it adds a second set of semantics).rolling,cumsumand quadratic products. Revisit them if users report them.Full benchmark output (sparse)
Timings are from one machine with
tracemallocon, so they are higher than without it. The memory peaks are stable across runs.Children
Model.matricesand trim the matrix export path #1008CSRConstraint#1009shift,roll,diffandmerge(dim=...)#1011