Umbrella: long-format / sparse `_term` kernel (dense-`_term` memory cluster)
Nobody has claimed this yet.
Assessment
- Difficulty
- 5/5
- Estimated time
- Over a week
- Newbie friendliness
- 35/100
- Issue type
- Feature
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- python
- Domain
- backend, performance
Research direction
Start with expressions.py:1729 for matmul and the linked #748 prototype, then inspect expressions.py:1488, 262, and 2476 to understand the other dense _term paths. Review common.py:194 and constraints.py:476 for existing conversion and compressed-storage boundaries. Done means an independently shippable operation matches the dense path bit-for-bit while reducing the documented peak allocation.
Written by the indexing model from the issue text.
Description
This issue collects information about how to handle sparsity in linopy, while keeping the convenience of xarray broadcasting and backwards compat.
[!NOTE]
Generated with AI
Root cause
A LinearExpression stores coeffs/vars over a dense coord_dims × _term rectangle. Every op that combines terms re-pads that axis — which is the peak allocation in real PyPSA builds. The same root cause surfaces at four entry points:
- #748 —
@/dotvs a sparse matrix - #745 —
groupby().sum()pads to the largest group - #749 —
mergeof ragged expressions (the build peak) - #757 — multi-key
groupby([names])densifies to a cartesian grid (the fast-path half, #753, shipped in #751)
Context: #740 (polar-high benchmark — the motivation), #712/#713 (absent/NaN semantics a sparse store must keep), #744 (flat dims — prerequisite for a tabular core), #630 (CSRConstraint — existing compressed path, export-side only).
Direction
Two backends were weighed (full note below): sparse.COO under xarray (all-or-nothing; densifies at the first unsupported op) vs. a Polars long-format kernel (the form to_polars already emits). Recommendation: incremental long-format on the hot ops, behind the existing dense xarray API — convert back to dense _term only at the boundary, validate bit-for-bit, ship one op at a time:
__matmul__/dot(#748) — most self-contained, biggest single wingroupby().sum()(#745) — transient win first (FBumann/linopy#25), then the inherent paddingmerge(#749) — concat long, rectangularize once
A full tabular core or sparse.COO remain open later, but are larger bets (the core is gated on #744).
(Earlier siblings referred to "#740" as the kernel — that was a mis-reference to the polar-high benchmark; this issue is the kernel.)
Full investigation note
Sparsity support in linopy — investigation
Investigation note (Claude Code, prompted by @FBumann), 2026-06-04. Assesses
two candidate backends for sparse expression storage — sparse xarray
(sparse.COOduck-arrays) vs. a tabular/long-format (Polars) kernel — and
recommends an incremental path. Code anchors are against the
docs/ai-contribution-guideworking tree.
What the issue tracker already says
A tight, well-profiled cluster of open issues all trace to one root cause:
the dense _term rectangle in LinearExpression.
| Issue | Op | Symptom |
|---|---|---|
| #748 | @ / dot vs sparse matrix |
KVL constraint: 852 _term vs 3 needed → 284× cells, the single largest alloc (132 MB) in a SciGRID-DE build |
| #745 | groupby().sum() |
pads every group to the largest group → ~12 GiB on zipf groups; PyPSA works around it by splitting nodal balance into meshed / weakly-meshed buckets |
| #749 | merge of ragged exprs |
the actual build peak — concat pads each block to global-max _term |
| #740 | (polar-high benchmark) | external Polars-backed competitor, ~2.3–3.5× faster build, ~30% less memory; community + maintainers interested |
| #712 / #713 | absent vs zero, NaN | the data-model semantics a sparse store would have to preserve |
| #744 | MultiIndex storage | leaning toward dropping first-class MI — a prerequisite for a tabular core (see below) |
There is already a partial sparse path: #630 / freeze_constraints=True →
CSRConstraint (scipy CSR + label arrays, no dense Dataset;
constraints.py:476). Measured at ~32% leaner constraint storage on a real
network. But it only engages at the export/storage boundary — it does not
touch the build phase where the peak actually lives.
The actual data model, and where density is forced
A LinearExpression is an xarray Dataset of three arrays over
coord_dims × _term:
coeffs(float, fill0),vars(int labels, fill-1= absent),
const(float, no_term).
The _term axis is a dense rectangle. Every operation that combines terms
re-rectangularizes:
_sum(dim)(expressions.py:1488) →stack({_term: [_term]+dim})— dense product.groupby().sum()(expressions.py:262) →unstack(group_dim, fill_value=...)— pads to largest group.merge(expressions.py:2476) →xr.concatwithfill_value, aligning_termaxes.__matmul__(expressions.py:1729) → literally(self * other).sum(common_dims)— never inspects whetherotheris zero.
One subtlety: even to_polars densifies first — to_polars
(common.py:194) does broadcast(ds)[0] then .values.reshape(-1) before
filter_nulls_polars. So today's "tabular" output is a dense→long dump at the
very end; it does not relieve the build-phase peak (consistent with #749
placing the peak at merge, upstream of export). The sparse story must be
fixed in the build ops, not at export.
Backend A — sparse xarray (sparse.COO duck-arrays)
Idea: back coeffs/vars with pydata sparse.COO instead of numpy;
zero-coeff terms cost nothing, the public xarray API stays.
Attractive because: minimal API change — LinearExpression stays an xarray
Dataset; in principle every op "just works" and all four issues dissolve at
once (#740's last comment makes exactly this point).
Riskier than it looks:
- The ops linopy leans on hardest —
stack/unstack(in_sumand
groupby-sum),reindex-with-fill andconcat-with-fill (inmerge),
groupby— are precisely the weakest-supported operations onsparse.COO
under xarray.unstackon COO is notoriously unsupported/slow. You densify
at the first gap and get no win while paying COO overhead everywhere else. - Two different fill values.
coeffsis sparse-around-0;varsis
sparse-around--1. COO supports a non-zerofill_value, but the two
arrays must stay co-indexed through every broadcast/align, and xarray
broadcasting across two COO arrays with differing fill values does not
compose cleanly. - Everything numpy-shaped breaks:
.flat/.values,np.add.atin
matrices.py, scipy interop,repr,simplify'sbincount. Each needs a
COO branch. - It collides with the absent/NaN semantics work (#712/#713) — "absent" is a
structural zero in COO, the opposite of what #712 wants (absent must stay
distinguishable from a real zero).
Verdict: elegant on paper, invasive and fragile in reality. All-or-nothing —
the payoff requires every hot op to have a real COO path.
Backend B — tabular / long-format (Polars / COO triplets)
Idea: represent the term-carrying part of an expression as a long relation
— (…coord cols…, coeffs, vars) — i.e. exactly the COO/tidy form to_polars
already emits, and exactly polar-high's architecture (#740).
In this representation the pathological ops become cheap and natural:
groupby(k).sum()→group_by(k).agg— no padding, output is observed groups only.expr @ sparse→ a join on the contracted dim over nonzeros only — #748's bit-equivalent prototype is literally "explodeCto(branch, cycle, value)thengroupby(cycle).sum()".merge→ verticalconcat— no_termalignment, no global-max padding.sum(dim)→ drop the dim from the key — no restack.
Attractive because: Polars is already a required dependency
(pyproject.toml:38) and already the IO kernel (to_polars on every object,
the LP writer streams Polars). The seam exists.
Limitations:
- It's a second data model beside xarray. Two honest sub-options:
- (B1) Op-local long kernels with dense↔long round-trips at the edges.
Cheap to land incrementally, but a round-trip only pays off if the op stays
long end-to-end; isolated conversions can cost more than they save. - (B2) Long-format as the primary store — effectively the polar-high
rewrite. You'd re-implement xarray's label-alignment, broadcasting,
.sel/.where/.shift, masks, coord bookkeeping, and printing on a
columnar table. A large fraction ofexpressions.py/variables.py/
alignment.pyis exactly that machinery.
- (B1) Op-local long kernels with dense↔long round-trips at the edges.
- Polars has no n-D labelled array: dims become columns, and broadcasting a
(snapshot)constant against(snapshot, branch)becomes an explicit join —
works, but you're reimplementing xarray. - #744 is a real prerequisite for B2: a flat-dim model maps cleanly to a
table; first-classpd.MultiIndexdoes not. The #744 thread is already
leaning toward dropping MI, which conveniently de-risks an eventual tabular
core.
Feasibility & recommendation
Don't replace xarray, and don't go COO-under-xarray. Go incremental
long-format on the hot ops (B1), behind the existing dense API — the "only the
critical operations" instinct. In order of measured peak:
__matmul__/dotagainst a constant/sparse operand (#748) — most
self-contained, biggest single win. Detect zeros inother, build the COO
product, group-sum the contracted dim in long form, return a compact-_term
expression. No public API change; #748 already has a bit-equivalent reference.groupby().sum()(#745) — two separable wins: the transient
(FBumann/linopy#25scatter-instead-of-unstack prototype: ~32× faster, fits
the current dense model as a drop-in) and the inherent padding (needs long
output). Do the transient first.mergealong a non-_termdim (#749) — concat in long form,
rectangularize once to per-result max instead of per-input-then-global.
Each is independently shippable, validated bit-for-bit against the dense path,
and each stays long end-to-end if it feeds the next op or to_polars / the
matrix export (where it would also let you skip the densifying broadcast in
to_polars). The CSRConstraint path (#630) is the proof that a compressed
representation can coexist with the dense one — extend that pattern leftward
into expression building.
Backend A and the full tabular core (B2) are both "one architecture fixes
everything" stories, but they are large, all-or-nothing bets. The incremental
long-format route recovers most of the measured memory op by op, each step
verifiable — and leaves the door open to B2 later (gated on #744 landing as
flat-dim).
Tracking
This issue is that long-format/sparse _term kernel tracker — linking #748 /
#745 / #749 (+ #757) and the incremental plan above. (Earlier siblings pointed
at "#740" for the kernel; #740 is actually the polar-high benchmark.)
- Dominant language
- Python
- Stars
- 257
- Forks
- 87
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 32
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from PyPSA/linopy
-
bug solver interface
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
documentation sparse
Difficulty 3/5 1-2 days Newbie friendliness 68/100
-
enhancement sparse
Difficulty 4/5 3-5 days Newbie friendliness 48/100
-
Make the sparse path observable and controllable (.is_sparse, densify warning, per-call sparse=) Openenhancement sparse
Difficulty 5/5 Over a week Newbie friendliness 35/100
-
enhancement performance sparse
Difficulty 4/5 3-5 days Newbie friendliness 68/100
Similar issues
-
essnmx good first issue
Difficulty 1/5 Under an hour Newbie friendliness 95/100
-
[Feature] 奇物选择添加优先级 Open
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
syfoud/Simulated_Scepter#174 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Giskard-AI/giskard-oss#2840 · 1 comment ·
-
A claim comment carrying the issue number is silently declined while the workflow reports success Openarea: repo bug perceived difficulty: 2
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
yeti-platform/yeti#1380 ·