Skip to content

feat: v1 semantic convention#717

Draft
FBumann wants to merge 38 commits into
masterfrom
feat/arithmetic-convention
Draft

feat: v1 semantic convention#717
FBumann wants to merge 38 commits into
masterfrom
feat/arithmetic-convention

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented May 21, 2026

Draft PR

The strict v1 semantic convention for linopy — predictable coordinate alignment and NaN handling.

This is the master PR for the new semantic convention in linopy. It starts with our Design & transitioning goals, which is carried out in our New Semantics spec. Both files are tracked in this branch. WHat you read is the current state.

Both might change until this PR is merged

Scope

The convention ships behind linopy.options["semantic_convention"]v1 opt-in, legacy the default. This PR carries the design, spec, tests and implementation; documentation notebooks follow separately.

Testing

All tests in linopy will be executed for both semantics.
Differing behaviour will be tested using pytest.markers.

This will increase ci time temporarily until v1 is released.

Defered to a follow up PR

Docs: Documentation, Migration guide, Release notes

FBumann and others added 2 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>
@FBumann FBumann force-pushed the feat/arithmetic-convention branch from 40af9d6 to 1e336a4 Compare May 21, 2026 12:48
@FBumann FBumann mentioned this pull request May 21, 2026
4 tasks
FBumann and others added 3 commits May 21, 2026 20:39
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>
FBumann and others added 6 commits May 23, 2026 12:54
`_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>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 23, 2026

Coming next, in order:

  • Slice F — §11 auxiliary-coordinate conflicts. Raise on non-dimension-coord conflict during alignment (covers Auxiliary non-dimension coordinates leak into expressions and break alignment #295). Small.
  • Slice G — §13 reductions audit. Most reductions already use skipna=True; need a focused audit + tests for sum / mean / groupby / resample / coarsen and the objective. Likely mostly tests.
  • Slice P — linopy/piecewise.py and linopy/sos_reformulation.py. Internal callers build expressions by .isel(...)-slicing along a piece dim and comparing the two slices (delta_hi <= delta_lo); the slices share the dim with different coords, which v1 §8 rejects. Until this lands, test_piecewise_constraints.py + test_piecewise_feasibility.py carry a module-level pytestmark = pytest.mark.legacy and two SOS2 tests are method-marked legacy. Fixing piecewise removes those marks.

Then a final pass on docs (user-facing migration / rollout — deferred so far).

FBumann and others added 4 commits May 23, 2026 20:44
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>
FBumann and others added 7 commits May 23, 2026 22:17
… 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>
@MaykThewessen
Copy link
Copy Markdown
Contributor

MaykThewessen commented May 24, 2026

Thanks @FBumann — read goals.md + convention.md + the [#714] catalogue. Priority ordering (no-silent-wrong-answers > algebraic-laws > absence-first-class > least-surprise) reads exactly right for an optimisation library. Notes from the PyPSA-Eur side, ordered by leverage:

Highest leverage: lock the §5 / [#627] question before v1 GAs

§5 ("user NaN raises") is the safe default, but if [#627] later flips to "user NaN means absent", code written defensively for strict-v1 — peppering fillna(...) at construction sites — will quietly behave differently than the same code under v1-with-#627. Two semantic flips inside the v1 lifecycle costs trust. Worth deciding §5 before v1 promotes to default, even at the cost of slipping the schedule. My vote: keep raise — in PyPSA-Eur, NaN in user data is almost always an index-mismatch bug, and goal 1 is the whole point.

Biggest migration cost is §8, not §5

PyPSA-Eur builds expressions against n.snapshots[mask] subsets and currently relies on size-coincidence positional behaviour. To make the legacy → v1 step survivable on a 50k-LOC codebase, the warning needs to identify the exact mismatch: (dim, missing_labels[:5]), not just "shared dim mismatch". And — bigger ask — a linopy.diagnose_alignment(model) helper that walks every stored expression and lists every site v1 would raise, runnable under legacy, would let users patch one site at a time before flipping the switch.

Happy to PR this helper once the spec stabilises.

§10 needs an operator-syntax escape hatch

join= lives on named methods only. A scoped context manager — with linopy.alignment(join="override"): ... — would let people refactor incrementally without rewriting every a + b into a.add(b, join="override"). Especially useful where the positional override is intentional (some legacy PyPSA stats code). Scoped, not global; threadlocal.

Happy to PR this too.

Spec gaps worth nailing down

  • §13 — mean over an all-absent dim is 0/0. Pick one: result is absent (NaN propagates) or raise. My vote: absent, consistent with §6's "absence absorbs".
  • §5 — does the rule cover bounds and RHS, not just coeffs / const? add_variables(lower=series_with_nan) today silently broadcasts. Under v1 I assume it raises; spec should say so. Same for constraint RHS.
  • §11 — error message on aux-coord conflict should print both values (or .head() thereof), not just the coord name. We've been bitten by [Auxiliary non-dimension coordinates leak into expressions and break alignment #295] in PyPSA-Eur post-processing where a carrier aux coord silently disappears after expr.sum("snapshot"); the diagnostic matters.

On my #715

Once v1 is opt-in, the six xfail-strict tests flip to XPASS under options["semantic_convention"]="v1". Happy to parametrize the file over legacy / v1 so it tracks the rollout schedule rather than only gating on the eventual default — turns #715 into a v1 conformance harness.

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>
@MaykThewessen
Copy link
Copy Markdown
Contributor

Lovely commit @FBumann — two upgrades I didn't ask for that I should have:

  • Per-op message split (+/- → 0, * → 0, / → 1) puts the NaN in user-supplied data is silently filled instead of surfaced #713 asymmetric fill in the warning text. Users can grep CI logs by op_kind.
  • _legacy_masked_variable_message in Variable.to_linexpr catches the 2 * x + y (masked y, no NaN constant) case where no other warn site fires. That's the most common PyPSA-Eur divergence and would have migrated silently. Excellent.

Three follow-up suggestions, ordered by leverage:

  1. Include (dim, missing_labels[:5]) in coord-mismatch text. _legacy_coord_mismatch_message("merge along dim 'time'") names the dim but not the diff. In a large codebase the dim alone isn't enough to locate the wrong reindex. The _shared_dim_mismatch_message v1-raise formatter already prints both sides — could share it between v1-raise and legacy-warn so the strings stay aligned.
  2. Stacklevel sanity check. Current mix is 3/4/5 across call sites. Worth a test that pins the user-visible frame to the caller's source, not linopy internals — IDE jump-to-source on the warning depends on it.
  3. diagnose_alignment(model) audit helper still distinct from warnings. Warnings fire only when ops run; the helper walks every stored expression in a built model and lists every v1-raise site, runnable under legacy. Different shape — lets users audit before opting in. Happy to PR if you want it; or if you'd rather own it, glad to leave it to you.

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 24, 2026

@MaykThewessen Im not sure how such a diagnose_alignment(model) would work, as it might not be clear from a build constraint if the creation path that lead to it would have produced a different constraint in the new semantic convention.
However, feel free to PR this.

Copy link
Copy Markdown
Contributor

@MaykThewessen MaykThewessen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full line-level pass on the implementation files (semantics.py, expressions.py, variables.py, config.py, piecewise.py, sos_reformulation.py) against the §1–§13 spec in convention.md. The spec ↔ code alignment is strong overall — almost every section has a direct implementation site, and the is_v1() switch is consistently placed. Findings below are not blockers to opt-in landing, but most should resolve before v1 promotes to default, because they constitute silent-divergence paths the rollout warnings cannot catch.

High-impact (silent §-violations under v1)

  1. §8 / §11 enforcement gated behind if join is None: in both merge (line 2573) and _align_constant (line 580). User-supplied join= bypasses aux-coord conflict check — the #295 bug v1 is supposed to close still reproduces with join="override".
  2. §6 absence propagation gap on quadratics (line 2633–2634): the FACTOR_DIM branch's .prod(...) uses xarray default skipna=True. The TERM_DIM branch right above correctly does skipna = not is_v1(). expr * expr with one absent factor silently collapses absence to identity 1.
  3. §5 not enforced on Variable.to_linexpr(coefficient) (variables.py:312): array coefficients carrying user NaN flow through with no check_user_nan_array call.
  4. §1/§2 storage invariant not constructor-enforced (expressions.py:1328 / __init__): no absorb_absence call inside LinearExpression.__init__. A bypass-constructed expression can carry "half-absent" rows; isnull() then misreports.

Migration ergonomics

  1. stacklevel lands inside linopy internals on every call site I traced (semantics.py:170 default =3, lines 200 / 207 default =5). IDE jump-to-source on the legacy warnings points at __add__/merge/to_linexpr, not at the user's call site — defeats the actionable-warning goal #2.
  2. Legacy RHS-NaN warning collapses coord-mismatch and data-error into one message (expressions.py:1292). User gets fillna(...) advice when the underlying cause was a reindex artifact.
  3. override join semantic drift (line 609): docs and _shared_dim_mismatch_message advertise "positional alignment, made explicit". Implementation is assign_coords, which is coord-renaming with no shape guarantee.

Robustness

  1. Brittle xarray exception string-match (line 624): if "exact" in str(e): will silently break on any upstream xarray rephrase. Structural check via merge_shared_user_coord_mismatch is safer.
  2. op_kind derived from fill_value magic value (line 749) — fragile coupling between numeric fill and message routing.

Spec/doc gaps

  1. §11 asymmetric-presence behaviour (semantics.py:255) is implemented (propagate from the one operand that has it) but not stated in convention.md §11. One sentence closes it.
  2. §13 / mean of all-absent dim still open — I raised this above; no impl decision visible yet.

Performance (deferrable)

The _align_constant / merge hot paths run aux-coord checks on every v1 binary op without identity short-circuits. Likely measurable in PyPSA-Eur-scale models (thousands of add_constraints calls). Cheap to add if a is b: return False before the equals-check. Worth a profile pass once functionality is locked.

What's working well

  • The §12 raise plumbing for to_constraint is clean — self.sub(rhs, join=join) reuses the §8 path correctly.
  • piecewise.py / sos_reformulation.py minimal drop=True / assign_coords edits are correct under both semantics; no findings.
  • Per-op message split + masked-variable warn site (commit 9455683) catches the most common PyPSA-Eur divergence at its origin. That's the highest-leverage migration affordance in the PR.

Happy to PR fixes for any subset (especially #1, #2, #3, #5, #8) once the design questions above are settled. Treat all line comments as suggestions, not blockers.

Comment thread linopy/expressions.py
Comment thread linopy/expressions.py Outdated
Comment thread linopy/expressions.py
False,
)

if join == "override":
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

override semantics drift. _shared_dim_mismatch_message (and convention.md §10) document override as "positional alignment, made explicit". The implementation here is other.assign_coords(coords=self.coords) — that's coord-renaming, not positional alignment, and has no shape/size guarantee.

If other.sizes != self.const.sizes on a shared dim, assign_coords will silently broadcast (or raise an opaque xarray error). The legacy positional path at least gated on other.sizes == self.const.sizes (line ~593) before doing the rename. The v1 override branch should either keep that gate or rename itself to relabel/coerce to be honest about what it does.

Comment thread linopy/expressions.py
Comment thread linopy/semantics.py Outdated
Comment thread linopy/expressions.py Outdated
Comment thread linopy/variables.py
Comment thread linopy/expressions.py Outdated
Comment thread linopy/semantics.py
Comment thread linopy/variables.py Outdated
@MaykThewessen
Copy link
Copy Markdown
Contributor

You're right, @FBumanndiagnose_alignment(model) walking the built artifact is the wrong shape. Once a constraint is in model.constraints, the legacy-vs-v1 divergence has already collapsed: a silently-positionally-aligned x + y and an explicitly label-aligned one produce identical Datasets. The build path is the only place the difference is visible.

Pivot proposal: capture at op time, not audit time. The warnings from commit 9455683 already fire at every divergence site — turn them into a structured trace rather than stderr lines.

with linopy.alignment_trace() as trace:
    m = build_my_model()

trace.report()   # pandas.DataFrame
#   file              line  op_kind  divergence              dim    n_unmatched
#   pypsa/optimize.py  412   add      coord_mismatch          time   17
#   pypsa/optimize.py  588   div      nan_user_fill           -      3
#   my_constraints.py  102   mul      masked_var_arithmetic   bus    2

Implementation sketch: context manager installs a warnings.catch_warnings() filter for LinopySemanticsWarning, parses each into a structured record (each _legacy_*_message already has a canonical shape — divergence class, op_kind, dim, fix-hint). On exit, returns a Trace object with .to_df() / .summarise() / .first_n_per_site(n=5).

Two payoffs over raw warnings:

  • Deduplicates: 50,000 identical warnings from a tight loop collapse to one row with count.
  • Survives filterwarnings('once') / 'ignore' — runs under its own catch context, independent of the user's global warning config.

Net: it's the warning machinery you already shipped, with structured capture and reporting on top. No new emission sites, no instrumentation in the hot path. PR-ready once the legacy-warn message format stabilises.

I'll open the PR once the spec-drift items from my review are settled — they affect what divergence classes the trace needs to recognise (e.g. if _align_constant's override semantics get tightened, that's a new divergence class to surface).

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 24, 2026

@MaykThewessen Seems like a usefull addition!

FBumann and others added 12 commits May 24, 2026 14:17
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>
``to_constraint`` legacy path used to warn ``_legacy_nan_rhs_constraint_message``
on every NaN in the post-reindex RHS, but ``reindex_like(fill_value=NaN)``
introduces NaN at unmatched coord positions too. The user got
``mask=`` / ``.fillna(value)`` advice when the actual cause was a
coord mismatch (fix: ``.sel`` / ``.reindex``).

Check both causes before the reindex and emit the right
``_legacy_coord_mismatch_message`` / ``_legacy_nan_rhs_constraint_message``
each independently. Both can fire when the RHS has both problems.
The post-reindex ``rhs_nan_mask`` still drives the auto-mask drop
downstream — only the user-visible warn text changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``_align_constant`` wrapped ``xr.align(..., join="exact")``'s
ValueError in a ``try/except`` and triggered the actionable
``Resolve with .sel(...) / .reindex(...) ...`` text only when
``"exact" in str(e)``. The wording isn't API-stable across xarray
releases — an upstream rephrase would silently drop the hint.

Do the §8 check ourselves with ``first_mismatched_dim`` when
``join == "exact"`` and raise the canonical
``_shared_dim_mismatch_message`` (already used by the v1-default
``_align_constant`` and ``merge`` paths). Other joins
(inner / outer / right) handle coord mismatches via the join mode
and don't reach the error path.

Pre-existing bug uncovered by the new structural check:
``first_mismatched_dim`` used ``coords[dim].equals(...)``, which
compares attached aux coords too and reports a false-positive
mismatch when only one operand carries an aux coord on the shared
dim (an §11 case, not §8). Switch to ``indexes[dim].equals(...)``
(the bare pandas Index), matching ``merge_shared_user_coord_mismatch``.

Tests that were matching xarray's ``"exact"`` wording switch to
the canonical ``"Coordinate mismatch on shared dimension"`` text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#627 was closed in favour of the existing §5 behaviour ("user NaN
raises"). Replace the "Open question" note with a one-paragraph
record of the decision and its rationale (goal #1 — no silent
wrong answers) so future readers don't re-open the debate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``to_linexpr`` runs on every ``__add__``/``__mul__``/``__sub__``
that involves a Variable; the four-name ``from linopy.semantics``
inside the function paid the import-lookup cost on each call.
linopy.semantics only depends on linopy.config and linopy.constants
(no circular risk), so the import lifts cleanly to module top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Legacy ``_apply_constant_op_legacy`` derived ``op_kind`` from the
numeric ``fill_value`` (``"div" if fill_value == 1 else "mul"``) to
pick the per-op legacy warning text. The coupling was fragile: any
future call site that needed a different fill (e.g. safe-division
``inf``) would silently mis-route warning messages.

Pass ``op_kind`` explicitly from each call site
(``_multiply_by_constant("mul")``, ``_divide_by_constant("div")``)
all the way down. Both v1 and legacy branches now receive it; v1
already accepts it on the ``check_user_nan_*`` helpers (no-op for
the single v1 message, makes intent explicit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ue, handle object-dtype NaN

Three findings on ``conflicting_aux_coord`` and its message helpers:

1. **§11 asymmetric-presence policy was implicit.** ``conflicting_aux_coord``
   short-circuits when only one operand carries the coord
   (``len(present) < 2``) and the coord propagates from that operand
   unchanged. The convention text only described the symmetric
   conflict case, so users could be surprised by a one-sided coord
   surviving a binary op. Add a sentence to §11 stating the rule.

2. **Object-dtype aux coords with embedded NaN false-positived as
   conflict.** ``np.array_equal(equal_nan=True)`` only works on float
   dtype; for object/string the call was made with ``equal_nan=False``
   and two identical arrays carrying ``np.nan`` at the same slot
   compared unequal (NaN ≠ NaN). Route those through
   ``pd.Series.equals`` which has NaN-equal-NaN semantics on every
   dtype.

3. **Shape mismatch and value disagreement shared one message.** Both
   surfaced as "Auxiliary coordinate 'X' has conflicting values" even
   when the actual mismatch was a shape difference (e.g. scalar isel
   on one operand, vector on the other). Add a ``kind`` field to the
   return tuple and branch the v1-raise / legacy-warn text — shape
   problems now read "has differing shapes" and report ``.shape``,
   value problems keep the existing text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 24, 2026

@MaykThewessen
Feel free to PR a linopy.alignment_trace() context manager. Although if its not to complex i would prefer to have it as a documented helper in the docs instead of inside the codebase. But only if thats possible...

Also feel free to PR a context manager like 'with linopy.alignment(join="override")', although i would argue that you should wait until this PR is reviewed and merged.

I would defer performance polishes like the proposed short-circuits until we merge this, maybe even until we drop the legacy convention. THis will keep the convention more focused and readable as long as its not 100% proven.

FBumann and others added 2 commits May 24, 2026 15:33
``self.const.dims`` is typed ``Hashable``, not ``RichComparable`` —
mypy rejects ``sorted(...)`` without a key. The sort is purely for
stable error-message output, so ``key=str`` is the right call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip the rule-by-rule cheat sheet, migration recipe, known-limitations
section, and issue-cross-reference list. Those belong in the guide
itself, not in the plan for the guide. Keep only the three pieces the
plan actually needs at this stage: who the audiences are, why v1
exists, the rollout timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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