Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

Meta: separate dense and sparse stores from the expression and constraint frontend

Abierto
#1,019 1 comentario 0 reacciones 0 asignados Ver en GitHub

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
Tipo de issue
Refactorización
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
numpy, pandas, python
Área
backend

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

discussion refactor sparse

[!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 KeySet interface (former step 5) is dropped: an interface with one implementation and a deferred second one. Instead Grid is the declared key set of the store protocol from step 2 on, so a table-based key set later touches construction only. Grid already 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.
  • .data on 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 (reindex on method=, where on drop=, sum on 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 .data is 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_rule produce 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 FutureWarning through 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_cls and the store constructors as the construction seam)
  • #1046 (step 4a: dense Constraint over lhs with derived data)
  • #1047 (step 4b: one Constraint class, 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

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de PyPSA/linopy

Todos los issues de PyPSA/linopy

Issues similares

Más issues de Python

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.