Skip to content

feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case) - #870

Draft
FBumann wants to merge 102 commits into
masterfrom
feat/v1-sparse-groupby
Draft

feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case)#870
FBumann wants to merge 102 commits into
masterfrom
feat/v1-sparse-groupby

Conversation

@FBumann

@FBumann FBumann commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

This is a drive by PR done with CLAUDE FABLE. Its here to explore how we can better ahdnle sparsity.

Not sure where this goes yet.

Note

The following content was generated by AI (Claude Code), prompted and reviewed by @FBumann.

pytest-benchmem, the #745 hub scenario (nodal balance, 120 buses × 24 snapshots, one hub bus with hub generators, 100 on each other bus; per-benchmark allocator peak of the build):

hub generators dense groupby build peak sparse=True + freeze=True median build time
8,000 277.1 MiB 43.5 MiB (6.4×) 19.4 ms → 12.9 ms
16,000 546.1 MiB 61.0 MiB (9.0×) 38.5 ms → 16.5 ms

Doubling the skew doubles the dense peak but only grows the sparse one by its actual new terms: the sparse path is O(nnz) and independent of the group-size distribution, so the padding cost that PyPSA's meshed-bus banding exists to contain (see #745) is gone rather than capped. Constraint export also gets cheaper, because the result already is the export representation.

What this adds

Addresses the inherent half of #745 (and #757) on the v1 line, following the umbrella plan in #756: groupby(g).sum() must pad every group to the largest group's term count because a LinearExpression stores terms in a dense cells × _term rectangle. The padded cells are pure intermediate waste for constraint-bound results — the LP/matrix export drops them again.

expr.groupby(g).sum(sparse=True) (or linopy.options["sparse_groupby"] = True) builds the grouped sum in CSR form behind the unchanged LinearExpression type — modeled on dask-backed xarray objects: same public class, different backing, no new public primitive. The payload (linopy/csr.py, CSRPayload) stores the expression as A @ x + c: one CSR row per result cell, one column per variable label, plus a dense per-cell constant. The _term axis is ragged by construction, so group-size padding has no analog, and the operations between a groupby and its constraint become sparse linear algebra:

  • grouping scatters members into group rows (conceptually G @ A with a 0/1 grouping matrix),
  • merge along _term — and therefore +/- — is sparse matrix addition,
  • unary minus / scalar multiplication scale values,
  • == rhs is carried as a pending payload on the Constraint,
  • Model.add_constraints(..., freeze=True) (or Model.freeze_constraints) staples sign and rhs on and registers a CSRConstraint directly — the existing frozen backend (perf: matrix accessor rewrite #630), so the LP writer and matrix export work unchanged.

Anything without a sparse branch transparently expands to the dense rectangle through the .data property and proceeds exactly as today, so compatibility is a fallback, not a constraint.

The contract, and why it is v1-gated

The CSR form is canonical: duplicate variables within a cell are summed (2x + 3x → 5x) and terms are ordered by variable label. Expanding back to the dense form therefore yields the mathematically identical expression in canonical term layout, not the eager kernel's exact positional layout. Term layout is non-contractual under v1 (linopy already treats it as such at export: zero-coeff filtering, maybe_group_terms_polars, densify_terms), which is why the feature requires options["semantics"] = "v1" — under legacy, sparse=True raises and the option is ignored. Equivalence is asserted at the level that matters: identical polars constraint rows and identical LP files (the test suite includes an end-to-end LP diff, term order within a row canonicalized).

v1 parity is kept in the direct realization: NaN in the rhs raises (§5), a reordered/differing rhs index raises with the standard alignment message (§8), and rows whose constraint is absent realize as masked (§12). Labels and the constraint grid bit-match the dense path.

Reproduce

The suite gains a sparse sibling of the existing nodal_balance pattern (the #745 severity sweep), so the padding cost is CI-visible on CodSpeed and one command locally:

pytest benchmarks/ -k "nodal_balance and build" --benchmark-memory
build peak (KiB) severity 0 50 100
nodal_balance (dense) 951 5,460 9,909
nodal_balance_sparse 1,524 1,524 1,524
Standalone repro (single pytest file — the headline table above is its output)
# repro745.py — the #745 hub scenario, dense vs sparse (CSR) groupby-sum.
# run:  pytest repro745.py --benchmark-memory
import numpy as np
import pandas as pd
import pytest
import xarray as xr

import linopy


def build_balance(sparse: bool, hub: int) -> linopy.Model:
    linopy.options["semantics"] = "v1"
    n_bus, n_snap = 120, 24
    buses = pd.RangeIndex(n_bus, name="bus")
    gen_of_bus = np.repeat(np.arange(n_bus), [hub] + [100] * (n_bus - 1))
    gens = pd.RangeIndex(len(gen_of_bus), name="gen")
    snaps = pd.RangeIndex(n_snap, name="snapshot")

    m = linopy.Model()
    gen_p = m.add_variables(coords=[gens, snaps], name="gen_p")
    load = xr.DataArray(np.ones((n_bus, n_snap)), coords=[buses, snaps])

    grouper = pd.Series(gen_of_bus, index=gens, name="bus")
    supply = (1 * gen_p).groupby(grouper).sum(sparse=sparse)
    m.add_constraints(supply == load, name="balance", freeze=sparse)
    return m


@pytest.mark.parametrize("hub", [8000, 16000])
@pytest.mark.parametrize("sparse", [False, True], ids=["dense", "sparse"])
def test_hub_balance_build(benchmark, sparse, hub):
    benchmark(build_balance, sparse, hub)

Needs pytest-benchmark and pytest-benchmem (both in the benchmarks extra). Measured on this branch, macOS arm64, python 3.13. Both paths produce identical constraints — the test suite asserts identical polars term rows and identical LP files for this construction.

Scope and current limitations

  • Single-key groupers over an existing dimension; multi-key / multidim groupers, use_fallback and observed take the eager path.
  • The sparse branches cover the constraint-building chain (neg, scalar mul, merge/+/- on the same grid, comparison with a constant rhs); everything else materializes canonically.
  • freeze=False falls back to a dense Constraint (mathematically equal, canonical layout).
  • Quadratic expressions are untouched.

Natural follow-ups on this seam (not in this PR): dot as a payload op (the v1-side counterpart of #867, which stays the master-era #748 fix) and ragged/KVL-style merge (#749).

History

Two commits kept deliberately: the first implements the same public behavior with a deferred-recipe payload (ungrouped parts + groupers, bit-identical fallback via replaying the eager kernel); the second swaps the payload to CSR. The recipe variant measured within ~5 % of the CSR numbers but accumulates unbounded part lists, holds all input expressions alive until realization, and every future op would need its own replay logic — CSR is the representation the rest of #756 (dot, merge) composes on. The swap trades the recipe's bit-identical fallback for the canonical-form contract above.

Full suite: 6336 passed, 557 skipped — unchanged from the base branch. Stacked on #717 (feat/arithmetic-convention) — draft until that lands.

🤖 Generated with Claude Code

FBumann and others added 30 commits May 21, 2026 14:13
The design goals and transitioning goals for linopy's v1 arithmetic
convention, under arithmetics-design/goals.md. The convention itself and
the bug catalogue (meta issue #714) follow separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Placeholder for the v1 convention document, to be written. Goals are in
arithmetics-design/goals.md; the bug catalogue is the meta issue #714.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flesh out convention.md from the placeholder into the full spec —
thirteen numbered sections in three groups: absence (§1–§7), coordinate
alignment (§8–§11), and constraints and reductions (§12–§13). Covers the
strict exact-match alignment model and the propagate-don't-fill
NaN/absence convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The convention governs coordinate alignment, absence/NaN handling,
constraints, and reductions — not just arithmetic operators — so
retitle convention.md and goals.md to "The v1 convention".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduce linopy.options["semantics"] — legacy (default) or v1 — with
LinopySemanticsWarning, a FutureWarning shown to users by default and
exported at top level. Add the autouse `semantics` conftest fixture
that runs every test under both conventions, plus legacy/v1 markers
to pin a test to one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`_align_constant` branches on `options["semantics"]`: v1 uses exact
alignment via `xr.align(join="exact")`; legacy keeps the size-aware
positional/left-join behaviour and emits `LinopySemanticsWarning` when
v1 would diverge. `_add_constant`/`_apply_constant_op` raise on a NaN
in a user-supplied constant under v1, warn under legacy.

`Variable.__mul__(DataArray)` now routes through `to_linexpr() * other`
so the LinearExpression checks fire; the scalar fast-path is preserved
(a NaN scalar diverts to the expression path so v1 raises).

Marks the bug-class test groups `TestCoordinateAlignment` (#708/#586/
#550), `TestConstraintCoordinateAlignment`, `TestNaNMasking`,
`test_auto_mask_constraint_model`, and four piecewise NaN-padding tests
as `@pytest.mark.legacy` — they assert the very behaviour v1 forbids.
v1 coverage of those bug classes accretes via later slices.

`test/test_legacy_violations.py` (new) adds 22 paired tests covering
§5/§8/§9 plus the PyPSA #1683 `0*inf=NaN` case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`merge` now pre-validates that all operands agree on the labels of
every shared *user* dimension before concatenating. Helper dims
(`_term`, `_factor`) and the concat dim itself are excluded — those
legitimately vary between operands. v1 raises on mismatch; legacy
keeps current size-based override/outer behaviour and emits
`LinopySemanticsWarning` when v1 would diverge.

The check uses a new `_merge_shared_user_coords_differ` helper. The
existing override/outer decision is unchanged for the actual
`xr.concat` call — the new check only gates whether legacy/v1 accept
the merge, never how the concat itself runs.

Adds 8 paired tests for var+var, var-var, expr+expr, broadcast guard,
and warning emission on the merge path.

Reclassifies as `@pytest.mark.legacy`: `test_non_aligned_variables`
(deliberately disjoint coords), `test_linear_expression_sum` /
`test_linear_expression_sum_with_const` (assert `v.loc[:9]+v.loc[10:]`
merges), `TestJoinParameter` cases that build `a*b` from mismatched-
coord vars, and two SOS2 reformulation tests. File-level legacy mark
on `test_piecewise_constraints.py` + `test_piecewise_feasibility.py`
until `linopy/piecewise.py` itself is made v1-aware (tracked as
Slice P).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Variable.to_linexpr() now produces a LinearExpression whose absent
slots (labels == -1) carry NaN coeffs and NaN const under v1, so
downstream arithmetic has something to propagate. The expression
constant operators (_add_constant, _apply_constant_op) no longer
fillna(0) self.const / self.coeffs under v1 — NaN flows through.
`merge` sums const along _term with skipna=False under v1, so a slot
that's absent in any operand stays absent in the result. Legacy paths
keep the silent-fill behaviour verbatim.

LinearExpression.isnull() now returns `const.isnull()` under v1: a
slot is absent iff its const is NaN. ``vars == -1`` is a dead-term
signal (the slot can still be a present constant after fillna),
not a slot-level absence marker. Legacy keeps the historical
``(vars == -1).all() & const.isnull()`` formula for byte-for-byte
compatibility.

Variable.fillna(numeric) now returns a LinearExpression (a constant
isn't a variable). Variable.fillna(Variable) stays Variable, as
before.

Adds 11 tests for §6 propagation (mul/add/sub/div preserve absence,
absent-vs-zero distinguishable, present + absent propagates) and §7
resolution (fillna numeric on expr / Variable, present-zero revival).

Reclassifies test_masked_variable_model as @pytest.mark.legacy — its
assertion "x bound to 10 at masked-y slots" only holds because legacy
collapses absent y to 0. The v1 way is x + y.fillna(0) >= 10; a
counterpart test in test_legacy_violations.py pins this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The convention spec names ``reindex`` and ``reindex_like`` among the
absence-creating mechanisms (alongside ``mask=``, ``.where()``,
``.shift()``, and ``.unstack()``), but master only had them on
``LinearExpression``. Add them on ``Variable``, with the sentinel
fill values (``labels=-1``, ``lower=upper=NaN``) so new positions
slot cleanly into §6 propagation.

The methods work the same way under both semantics — under legacy
the sentinels exist but downstream arithmetic still collapses them
back to 0 (the #712 bug), so the user-visible effect of reindex-as-
absence only really lands under v1.

Adds 5 tests: extend with absent, subset drops, reindex_like with
another Variable, and the §4 + §6 hand-off (a reindex-introduced
absent flows through ``* 3`` and is visible via ``isnull()``).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slice C propagated NaN const cleanly but left the storage half-absent
after a merge: `(1*x) + xs` at the absent slot kept the `1*x` term's
valid coefficient and label even though `const` was NaN there. The
§1/§2 promise "absence is one concept, whatever the dtype" only holds
if `const.isnull()` at a slot ⇒ every term at that slot has
`coeffs = NaN`, `vars = -1`.

Add `_absorb_absence(ds)` and call it at the end of `merge` under v1.
The constant-operand paths (`_add_constant`, `_apply_constant_op`)
don't need explicit absorption — their NaN-propagation naturally
preserves the invariant when the input is already v1-compliant
(NaN * anything = NaN; dead terms stay dead). Only `merge` opens the
gap by concatenating one operand's live term with another operand's
absent slot along `_term`.

`convention.md` §2 now states the invariant explicitly and introduces
the *dead term* terminology, so `fillna(value)` reviving a slot while
leaving the sentinel term in place reads as a feature, not a glitch.

Adds `test_outer_fillna_then_add_collapses_to_just_added` pinning
`(x + y.shift()).fillna(0) + x` — at the previously-absent slot the
result has exactly one live term (`1·x[0]`) with `const = 0`,
algebraically equal to `x[0]`. At present slots all three terms stay
live (`2·x[i] + y[i-1]`), so fillna placement is load-bearing — moving
it inside (`x + y.shift().fillna(0) + x`) would double-count `x` at
the absent slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`.add/.sub/.mul/.div/.le/.ge/.eq` already accepted a `join=`
argument; this slice's job is just §12's RHS handling under v1.

`to_constraint` branches on `options["semantics"]`. Under v1 it
skips the legacy `reindex_like(self.const, fill_value=NaN)` step
that silently padded a subset RHS, so a coord mismatch with the
LHS now flows through `self.sub(rhs)` and gets caught by §8's
exact alignment. A NaN in a user-supplied constant RHS raises at
construction (§5) — including the PyPSA #1683 case of
`min_pu * nominal_fix` with `p_nom=inf` and `p_min_pu=0`. An
absent slot in the LHS (propagated from §6) still produces a NaN
RHS at that row; downstream auto-mask drops the constraint there,
which is exactly §12's "absent slot yields no row."

Legacy keeps the old auto-mask path verbatim and adds a
`LinopySemanticsWarning` whenever a NaN RHS is observed, so users
get the rollout signal without behaviour change.

Adds 11 paired tests: TestNamedMethodJoin (inner/outer/left across
.add/.mul/.le, plus a "bare op still raises" guard) and
TestConstraintRHS (subset RHS raises, NaN RHS raises, PyPSA #1683
on the constraint side, §6→§12 hand-off where the absent LHS slot
yields NaN RHS, plus the paired legacy auto-mask documentation and
warning-emission tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three internal patterns were violating §8 / §11:

1. ``_add_incremental`` in ``linopy/piecewise.py`` builds
   ``delta_hi <= delta_lo`` from two ``.isel(piece_dim=slice)`` slices
   of the same variable. ``drop=True`` is a no-op for slice indexers
   so ``piece_dim`` stays on both with *different* labels (first n-1
   vs last n-1 of piece_index) — v1 §8 rejects. Relabel the high
   slice onto the low slice's labels so the comparison aligns by
   label (the explicit-positional path of §10). Same fix for
   ``binary_hi <= delta_lo``.

2. ``_incremental_weighted`` computes ``bp0 = bp.isel({dim: 0})``
   without ``drop=True``, leaving the breakpoint dim as a scalar
   coord on the resulting expression. When that expression appears
   as the RHS of ``links.eq_expr == ...`` it conflicts with the LHS,
   which has no such coord — §11 aux-coord conflict. Add ``drop=True``.

3. ``reformulate_sos2`` builds its first/last constraints from
   scalar isels at different positions on ``sos_dim`` (``x``/``M`` at
   ``n-1`` paired with ``z`` at ``n-2``, etc.). All without
   ``drop=True``, so the scalar ``sos_dim`` coord differs across
   operands — §11 aux-coord conflict. Add ``drop=True`` to all three
   sites.

Removes the module-level ``pytestmark = pytest.mark.legacy`` from
``test_piecewise_constraints.py`` and ``test_piecewise_feasibility.py``
and the method-level marks from the two SOS2 multidim tests. Suite is
+598 tests under v1 vs Slice E (legacy → v1 broadened coverage),
0 failures under either semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
§13 falls out of xarray's ``skipna=True`` default; no code changes
needed. Adds 4 tests so future drift is caught: sum over a dim,
sum without a dim, sum of all-absent (the zero expression), and
groupby.sum across heterogeneously-present groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `_conflicting_aux_coord(datasets)` and wires it into both
`merge` and `_align_constant`. When two operands carry an aux coord
of the same name with disagreeing values, v1 raises with a pointer
to the explicit resolutions (``.drop_vars(...)`` or
``.assign_coords(...)``). xarray silently drops the conflict — the
#295 bug — and legacy keeps that behaviour but now emits a
`LinopySemanticsWarning`. The helper guards against string-dtype
coord values (no `equal_nan=True` there) so the multiindex case
keeps working.

`_merge_shared_user_coords_differ` refactored to compare bare
``d.indexes[k]`` instead of ``d.coords[k]``: aux coords no longer
leak into the §8 check, so §11 owns aux-coord conflicts cleanly
and §8 owns dim-coord mismatches with a separate message.

Convention §11 expanded from one paragraph: aux coords are
validated and propagated but never computed with — they describe
the data, they don't enter the math. Goal #4 in `goals.md` picks
this up: user-attached auxiliary coordinates are the user's,
linopy never silently rewrites them.

`test_linear_expression.py::test_merge` adds ``drop=True`` to its
``.sel`` setup — the test was leaving a leftover scalar coord that
v1 now correctly catches as a §11 conflict; the fix preserves the
test's intent of exercising merge with differing term counts.

Conflict-raising tests (TestAuxCoordConflict) cover expr+const,
var+var, scalar-isel-without-drop, the ``drop=True`` escape hatch,
plus the paired legacy left-wins documentation and warning-emission
tests. Propagation guarantees land in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression coverage on the half of §11 that wasn't tested before:
non-conflicting aux coords carry through every binary operator and
into constraints. xarray already preserves them; the tests guard
against future drift (e.g. a reduction or helper accidentally
dropping a non-dim coord).

TestAuxCoordPropagation covers ``3*v``, ``v+5`` (single-operand,
fast paths), ``v+v`` with matching aux (the merge path), ``v<=10``
(the constraint path), ``x*a`` / ``x+a`` / ``x/a`` / ``x<=a`` where
only the constant DataArray carries the coord (the
``_align_constant`` path), and the var+var case where only one side
has the coord. Together: every operator times every "one side / both
sides" arrangement, since only conflicts on both sides raise.

Runs under both semantics — the legacy behaviour matches the v1
behaviour for the non-conflict cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… solve

Fills the convention-coverage gaps surfaced by review of the branch:

- §1/§2 dead-term storage invariant: pin that after a merge with an
  absent slot, coeffs=NaN AND vars=-1, not just const=NaN. The existing
  propagation tests read through isnull() which only checks const, so a
  regression in _absorb_absence would have passed them. Multi-operand
  variant catches binary-only-absorption regressions.
- §12 equality: mirror the existing <=/>= TestConstraintRHS coverage for
  ==. Subset RHS raises, NaN RHS raises, absence in LHS drops the row.
- §11 extra operators: add mul-constant and == constraint cases to the
  existing TestAuxCoordConflict. The class already covered +-constant
  and var+var; these extend coverage to the other call-site shapes.
- §13 scope note: mean/resample/coarsen aren't yet on LinearExpression
  (tracked in #703); the spec text is the rule those will follow when
  implemented. Docstring note in TestReductionsSkipAbsent makes this
  explicit so the gap doesn't read as missing coverage.
- End-to-end v1 solve: test_masked_variable_model_v1_drops_constraint
  pins the v1 outcome at the solver layer — con0 masked at absent
  slots (solver-independent) and x bound to 0 where the constraint
  still binds. _v1_fillna_binds confirms the §7 escape hatch recovers
  the legacy outcome. Catches the regression where v1 silently
  produces wrong solutions instead of raising.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the seven v1-specific helpers and the user-NaN message out of
``expressions.py`` and into a dedicated ``linopy/semantics.py`` module
— a single home for "what v1 means" that imports cleanly from
``config`` and ``constants`` only. Adds a tiny ``is_v1()`` predicate
so the 16 scattered ``options["semantics"] == V1_SEMANTICS`` checks
collapse to a one-line call.

Helpers (renamed to drop the leading underscore now that they're a
real module API): ``check_user_nan_scalar``, ``check_user_nan_array``,
``dim_coords_differ`` (was ``_shared_coords_differ`` — clearer name,
matches ``merge_shared_user_coords_differ``), ``merge_shared_user_coords_differ``,
``conflicting_aux_coord``, ``absorb_absence``, plus ``is_v1``.

No behaviour change — same checks, same warnings, same raises. The
diff is mechanical: imports flipped, two local ``is_v1 = options[...]``
bindings replaced by the imported predicate, one missed
``_USER_NAN_MESSAGE`` reference in ``to_constraint`` routed through
``check_user_nan_array`` for consistency. ``expressions.py`` shrinks
by ~105 lines.

Future v1-only API surface (e.g. exposing ``is_v1()`` as
``linopy.is_v1()`` for downstream code) and the eventual legacy
removal at 1.0 both reduce to deletions of ``semantics.py`` and its
import sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three test clusters in ``test_legacy_violations.py`` had near-identical
``test_add_X``, ``test_mul_X``, ``test_div_X`` triples that varied only
by which binary operator they exercised. Collapse each into a single
``@pytest.mark.parametrize("op", ...)`` test:

- TestExactAlignmentConstant: same-size-different-labels and
  subset-constant raises, parameterized over add/sub/mul/div.
- TestUserNaNRaises: NaN-DataArray raises over add/sub/mul/div, NaN
  scalar over add/sub/mul (div scalar shares the same ``_apply_constant_op``
  code path as mul, but ``x / nan`` trips ``__div__``'s unary-negate
  TypeError before our check fires; the dispatch needs a separate
  fix that's not worth pulling into this refactor).
- TestAbsencePropagation: ``shifted OP scalar`` preserves absence,
  parameterized over add/sub/mul/div. Adds a per-op present-slot
  value check so the parameterization broadens rather than narrows
  the assertion.

Adds a module-level ``_OPS`` dict mapping name → ``operator``
callable so the parameter is the readable name (``"add"``,
``"div"``) while the test still calls the actual operator.

Cuts ~50 lines off ``test_legacy_violations.py`` and makes adding a
new operator a one-line change. Test IDs become e.g.
``test_same_size_different_labels_raises[v1-add]`` — slightly less
self-describing than the explicit-method names but cheap to read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both methods had v1 and legacy logic interleaved via a ``fillna0``
closure that was identity under v1 and ``da.fillna(0)`` under legacy.
Pull them apart into:

- ``_add_constant`` / ``_apply_constant_op`` — two-line dispatchers.
- ``*_v1`` — v1's implementation, reads as a single coherent story.
- ``*_legacy`` — legacy's implementation, ``# LEGACY: remove at 1.0``
  marker on each.

At 1.0 the removal is mechanical: delete the ``_legacy`` methods and
inline the ``_v1`` body into the dispatcher (or rename it back to the
public name). Future readers don't have to mentally subtract the
legacy branches to understand what v1 does.

Add ``LEGACY: remove at 1.0`` marker comments at the other mixed
sites in ``expressions.py`` so ``grep`` finds every place that needs
touching: ``_align_constant``'s size-aware default fallback,
``to_constraint``'s auto-mask fallthrough, ``LinearExpression.isnull``'s
historical AND, and the two warn-on-divergence sites in ``merge``.

New ``arithmetics-design/legacy-removal.md`` is the master checklist
for the 1.0 cut: every file, function, test, doc edit, and the safe
order to do them in. The intent is that the eventual legacy removal
takes an afternoon, not a week of grep-archaeology.

No behaviour change — same checks, same warns, same raises. Suite is
7282 passed, 0 failures under both semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two distinct CI failures both rooted in the v1 harness commit:

1. **Test collection crash on every linopy/*.py module.** ``test/conftest.py``
   imported ``linopy.config`` at module top, which loaded linopy from
   site-packages before pytest's ``--doctest-modules`` collection walked
   the source tree. The resulting __file__ mismatch broke all 22 module
   collections. ``pyproject.toml`` already documents this exact failure
   mode in the ``filterwarnings`` block. Fix: keep the constant *values*
   (``"legacy"`` / ``"v1"``) inline in conftest as ``_LEGACY_SEMANTICS``
   etc. so the parametrize decorator doesn't force an import, and defer
   the ``LinopySemanticsWarning`` / ``options`` import into the fixture
   body. The original import comment in pyproject is now mirrored at
   the top of conftest.

2. **mypy: 72 "no-untyped-def" errors in test_legacy_violations.py.**
   The new tests were missing parameter type annotations on the
   fixture-injected params (``x``, ``xs``, ``op``, ``unsilenced``,
   ``subset``, ``A``, ``da_aux_B``, ...). ``disallow_untyped_defs`` is
   set globally, so test files need them too. Filled in the types
   (``Variable``, ``str``, ``None``, ``xr.DataArray``, ``pd.Index``),
   added an ``isinstance(result, LinearExpression)`` narrowing in
   ``test_variable_fillna_zero_revives_slot_as_present_zero`` so mypy
   can pick the right branch of ``fillna``'s return union.

Local: 7282 passed, 0 failures under both semantics; ``mypy .``
Success.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three v1 raises were under-informative — naming the rule violated but
not the operand, dim, or values involved. Make each message carry the
information the helper already has:

- **§5 user-NaN**: the old message conflated the two intents the user
  might have had — *data error* (fix with ``.fillna(value)``) vs
  *intended absence* (mark on the variable with ``mask=`` / ``.where``
  / ``.reindex`` / ``.shift``). The new message separates them and
  points each to its own remedy.
- **§8 merge mismatch**: rename ``merge_shared_user_coords_differ``
  (bool) to ``merge_shared_user_coord_mismatch`` (tuple ``(dim, left,
  right) | None``). Raise text now includes the offending dim name and
  both sides' labels (truncated), plus the full set of resolution
  paths from §10: ``.sel`` / ``.reindex`` / ``.assign_coords`` /
  ``linopy.align`` / ``join=`` on ``.add`` / ``.sub`` / ``.mul`` /
  ``.div`` / ``.le`` / ``.ge`` / ``.eq``.
- **§11 aux-coord conflict**: ``conflicting_aux_coord`` returns
  ``(name, left_vals, right_vals) | None``. Raise text includes the
  coord name, both value snippets, and all three resolution paths
  (``.drop_vars`` / ``.assign_coords`` / ``isel(drop=True)`` —
  ``.assign_coords`` was previously omitted). The text is now
  centralized in ``semantics.py`` so the two raise sites in
  ``expressions.py`` (``_align_constant`` and ``merge``) share one
  voice instead of paraphrasing each other.

New ``TestErrorMessageContent`` pins the rich content in three tests
— that the §5 message names both intents, that the §8 message names
the dim and both label lists, and that the §11 message names the
coord, both value lists, and lists all three §11 fixes (the
``.assign_coords`` omission would have slipped through ``match=
"Auxiliary coordinate"`` substrings).

Section references (``§5``, ``§8``, ``§11``) deliberately omitted
from user-visible text — spec jargon, not a navigation aid for
downstream callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the small-but-real holes in the §1–§13 coverage map. New tests
mostly, plus one code fix that the test surfaced.

§4 — absence creation
  - test_where_creates_absence: §4 names ``.where(cond)`` but only
    ``mask=`` / ``.reindex`` were tested.
  - test_unstack_creates_absence_at_missing_combinations: the
    non-rectangular MultiIndex case (``stack`` preserves, ``unstack``
    fills) is the asymmetry that earns its own test. Hit a real bug
    on the way — ``Variable.unstack`` was producing float NaN in the
    integer ``labels`` field instead of the ``FILL_VALUE`` sentinel
    (-1), violating §2. Fixed by passing ``fill_value=_fill_value``
    to the underlying ``Dataset.unstack`` (same pattern as ``shift``).
    Audited the rest of the varwrap calls — only ``shift`` and
    ``unstack`` introduce new positions; the others either preserve
    shape (``assign_*``, ``rename``, ``swap_dims``, ``set_index``,
    ``roll``, ``stack``), select existing positions (``sel`` /
    ``isel`` / ``drop_*``), or broadcast existing data without fill
    (``broadcast_like``, ``expand_dims``).
  - test_data_preserving_methods_do_not_create_absence: parameterized
    over ``.roll`` / ``.sel`` / ``.isel``, regression-guards §4's
    explicit contrast against the creators.

§10 — named-method join= argument
  - test_add_join_override_aligns_positionally: positional-mode is the
    surprising one in the join= set; pin it explicitly.
  - test_reindex_like_resolves_mismatch_before_bare_op and
    test_assign_coords_resolves_mismatch_before_bare_op: §10 names
    these as the canonical user fixes; pin that the post-fix bare
    operator actually accepts the once-mismatched operand.

§11 — auxiliary-coordinate conflicts
  - test_assign_coords_resolves_conflict: §11 lists three escape
    hatches; only ``.drop_vars`` / ``isel(drop=True)`` were tested.
  - test_multi_operand_merge_aux_conflict_raises: the merge-path
    check inspects all operands; a 3-way ``v + w + u`` with the
    third disagreeing exercises that.

§12 — constraints follow the same rules
  - Parameterize the existing subset / NaN / absence-propagation
    tests in ``TestConstraintRHS`` over the three signs (``le`` /
    ``ge`` / ``eq``) via a new module-level ``_SIGNS`` dispatch.
    Folds the previous ``<=`` and ``==`` duplicates together and
    fills in ``>=`` for each rule (which was the explicit gap).
    The PyPSA #1683 test stays separate — it's tied to ``>=`` by
    the real-world case it documents.

Suite: 7303 passed, 515 skipped, 0 failures under both semantics.
``mypy .`` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two regression guards and one stale comment fix. No production code
change.

- ``test_nan_in_expression_used_in_objective_raises``:
  ``m.add_objective((x * nan_costs).sum())`` raises at the ``*``
  before ``add_objective`` ever sees the expression. Caught upstream
  already — guards against a regression that would let a NaN-cost
  objective slip through.
- ``test_nan_in_constraint_lhs_raises``: ``(x + nan_da) <= 5`` raises
  at the ``+``. RHS-NaN was already covered; this pins the symmetric
  LHS case.
- ``test_nan_scalar_raises``: drop the comment that ``x / nan`` trips
  ``__div__``'s TypeError before our ValueError — that was fixed by
  an earlier change to ``Variable.__mul__``'s scalar fast-path
  routing (``__truediv__`` reuses the same dispatch). The
  parameterization now covers ``add`` / ``sub`` / ``mul`` / ``div``
  uniformly.

Not added: a strict ``add_objective`` NaN-const check. The convention
(§13 — "the objective totals its terms the way ``sum`` does") allows
absent slots in the objective, and the solver writer implicitly
strips them — masked-variable patterns like ``m.add_objective(2 * x
+ y)`` (with ``y`` mask=…) rely on this. Adding a strict check at
the boundary would force every such test to write ``y.fillna(0)``
explicitly, which is too invasive for this PR. The one remaining
gap — hand-built ``LinearExpression(... const=NaN ...)`` passed
into ``add_objective`` — is a sharp edge case left for follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the goal-#2 gap: legacy users now get warnings that name *what*
will change for the operation they just ran, not just "legacy is going
away."

Adds a per-site message helper per divergence class in
``linopy/semantics.py`` (``_legacy_nan_constant_{add,mul,div}_message``,
``_legacy_coord_mismatch_message``, ``_legacy_aux_conflict_message``,
``_legacy_nan_rhs_constraint_message``, ``_legacy_masked_variable_message``)
plus a shared ``warn_legacy(msg)``. Each message is formatted with
linebreaks — a one-line summary, a ``Resolve:`` block, then ``Opt in``
/ ``Silence`` lines.

The per-operator distinction matters: ``+`` / ``-`` / ``*`` fill NaN
with 0; ``/`` fills with **1** (the asymmetric fill from #713). The
mul/div distinction was previously lost behind a generic message —
the new `check_user_nan_*` helpers take an ``op_kind`` parameter and
pick the right text per call site (`_apply_constant_op_legacy` derives
``op_kind`` from ``fill_value``).

The biggest gap was that ``2 * x + y`` (masked ``y``, no fillna)
under legacy fired *no* warning at all — no NaN constant, no coord
mismatch, no aux conflict reached any existing warn site. The new
``_legacy_masked_variable_message`` fires inside ``Variable.to_linexpr``'s
legacy path whenever the variable carries sentinel labels, so the
divergence is caught at its origin.

``TestLegacyWarning`` now pins each emission with ``match=`` (regex
with ``(?s)`` where the pattern spans the message's linebreaks):
- ``Coordinate mismatch`` for the const-path coord mismatch
- ``Coordinate mismatch`` for the subset constant
- ``treated as 0`` for `+`/`-` NaN
- ``multiplicative factor.*treated as 0`` for `*` NaN
- ``divisor.*treated as 1`` for `/` NaN (the asymmetric one)
- ``'y'.*fillna`` for the masked-variable arithmetic case
- ``merge along dim`` for the merge-path coord mismatch

Two existing warning tests in other classes also gain ``match=``:
- ``test_warn_on_nan_rhs`` → ``no constraint at this row``
- ``test_warn_on_aux_conflict`` → ``'B'.*silently dropped``
- ``test_warn_on_var_plus_var_different_labels`` → ``merge along dim``

The generic ``LEGACY_SEMANTICS_MESSAGE`` from ``config.py`` is no
longer referenced from ``expressions.py``; will be removed at 1.0
with the rest of the legacy plumbing (already in the removal
checklist).

Suite: 7310 passed, 522 skipped, 0 failures under both semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes addressing reviewer feedback (PR #717):

**1. Full-text legacy-warning assertions** (the reviewer's
suggestion: tests double as the message spec). Replaces the
``match=`` regex fragments in ``TestLegacyWarning`` with
equality-against-the-full-message assertions for each warn site:
coord mismatch (const-operand same-size + subset, merge path),
NaN addend / multiplier / divisor, aux conflict, NaN constraint
RHS, masked variable in arithmetic. Each test reads as a small
spec — reviewing the message wording = reading the test, and any
change to a message surfaces as a diff. Adds a tiny
``_one_legacy_warning(*ops)`` helper to keep each test focused on
the text, not the warning-capture plumbing.

**2. Symmetric diagnostics in legacy warns** (reviewer follow-up
1). The v1-raise messages already named the offending dim and
showed both sides' labels; the legacy warns just said "merge
along dim 'time'" without the diff. Refactor
``_legacy_coord_mismatch_message`` / ``_legacy_aux_conflict_message``
to accept ``(dim, left, right)`` / ``(name, left, right)`` and
render them via the existing ``_short_repr`` formatter — same
shape as the raise text. Adds a new ``first_mismatched_dim``
helper that returns ``(dim, a_labels, b_labels)`` so the
``_align_constant`` legacy default can pass through what it
finds. ``merge_shared_user_coord_mismatch`` and
``conflicting_aux_coord`` already returned tuples — wired the
values through to the warn sites too.

**3. Stdlib stacklevel + docs note** (reviewer follow-up 2). The
old static ``stacklevel=3`` was provably wrong: depth from
``warn_legacy`` to the user varies per site (5 frames for
``expr + masked_var`` via ``__add__``, 4 for ``var.fillna(0)``,
others elsewhere). On Python 3.12+ use stdlib
``warnings.warn(skip_file_prefixes=(linopy_root,))`` — exactly
this case, implemented by the CPython maintainers. On 3.11 fall
back to a static ``stacklevel=5`` (correct for the common merge
chain; overshoots on shorter ones — the warning *text* is
identical either way, only the source frame is approximate).

``test_warning_stacklevel_points_to_user_call`` pins the
3.12 case; the 3.11 case happens to work for the masked-variable
chain (depth 5) so the test passes on both. Verified on local
3.11 and a fresh ``uv venv --python 3.12``.

New ``arithmetics-design/docs-plan.md`` collects bullet points
for the eventual user-facing migration guide (deferred from this
PR). Includes the Python 3.12+ stacklevel-improvement note as a
known-limitation entry so it doesn't get forgotten when the
guide gets written.

Suite (3.11): 7313 passed, 525 skipped, 0 failures under both
semantics. Suite (3.12, minus oetc extras): 6067 passed, 0
failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In both `_align_constant` and `merge`, the `conflicting_aux_coord(...)`
guard was nested inside `if join is None:`, so an explicit `join=`
(any of "exact", "override", "inner", "outer", "left", "right")
bypassed §11 entirely and the #295 silent-aux-drop bug was still
reachable via `.add(const, join="override")` etc. The aux check is
independent of dim alignment: it must run before xr.align / xr.concat
sees the data, regardless of how the caller resolves the §8 mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two coupled fixes in the quadratic build path:

1. `merge(..., dim=FACTOR_DIM)` called `.prod(FACTOR_DIM)` on coeffs
   and const with xarray's default `skipna=True`, so an absent factor
   silently became multiplicative identity 1 and the product came back
   present. Apply the same `skipna = not is_v1()` treatment the
   TERM_DIM branch already uses.

2. The cross-term machinery in `_multiply_by_linear_expression`
   multiplied `self.const * other.reset_const()` directly. Under v1,
   `self.const` is an internal §6-propagated field carrying NaN at
   absent slots; routing it back through the public-API `*` hit the
   §5 user-NaN check and raised. `fillna(0)` the const factor first:
   the zero contribution at an absent slot adds nothing, and the
   FACTOR_DIM merge above already left absence in `res`, so absence
   survives end-to-end and `absorb_absence` enforces §1/§2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strengthens the single ``var * var`` regression test into six builds —
``var * var``, ``var ** 2``, ``expr * var``, ``expr * expr``,
``quad + linexpr``, ``quad * scalar`` — to pin that every path that
ends in a QuadraticExpression keeps an absent factor absent. Audit
follow-up to the FACTOR_DIM / cross-term fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The direct ``to_linexpr(coefficient)`` entry bypassed §5 because the
NaN check lived only inside the operator overloads
(``_apply_constant_op``). Callers that built expressions explicitly
(``var.to_linexpr(my_coefficient_array)``) had user NaN flow into
``coeffs`` silently — §6 would then propagate absence downstream,
masking what was actually a data error. Add a single
``check_user_nan_array(op_kind="mul")`` before the v1/legacy branch;
the default coefficient ``1`` carries no NaN, so the check is a
no-op for the common case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
convention.md §10 documents ``override`` as "positional alignment,
made explicit". Positional pairing is only well-defined when shared
dims have matching sizes — the legacy positional path explicitly
gated on ``other.sizes == self.const.sizes`` before doing the
``assign_coords`` rename, but the v1 ``override`` branch in
``_align_constant`` dropped that gate, so a size-mismatched override
either silently broadcast or raised opaquely from xarray.

Add a per-shared-dim size check that surfaces the mismatch with a
clear error and a list of fixes (other join modes / reshape first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FBumann and others added 25 commits July 16, 2026 09:32
Variable.to_linexpr under v1 unconditionally built an `absent` mask, a
`where(~absent)` copy and an explicit all-zero `const` array, even for a
fully-dense variable that carries no absent slots. On the isolated
`var * array` (match) path those are all no-ops but still allocate
full-size temporaries — the CodSpeed test_op[var_mul_array_match] memory
regression (212 -> 306 KB) and the small-model test_build peaks.

Gate the mask/where/const on `has_absence`. Dense variables now build the
same lean coeffs+vars expression as legacy; masked variables still carry
NaN at absent slots for §6 propagation. Isolated var*array peak (memray):
v1 5000-elem 133.7 -> 78.1 KiB, 200k 5275 -> 3125 KiB (now below legacy).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two follow-ups to the §8-exact work (#831/#834), from an open-items.md audit.

Gap 1 — transition-surface hole: after #831 made a pure reorder raise under v1,
reordered *constant* operands (`x + array`, `-`, `*`, `/`, rhs) were still
silently reindexed by label under legacy with no warning, breaking the
"no silent change" guarantee (coeff and merge already warned). Thread a
`warn_reorder` flag broadcast_to_coords -> _reindex_reordered_dims, set at the
arithmetic const/rhs sites; legacy now emits `_legacy_const_reorder_message`
(accurately: reindexed by label, not positional). Legacy result unchanged;
no double-warn; construction (bounds/mask, strict=True) stays silent.

Groupers — add a strict-alignment paragraph to convention.md §13: a groupby
grouper aligns to the grouped dimension by §8 (reorder or set-mismatch raises,
never positional), multi-key -> flat group dim + aux coords. Implementation is
#830 (on master, lands here on merge). open-items.md records the §8-exact
decision and the pending grouper landing.

Full suite green; ruff/mypy clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`broadcast_to_coords` leaves the coefficient spanning the variable's dims
but with fresh index objects (equal to `labels`, not identical). The
follow-up `coefficient.reindex_like(self.labels)` therefore deep-copied the
whole coefficient to no effect on the exact-match `var * array` path — the
sole source of the CodSpeed test_op[var_mul_array_match] memory regression
(217 -> 311 KiB, +43%). Every other op was byte-identical to master.

Add `reindex_like_if_needed`: reindex only when a `ref` dim is absent or
`first_mismatched_dim` reports a disagreement (the same shared-dim check
the alignment rules use), else return the array untouched. A reorder or
subset coefficient still reindexes (legacy aligns, v1 raises); an aligned
one skips the copy.

Isolated op peak (memray, GRID 3x4x1000), legacy default:
  var_mul_array_match  311 -> 217 KiB (master parity)
  var_mul_array_bcast  998 -> 593 KiB (redundant reindex hit bcast too)
v1 also drops (match 188 -> 106, bcast 470 -> 124). Full suite 7813 passed
under both semantics.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndex (#840)

Transition-surface audit (working through open-items.md) found one silent
legacy->v1 divergence: `groupby([names]).sum(observed=True)` mints a stacked
`group` MultiIndex under legacy (v1 returns a flat dim + aux coords) but emitted
no warning — only DataFrame groupers did. A legacy model consuming
`.sel(group=(...))` would break under v1 with no deprecation notice.

- _restore_multikey_index: warn whenever legacy keeps a *surviving* group
  MultiIndex (drop the DataFrame-only `user_facing` gate); reword the message.
- The warning now offers a robust migration: `.reset_index('group')` yields
  exactly the v1 flat result (pinned by assert_linequal from the same expr).
- Tests: namelist+observed warn/flat, the reset_index->v1 equivalence; scope
  test_observed_silences_blowup_warning to the blowup warning.
- open-items.md records the audit; no other silent fork site found.

Full suite green (7840 passed); ruff/mypy clean (pre-existing solvers.py ignore
aside).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#844)

fill_missing_coords did `ds = ds.copy()` unconditionally, then filled a
coord only for dims that lack one. When every dim already has a coord — the
overwhelmingly common case, including every `var * array` op — it returned a
byte-identical deep copy that is immediately discarded.

Because `as_dataarray` ends in `fill_missing_coords`, the §8 check
`coeff_da = as_dataarray(coefficient)` in Variable.to_linexpr copied the whole
coefficient and held it live through reindex+fillna, inflating build peak on
the exact-match path (the remaining half of the test_op[var_mul_array_match]
regression after #838).

Compute the missing dims first; copy (and mutate) only when there is
something to fill, else return `ds` untouched. The copy still guards the one
mutation it ever performs. memray, legacy default, GRID 3x4x1000:
var_mul_array_match 217 -> 123 KiB, var_mul_array_bcast 998 -> 593 KiB.
Full suite 7840 passed under both semantics.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Features entry announcing v1 (`options["semantics"] = "v1"`, legacy stays
the default) with the strict-alignment / user-NaN / absence / aux-coord /
MultiIndex summary and a link to the convention. Mark the changelog and
grouper-landing (#830 merged) items done in open-items.md.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `doc/migrating-to-v1.rst` (in the User Guide toctree): why v1 exists, the
opt-in → default → 1.0 rollout, the three audiences, the migration recipe
(surface warnings via LinopySemanticsWarning-as-error, fix per site, opt in,
release), and a situation → v1 behaviour → fix table (user-NaN, label-set /
reorder mismatch, masked-variable absence, aux-coord conflicts, MultiIndex
dims, multi-key groupby). Links the convention for the normative rules.

Marks the migration-guide open item done; docs-plan.md points to the guide.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(v1): don't warn on exactly-aligned @/dot under legacy (#849)

The legacy alignment path in LinearExpression._align_constant warned
whenever the constant operand's size dict differed from the expression's.
But a size difference can come purely from disjoint dims (e.g. `x @ C`,
where C carries its own contracted-out dim) — that is ordinary
broadcasting, not a shared-dim misalignment. v1 accepts it, so the legacy
LinopySemanticsWarning was a false positive on already-aligned operands.

Warn only when first_mismatched_dim reports a real shared-dim
disagreement, mirroring the sizes-equal branch, and collapse the
duplicated warn/branch into one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: simplify legacy _align_constant branch (single return)

Name the aligned result and return once instead of repeating the
(self.const, ..., False) triple, and move the "positional when sizes
match" comment onto the branch it describes. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…entions (#847, #848) (#851)

* fix(v1): make Variable.fillna(scalar) resolve absence under both conventions (#847, #848)

Variable.fillna(<scalar>) is the documented resolution for absent slots
(from shift/where/reindex/mask), but under legacy it misbehaved two ways:

- #847: it warned. fillna internally calls to_linexpr(), whose legacy path
  emits the masked-variable LinopySemanticsWarning — even though fillna is
  itself the resolution that warning points to. So the documented fix
  couldn't be written warning-free on both conventions.
- #848: it silently dropped the fill value. Legacy to_linexpr marks absent
  const as 0 (not NaN), so the subsequent LinearExpression.fillna had
  nothing to fill; fillna(5) left 0 at absent slots while v1 put 5.

The v1 path already behaved correctly, so this is a pure legacy workaround
(marked LEGACY: remove at 1.0): keep the clean one-liner under v1, and under
legacy place the value at the -1 labels directly and skip the absence warning.
Result: `var.shift(1).fillna(v)` is now a single form, identical under both
conventions (same vars/const; only the immaterial phantom coeff differs), so
downstream migrating to v1 no longer has to version-gate the expression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(v1): note legacy no-op of LinearExpression.fillna on absent slots

Document why var.to_linexpr().fillna(v) is a no-op under legacy (absence is
already materialised as const=0, so there is no NaN to fill) and point at
Variable.fillna as the cross-convention resolution. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Polish for the #717 "polish the tests" checklist, scoped to the v1/legacy
groupby-MultiIndex divergence tests in test_linear_expression.py:

- Replace the manual try/finally toggle of options["semantics"] in
  test_group_multiindex_reset_index_matches_v1 with the `with options as o`
  context manager, so semantics state is restored even if the assertion
  fails (it was the only test bypassing the conftest marker mechanism).
- Swap internal `.data.indexes` / `.data.coords` reaches for the public
  `.indexes` / `.coords` accessors, matching the already-public sibling
  tests in the same class.

No behaviour change; 584 passed, ruff clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sages (#853)

Follow-up polish for the #717 "polish the tests" checklist:

- Move the four masked-addend §6 tests (absence propagation of a fully
  masked term in a sum) out of test_linear_expression.py into
  TestAbsencePropagation in test_legacy_violations.py, where the rest of
  the §6 coverage lives. They now share one `ab_all_masked` fixture instead
  of rebuilding an inline two-variable model four times.
- Add `match="only supported for"` to the four bare `pytest.raises(ValueError)`
  guards on the `use_fallback=True` DataFrame-grouper path, so they assert
  *why* the fallback raises rather than accepting any ValueError. The fifth
  guard (issue #351) keeps its precise `(KeyError, IndexError)` — its message
  is an incidental numpy index error, brittle to pin.

No behaviour change; test_legacy_violations.py 294 passed,
test_linear_expression.py green, ruff clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rop dead code (#854)

Three follow-ups from the #717 review:

- §5 user-NaN check on the scalar fast path used `isinstance(other, float)`,
  which misses `np.float32`/`np.float16` NaN scalars (they don't subclass
  Python `float`). Such a scalar was silently added/multiplied into the
  expression instead of raising (v1) / warning (legacy). Add
  `semantics.is_nan_scalar` (float | np.floating) and use it at all four
  scalar sites.

- `conform_merge_dims` called `Index.get_indexer` on a shared dim with
  non-unique labels, raising an opaque `InvalidIndexError` — a regression
  vs. master, which aligned duplicate labels positionally. Guard on
  `idx.is_unique` so a non-unique differing index is reported as a §8
  mismatch: legacy aligns positionally + warns, v1 raises the canonical
  "Coordinate mismatch" ValueError.

- Remove the unused `LEGACY_SEMANTICS_MESSAGE` constant and the stale
  `EvolvingAPIWarning` filter cells in the piecewise notebooks (obsolete
  under `semantics="v1"`; the warning was renamed to LinopySemanticsWarning).

Adds regression tests for the NaN-scalar dtypes and the duplicate-label
merge on both conventions.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to MI row (#855)

The migration guide's fix table covered the core absence + alignment +
aux-coord + MultiIndex-dim + groupby-result cases, but three issue-backed
v1 changes were under-covered. Add rows and mirror two into the
release-notes v1 summary:

- Unlabeled operand (numpy / list / polars) pairs to dims by size;
  ambiguous or no-size-match raises (#736) — the object-scope premise.
- A reordered/mismatched groupby grouper raises rather than reindexing
  positionally (#827).
- MultiIndex-level projection: v1 rejects the MultiIndex *dimension* at
  construction, so the per-level-input case is folded into the existing
  MultiIndex row — decompose with reset_index, then project by the level
  aux coord. (The convention.md §11 example still shows the legacy-shaped
  `.get_level_values` snippet, which cannot run under v1 — flagged for the
  spec pass.)

Release notes already carry the level-projection deprecation separately,
so only #736 and the grouper point are mirrored there.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…raduation (#856)

The §11 example used `expr.indexes["snapshot"].get_level_values("period")`,
which cannot run under v1: v1 rejects a first-class `pd.MultiIndex` dimension
at construction, so `snapshot` is a flat dim with `period`/`timestep` as aux
coords and `.indexes["snapshot"]` is a flat index, not a MultiIndex. Replace it
with the working aux-coord mapping (verified on the branch) and add a one-line
note on the flat representation.

Also record in open-items.md that convention.md graduates into the rendered
Sphinx docs at the 1.0 reframe (add myst-parser, toctree, internal cross-refs);
until then it stays a design-record linked by URL — the rules are permanent but
the transitional framing would only be re-done, so rendering now is wasted.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e) (#858)

Positional indexing leaves a scalar coordinate behind: `x.isel(time=0)` drops
`time` as a dimension but keeps it as a scalar coord (the first label). So a
cyclic/boundary constraint like `x.isel(time=0) == x.isel(time=-1)` — extremely
common in storage/energy models — hits §11's aux-coord conflict under v1
(`time` = first vs last) and raises, where legacy silently dropped it.

The aux-coord row already covered this in principle, but listed only
`.drop_vars` / `.assign_coords` as fixes and didn't name the scalar-leftover
cause. Broaden the migration-guide row and add the natural fix — drop the coord
at the indexing site with `.isel(..., drop=True)` / `.sel(..., drop=True)` — and
add the same note to convention.md §11.

Verified on the branch: `x.isel(time=0) == x.isel(time=-1)` raises
"Auxiliary coordinate 'time' has conflicting values ... left=0, right=3" under
v1; `drop=True` on both sides builds cleanly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(v1): polish nan-scalar + duplicate-label merge tests (dedup, trim comments)

* test(v1): polish the #717 convention test suite

Applies the maintainer's polish guidelines across the v1/legacy semantics
tests (test_legacy_violations.py, test_convention.py):

- Assert FULL error/warning message text (hardcoded literals) instead of
  partial `match="..."` substrings, so every message change surfaces as a
  test diff. Covers all v1 ValueError raises and legacy LinopySemanticsWarning
  sites across the 17 convention classes.
- DRY: dedupe repeated operand/coord setup into fixtures; merge near-identical
  tests via @pytest.mark.parametrize (per-operator NaN/mismatch cases,
  quadratic build paths, join modes, object-scope operand kinds, ...).
- Trim essay-length docstrings/inline comments to one crisp line where the
  rationale isn't load-bearing.

Coverage is preserved or expanded (500 vs 480 collected invocations); no
tests removed. Suite green under both semantics; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…#861)

Relocates the load-bearing v1 specs out of the untracked-by-Sphinx
arithmetics-design/ folder into the rendered docs site:

- Convert convention.md / goals.md / legacy-removal.md → reStructuredText
  under doc/design/ and wire them into a "Design & Internals" toctree in
  doc/index.rst. rst (not markdown) so no myst-parser dependency is needed.
- Update every path reference: linopy/semantics.py and the five
  `LEGACY: remove at 1.0` comments in linopy/expressions.py, the
  test_legacy_violations.py header, and the arithmetics-design/ scaffolding
  files (open-items.md, docs-plan.md) that link to them.
- release_notes.rst and migrating-to-v1.rst now cross-reference the rendered
  convention page via :doc: instead of a GitHub blob URL.

The three process/scaffolding files (docs-plan.md, open-items.md,
multiindex-feasibility.md) stay in arithmetics-design/ as internal notes.

Docs build clean (sphinx-build exit 0, all three pages render, no new
warnings); ruff + the convention test suite pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prototype for #745/#756 option 1 (deferred groupby): hold (expr, grouper)
unmaterialized and realize the balance constraint straight from ungrouped
long triplets via scipy COO->CSR (duplicate summation == the group sum).
No padded _term rectangle ever exists; CSRConstraint plugs into the
existing LP/matrix export unchanged.

Equivalence: identical polars term rows and LP files vs the dense
groupby path (incl. permuted group order). memray peaks on the #745 hub
scenario (120 buses, 24 snapshots, build-only, setup baseline ~102MB):

  hub gens   dense      deferred
  8000       846.6MB    196.8MB   (constraint part: ~745MB vs ~95MB)
  16000      1213MB     223.0MB

dev-scripts is gitignored; files force-added to preserve the prototype
on this branch only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Internal-state redesign of the deferred-groupby prototype: no new public
primitive. groupby(g).sum(lazy=True) (or options['lazy_groupby'] under
v1) returns an ordinary LinearExpression whose payload is a LazyGroupSum
(ungrouped parts + groupers) instead of the materialized dense dataset,
modeled on dask-backed xarray. The .data property materializes through
today's kernel, so any operation without a lazy branch transparently
falls back to exactly today's result. Lazy branches: neg, scalar mul,
merge along _term (covers +/-), comparison with a constant rhs, and
Model.add_constraints with freeze=True, which realizes the constraint
directly as a CSRConstraint from long triplets (COO->CSR duplicate
summation is the group sum) - the #745 padded rectangle never exists.

Gated behind v1 semantics; under legacy, lazy=True raises and the
option is ignored. v1 parity kept: NaN rhs raises (par.5), reordered
rhs raises (par.8), absent const rows realize as masked (par.12).

memray, #745 hub scenario (120 buses, 24 snapshots, setup ~107MB):
  hub gens   eager       lazy
  8000       851.9MB     208.4MB
  16000      1219MB      223.3MB

Full suite: 6336 passed, 557 skipped (test/remote failures pre-exist
on the branch). Includes test/test_lazy_groupby.py (10 cases x
legacy/v1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the LazyGroupSum recipe payload with CSRPayload (linopy/csr.py):
groupby(g).sum(sparse=True) (or options['sparse_groupby'] under v1) now
builds the grouped sum eagerly in CSR form — rows are flat grid cells,
columns raw variable labels, duplicate variables summed, terms
label-ordered — behind the unchanged LinearExpression type. Operations
become sparse linear algebra: grouping scatters into rows (G @ A), merge
along _term (and thus +/-) is sparse addition, neg/scalar-mul scale
values, and add_constraints(freeze=True) staples sign/rhs on to form a
CSRConstraint directly. Anything else expands to the dense rectangle in
canonical form via .data (mathematically identical, term layout
canonicalized — the reason the feature stays v1-gated).

Vs the recipe: ops are real algebra with immediate errors instead of a
deferred parts list, chains stay compact, and the payload is the natural
seam for future sparse ops (dot #748, ragged merge #749). Cost: the
bit-identical fallback is replaced by the canonical-form contract.

memray, #745 hub scenario (120 buses, 24 snapshots, setup ~107MB):
  hub gens   eager       sparse (CSR)   [recipe was]
  8000       850.9MB     216.2MB        208.4MB
  16000      1219MB      234.1MB        223.3MB

Full suite: 6336 passed, 557 skipped (unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FBumann FBumann added this to the Post v1 milestone Jul 24, 2026
Replace dev-scripts/proto_deferred_bench.py with a suite entry:
nodal_balance_sparse builds the identical balance via sum(sparse=True) +
freeze=True under v1 (phases: build/matrices/to_lp). Paired with the
existing nodal_balance severity sweep, the padding cost becomes CI-visible:

  pytest benchmarks/ -k 'nodal_balance and build' --benchmark-memory
  severity        0        50       100
  dense (KiB)   951     5,460     9,909
  sparse (KiB) 1,524    1,524     1,524   (and ~1.6x faster builds)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@FBumann FBumann changed the title feat(v1): CSR-backed sparse groupby-sum — skew-independent build memory (851→216 MB on the #745 hub case) feat(v1): CSR-backed sparse groupby-sum - skew-independent build memory (6-9x on the #745 hub case) Jul 24, 2026
DRY the payload module (shared rhs-alignment helper, comprehension-based
assembly), fold inline comments into docstrings, shrink the module and
kwarg docs. No behavior change: sparse suite, nodal_balance benchmarks
smoke and core expression/constraint tests unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Base automatically changed from feat/arithmetic-convention to master August 19, 2026 06:06
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.

2 participants