Meta: separate dense and sparse stores from the expression and constraint frontend
Los mantenedores suelen responder en 1 día
@Rishabhkanhaiya ya está trabajando en esto.
Desde el 7/10/2026.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 25/100
Línea de trabajo
This is a meta issue; start with child #1020 for the first step, and read the referenced sections in linopy/expressions.py and linopy/csr.py to understand the current dispatch and sparse operations. The plan also identifies test/test_csr.py, constraints.py, matrices.py, and model.py for later steps. Done means completing the staged store refactor and conformance tests; several steps depend on other work, including legacy-semantics removal.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
[!NOTE]
The following content was generated by AI.
Describe the feature you'd like to see
Meta issue. With #972 the sparse (CSR) backing is complete and observable, and Model(sparse=True) makes dense and sparse two equivalent ways to hold the same model. The code does not reflect that yet: sparse support is layered onto the dense classes by overrides and isinstance switches. This issue fixes the target architecture and the order of the refactoring. Child issues are spawned per step. Code links point to master at f6da2877.
Revision 2026-10-22: legacy semantics are gone (#1041, #1042), so the plan targets v1-only code and the 1.0 release. 1.0 is a breaking release; the plan removes compatibility layers instead of deprecating them. The densification chokepoint and the dense store (formerly steps 1 and 4) are one step, the dispatch table is dropped, the KeySet step is dropped. A second revision the same day folds in a review of the plan against the code: fallbacks are per argument case, not per method; a sparse operand pulls a merge sparse; Grid is the declared key set of the store protocol; step 4 is split in three.
Target
One LinearExpression class and one Constraint class. The expression always holds one store object, dense or sparse, behind one Store protocol. The constraint holds its lhs expression plus sign, right-hand side, labels, scaling and dual, and never learns about stores. Model.store_cls picks the store at the construction seam; nothing downstream branches on it. Every conversion between stores passes through Store.to_dense(reason) / Store.to_sparse(reason), which name the operation. Both stores expose .grid: the store abstracts term storage on a fixed Grid, not the Dataset. LinearExpression is mutable by store swap; Constraint.lhs returns a new wrapper around the current store on each access. Model, export, persistent diff and solvers never branch on the store.
Where the coupling is today
| Layer | State | Reference |
|---|---|---|
LinearExpression |
one class with _data: Dataset and _csr: CSRLinearExpression | None; about a hundred lines branch on _csr; every non-overridden method falls back implicitly by reading .data, which converts the receiver in place and for good with a generic reason |
linopy/expressions.py:2145-2200 (data, _densify), :2676 (_sparse_matmul), :3440 (_densify_all), :3549 (_try_csr_merge), groupby :531-605 |
CSRLinearExpression |
already has the shape of a store interface (summed, scaled, added, contracted, taken, reindexed, filled, renamed, with_const, broadcast, from_labels, from_grouper, from_dense); the dense equivalent is inline xarray code in BaseExpression, mixed with alignment and semantics checks |
linopy/csr.py:263-787 |
| Constraints | Constraint and CSRConstraint are siblings under ConstraintBase(ABC); CSRConstraint stores a raw csr_array plus seven side arrays, not a CSRLinearExpression, so lhs and data rebuild a Dataset on every access and from_csr / from_dense are separate conversions; every setter and update() raise a "frozen" error |
linopy/constraints.py:141, :742, :1085, :1723 |
Model |
the read-only sparse key is clean; seven reads remain (add_constraints, add_objective, to_linexpr, groupby sum, @, two in netcdf I/O) plus five to_linexpr(_dense=True) sites on the quadratic, rolling and cumsum paths |
linopy/model.py:1285-1320, :1455; linopy/variables.py:398, :484, :505, :834, :874; expressions.py:574, :2611, :2714; linopy/io.py:1205, :1389 |
| Export and solvers | mostly polymorphic through ConstraintBase; MatrixAccessor and the dual write-back still switch on type; Model.copy densifies m.expressions but keeps the objective sparse |
linopy/matrices.py:48-58, :211, :289; linopy/io.py:1410, :1423, :1445; model.py:2428 |
| Tests | equivalence by twin builds (twin_models, assert_sparse_matches, assert_frozen_equal) and hand-picked sparse parametrizations; no backend fixture, no conformance suite over the store operations |
test/test_csr.py |
Reproduction of the implicit fallback:
import warnings
import numpy as np, pandas as pd, linopy
linopy.options["warn_on_densify"] = True
i, t = pd.RangeIndex(20, name="i"), pd.RangeIndex(5, name="t")
m = linopy.Model(sparse=True)
x = m.add_variables(0, 1, coords=[i, t], name="x")
g = pd.Series(np.arange(20) % 3, index=i, name="g")
for op in ["coeffs", "to_quadexpr()", "isnull()"]:
s = x.groupby(g).sum()
assert s.is_sparse
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
eval(f"s.{op}")
msgs = [str(x.message) for x in w if "dens" in str(x.message).lower()]
print(f"{op:16} sparse after: {s.is_sparse!s:5} warning: {msgs}")
The receiver is converted in place and the warning does not name the operation.
Plan
| Step | Content | Depends on |
|---|---|---|
| 1: conformance suite | A backend fixture (Model(sparse=False), Model(sparse=True)) and one parametrized suite over (operation, argument case, expected: kept or fallback reason). One assert_equivalent: same Grid including auxiliary coords, same isnull, same per-cell term matrix after explicit zeros are pruned and duplicates are summed. It generalizes assert_sparse_matches (test/test_csr.py:2071); assert_linequal stays for dense-only tests. Layout-dependent accessors (nterm, shape, has_terms) are out of scope. Every fallback must warn with its operation name and leave the receiver sparse. The twin-build helpers are deleted in step 4b, not here. |
– |
| 2: store protocol | A Store base class with DenseStore (the term-storage part of the xarray code in BaseExpression) and CSRStore (today's CSRLinearExpression). LinearExpression always holds one store, never None, and keeps alignment, broadcasting, auxiliary-coordinate and semantics checks, which both stores need and which run on Grid datasets today. Fallbacks are per argument case, not per method: a store operation returns a result or a reason string (the pattern of _aligned, _concatenated and csr_rhs today), and LinearExpression turns a reason into store.to_dense(reason) plus the dense call. Store.to_dense(reason) is the only place that emits the densify notice. Multi-operand operations (merge, constant arithmetic, @) go through one free function over stores with an explicit target-store rule (step 3). Quadratic products and the rolling and cumsum paths (the five _dense=True sites) are explicit dense conversions without a notice. No cached dense twin: netcdf and Model.copy go through the store instead of .data. #1020. |
– |
| 3: construction seam | Model.store_cls (DenseStore or CSRStore) decides the construction seam only: from_labels (Variable.to_linexpr), from_grouper (groupby sum), from_matmul (@), from_dense, and the netcdf reader. Mixed operands keep today's rule, a sparse operand pulls the merge sparse, and dense operands convert up through Store.to_sparse(reason), the second named chokepoint with its own notice; it refuses coordinates the Grid cannot hold (2-D auxiliary coords) with a reason instead of dropping them silently. A dense-only merge stays dense in a sparse model. |
2 |
4a: dense constraint over lhs |
Constraint holds lhs: LinearExpression, sign, rhs, labels, scaling, dual, slack and binvar fields; data is derived. canonical() becomes an explicit store method that add_constraints calls once, so c.lhs is canonical and update(lhs=) re-canonicalizes. The positional active-row CSR is a cache on the constraint, invalidated on store replacement, so the identity shortcuts of the persistent snapshot and diff keep working; _coef_dirty moves with it. Mutation paths: update(coeffs=, variables=), loc setters and set_blocks are reimplemented as store replacement on lhs; the conwrap rebuilds and __getitem__ read lhs. CSRConstraint stays untouched in this step. |
1, 2 |
| 4b: one constraint class | CSRConstraint and ConstraintBase are deleted; a constraint in a sparse model is a Constraint whose lhs holds a CSRStore. update() replaces the lhs store (closes #1033). to_sparse() / to_dense() convert one object. MatrixAccessor and the dual write-back call store methods (positional_csr, to_dense) and lose their isinstance branches. The freeze vocabulary (add_constraints(freeze=), freeze(), mutable(), the "frozen" errors) is removed without a deprecation period. Twin-build test helpers are deleted. |
3, 4a |
| 4c: netcdf layout | One netcdf layout for constraints and expressions written from the store (Grid.to_netcdf_vars exists); the _linopy_format="csr" branch and the dense-only reader go. 1.0 reads the 1.0 layout only. |
4b |
| 5: long-form inputs | Accept bounds, coefficients, constants and right-hand sides as a long table: key columns plus one value column. A missing key column means broadcast over that dim; cells not listed take a default=. Group-by sums over relation tables with several rows per source key (e.g. link port → bus) go through from_grouper with repeated source rows, a scatter onto the Grid the store already does; today they need an incidence matrix and @. Inputs are converted to the current stores; linopy does not depend on datarecord, narwhals or DuckDB types. |
1 (the suite must cover long-form inputs on both backends) |
Ordering notes:
- Step 1 first, then 2 and 3 back to back (3 is small once 2 exists), then 4a, 4b, 4c. Step 5 is independent of 2 to 4 and can start once step 1 exists.
- A
KeySetinterface (former step 5) is dropped: an interface with one implementation and a deferred second one. InsteadGridis the declared key set of the store protocol from step 2 on, so a table-based key set later touches construction only.Gridalready carries the netcdf, alignment and concatenation paths. - Sparse and dense results agree under
assert_equivalent, not on term order, padding or explicit zeros. The docs state this once; the code does not pay for layout identity. .dataon a sparse expression keeps converting with a notice rather than raising. After step 2 only user code reads.data.
Long-form data
PyPSA plans a long-form data model built on datarecord (beta). Measurements on a synthetic PyPSA-like model (10k generators, 1k buses, 2920 snapshots):
- Converting long form to a stacked 1-D dim (one entry per existing generator) and building the current stores is feasible: 9.5 s / 3.8 GB peak for the sparse store including the pivot. A naive build straight from the long table took 11.9 s. Only a full generator × bus cube is prohibitive (fill about 0.1 %). A cube of a long-form parameter costs less memory than the long table above about 40–45 % fill.
- A hand-rolled numpy build with the same layout (static relations as tables, time axis by stride) assembles the same matrix in 2.4 s / 3.2 GB peak. It is a lower bound for a table-based key set, not a linopy prototype.
So long form is served by step 5 now. A table-based key set (coords= a data frame, alignment as an equi-join) is deferred until datarecord is stable and a PyPSA benchmark shows the gap matters. Only join="override" and operations that depend on axis order have no meaning on a table key set.
Rejected:
- A lazy expression graph (already rejected in #972).
- Two public classes (
DenseLinearExpression/SparseLinearExpression) at the frontend. Users should not pick a class; the model key picks the store. - A dispatch table mapping frontend methods to sparse counterparts (former step 1), and its successor, a per-method fallback as base-class default. Fallbacks are per argument case (
reindexonmethod=,whereondrop=,sumon non-grid dims, ...), so the authoritative list is the conformance suite, not the class. - A cached dense twin on the sparse store. It doubles memory exactly where
.datais read today (netcdf,Model.copy); those paths go through the store instead. - "The model's store wins" for mixed merges. Fallback results, constants and
from_ruleproduce dense expressions in a sparse model; converting them up silently at every+and down at every unsupported operation hides a ping-pong. A sparse operand pulls sparse, as today, and the conversion up is named. - A sparse store for variables. Variables stay label arrays; the store choice starts at the variable → expression step (one-term expressions are sparse since #1034).
- A long table as a third term store. Long form changes which cells exist, not how terms are stored.
- Deprecating the freeze API. 1.0 is the breaking release; aliases with a
FutureWarningthrough 1.x would outlive the concept they alias.
Children
- #846 (legacy semantics removal, #1041); #845 is superseded
- #1034 (PR: one-term sparse expressions and sparse broadcast in merges)
- #1044 (step 1: backend-parametrized conformance suite with
assert_equivalent) - #1020 (step 2: store protocol with per-case fallback reasons)
- #1045 (step 3:
Model.store_clsand the store constructors as the construction seam) - #1046 (step 4a: dense
Constraintoverlhswith deriveddata) - #1047 (step 4b: one
Constraintclass,CSRConstraint/ConstraintBase/ freeze API deleted; closes #1033) - #1048 (step 4c: one netcdf layout written from the store)
- #1049 (step 5: long-form inputs and relation group-by)
- Lenguaje dominante
- Python
- Estrellas
- 262
- Forks
- 89
- Merge medio
- 2 d 4 h
- PR fusionados (30 d)
- 37
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de PyPSA/linopy
-
Constant LinearExpression * multi-term LinearExpression raises TypeError (mirrored product works)Abiertobug
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
bug solver interface
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
Los mantenedores suelen responder en 1 día
-
refactor sparse
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
PyPSA/linopy#1049 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
One netcdf layout for constraints and expressions written from the storePosiblemente ocupada @FabianHofmann la tomó hoy. Abiertorefactor sparse
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
Los mantenedores suelen responder en 1 día
Todos los issues de PyPSA/linopy
Issues similares
-
Dificultad 1/5 1-3 horas Aptitud para principiantes 85/100
WLAN-Pi/wlanpi-profiler#341 ·
Los mantenedores suelen responder en 1 día
-
Memory leak: the shared `validation_aborted_error` keeps every traceback it is raised throughPosiblemente ocupada @Fire162 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
graphql-python/graphql-core#287 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 67/100
roman-corgi/ctc-dip#160 ·
Los mantenedores suelen responder en 1 día
-
type-bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
Los mantenedores suelen responder en 1 día
-
render_findings.py reads and writes in the host's default encoding, so findings.html breaks on a cp1252 Windows hostPosiblemente ocupada @Aaqibhafeezkhan la tomó hoy. Abiertobug good first issue mentored python size: XS up-for-grabs
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
dheerajjha/mcp-migrate#366 · 1 comentario ·
Los mantenedores suelen responder en 1 día