Skip to content

feat: v1 semantic convention#717

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

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

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented May 21, 2026

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["semantics"]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>
FBumann and others added 8 commits May 24, 2026 14:49
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>
@MaykThewessen
Copy link
Copy Markdown
Contributor

Good call — docs recipe is tighter scope than a public linopy.alignment_trace() symbol. Less API surface to maintain, no docstring/signature compat to preserve across releases, users can copy-and-tweak for their own warning-filter needs.

Planned shape: a short notebook under doc/ (next to existing examples) titled "Migrating from legacy to v1 semantics" with three sections:

  1. Set up a trace context. ~20 lines: warnings.catch_warnings() + simplefilter("always", LinopySemanticsWarning) + a record list, wrapped in a contextlib.contextmanager.
  2. Parse warnings into a DataFrame. Regex extracts divergence_class from the canonical message prefix ("Coordinate mismatch" / "treated as 0" / "divisor.*treated as 1" / "'…'.*fillna"), plus stacklevel-walk for (file, line). ~30 lines.
  3. Report patterns. df.groupby(["file", "line", "divergence_class"]).size() collapsing 50k repeats to a punch list.

End result reads as a recipe a user runs once during opt-in, gets a punch list, fixes top-N sites, re-runs until empty. I'll wait for the spec-drift items from the line review to settle so the regex catalogue matches the final message texts, then PR. Sound good?

- merge check_user_nan_scalar/_array (byte-identical) → check_user_nan
- collapse 3 _legacy_nan_constant_* messages + dispatcher into one
  table-driven _legacy_nan_constant_message(op_kind)
- extract enforce_aux_conflict helper, replacing the raise/warn block
  duplicated at two call sites in expressions.py
- drop dead Optional handling in _legacy_aux_conflict_message — all
  callers pass full tuples from conflicting_aux_coord

No behavior or message-text change.

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 — that ordering works for me.

Plan:

Hold alignment(join="override") ctx mgr and the short-circuits until #717 merges (and revisit short-circuits post legacy removal).
For alignment_trace: try a pure-stdlib docs snippet first, leaning on the existing LinopySemanticsWarning channel — no new public API needed. Sketch below. If reviewers think it deserves to live in linopy/semantics.py after all (e.g. for stacklevel correctness across the call sites), happy to promote it then.
Proposed docs snippet (drop into the migration guide, not the package):

import warnings
from collections import Counter
from contextlib import contextmanager

from linopy.semantics import LinopySemanticsWarning


@contextmanager
def alignment_trace(*, summary: bool = True):
    """Capture every LinopySemanticsWarning raised inside the block.

    Use during migration to surface where legacy alignment/NaN behaviour
    is being relied on, without touching the codebase under test.
    """
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always", LinopySemanticsWarning)
        yield caught

    if summary:
        hits = Counter(
            (w.filename, w.lineno, str(w.message).splitlines()[0])
            for w in caught
            if issubclass(w.category, LinopySemanticsWarning)
        )
        for (fname, lineno, msg), n in hits.most_common():
            print(f"{n:>4}× {fname}:{lineno}  {msg}")

Usage in migration docs:

with alignment_trace() as warns:
    m = build_model()      # legacy semantics still default
    m.solve()

→ ranked list of call sites still relying on legacy NaN/align behaviour

Lets users sweep a real PyPSA-Eur / linopy workflow once, get a ranked punch-list of sites to fix, then flip linopy.options["semantics"] = "v1". Zero surface area in the library.

I'll open the docs PR after #717 lands so the import paths and warning class are stable.

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 27, 2026

Blocked by #732

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented May 27, 2026

For reference: Needed changes in flixopt to pass under the new v1 convention. With those changes, the code works under both v1 and legacy

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FBumann while working on #732 and trying to align the multiindex handling with the new convention, a gap in the convention revealed: The alignment on coords and dims does not only count for two linopy objects but for also for operations with non-linopy objects. IE. when performing a arithmetic operation between a linopy object and a non-linopy array-object, all expectations and conventions should be applied on the non-linopy object too. likely worth defining the object scope.

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Jun 1, 2026

@FBumann while working on #732 and trying to align the multiindex handling with the new convention, a gap in the convention revealed: The alignment on coords and dims does not only count for two linopy objects but for also for operations with non-linopy objects. IE. when performing a arithmetic operation between a linopy object and a non-linopy array-object, all expectations and conventions should be applied on the non-linopy object too. likely worth defining the object scope.

@FabianHofmann I think this should currently hold, as linopy owns the arithmetics. Did you find a case where this doesnt?

@FabianHofmann
Copy link
Copy Markdown
Collaborator

@FBumann while working on #732 and trying to align the multiindex handling with the new convention, a gap in the convention revealed: The alignment on coords and dims does not only count for two linopy objects but for also for operations with non-linopy objects. IE. when performing a arithmetic operation between a linopy object and a non-linopy array-object, all expectations and conventions should be applied on the non-linopy object too. likely worth defining the object scope.

@FabianHofmann I think this should currently hold, as linopy owns the arithmetics. Did you find a case where this doesnt?

it's true, just explicitly naming that in the conventions would be beneficial. other objects have to first go via as_dataarray and then arithmetics are performed. that is as_dataarray must be fully aligned with the convention in order to get object-agnostic behavior, ie. operations between expr and arr must behave exactly the same as operations between expr and arr_expr where arr_expr is a linear expression which only holds constants of the same shape as arr. this needs to hold for commutativity, associativity, distributivity.

Per review feedback on #717, the convention now names its object scope:
operations between a linopy object and any non-linopy operand behave
exactly like operations against the constant-only expression holding the
same values and coordinates, for every operator and operand position.

The alignment group resolves the previously open question on unlabeled
data: unlabeled operands (numpy arrays, lists, polars Series) pair with
dimensions by size, and the pairing must be unambiguous — a length-4
array against dims (a: 4, b: 4) raises instead of silently picking the
leading dim. Marked TODO: implementation builds on the coords-as-truth
seam from #732 and lands after it.

Slice H tests pin the substitutability that already holds: per-operator
raw-vs-wrapped equivalence, distributivity/associativity across mixed
operand types, identical §8 raises on either route, and the type-decided
divisor exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FBumann added a commit that referenced this pull request Jun 2, 2026
…ario B)

Per the #737 review discussion and Fabian's decision: implicit level
projection is deprecated and will raise under the v1 convention, so the
EvolvingAPIWarning now fires in both modes of broadcast_to_coords — the
MI check is the same for every use case:

- input missing a whole level: warn (strict and non-strict)
- coverage gap (level combinations without a value): warn (non-strict) /
  raise (strict — no downstream layer to defer the NaN to)

Warning emission lives in one helper, _warn_implicit_projections, with a
TODO(#738) to migrate to LinopySemanticsWarning once #717 lands.

Also clarifies the MultiIndex terminology everywhere: an MI dim has
*levels* and *level combinations* (one tuple per position). Docstrings
carry the glossary, the coverage-gap error names the missing
combinations explicitly, and "entry" is gone from messages.

User-facing: add_variables / add_constraints with per-period-style
bounds now emit the deprecation warning (PyPSA multi-investment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FabianHofmann pushed a commit that referenced this pull request Jun 2, 2026
… ladder (#737)

* refactor(common): split DataArray conversion into a 3-rung strictness ladder

Replace the as_dataarray + _as_dataarray_lax pair (and the
enforce_level_coverage flag) with three public entry points, each
including the previous one:

- as_dataarray: convert only (the former _as_dataarray_lax). Used by
  __matmul__, where dims missing from the constant must not be
  broadcast in (they would be contracted away as common dims).
- broadcast_to_coords: convert + broadcast against coords (the former
  broadcasting as_dataarray). Used by expression arithmetic.
- align_to_coords: convert + broadcast + enforce the coords contract.
  Used by add_variables / add_constraints (unchanged signature).

The broadcasting mechanics live in one shared private core
(_broadcast_core) that reports MultiIndex-level projections instead of
applying policy. The entry points decide what a partial projection or
coverage gap means: broadcast_to_coords warns (arithmetic convention),
align_to_coords raises (coords contract). This removes the
enforce_level_coverage flag and keeps validation concerns out of the
broadcasting layer.

No behavior changes; all call sites keep their semantics. New tests pin
the ladder contrasts and the matmul dim-contraction rules.

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

* docs: shorten release-notes bullet on conversion helpers

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

* refactor(common): rename _broadcast_core to _broadcast_to_coords

Private-twin convention: _broadcast_to_coords is the raw implementation
of broadcast_to_coords (returns projection events instead of applying
policy), shared with align_to_coords.

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

* fix(expressions): as_expression converts constants with the broadcast rung

The constraint lhs/rhs setters call as_expression(value, model,
coords=self.coords, dims=self.coord_dims); forwarding those kwargs to
the convert-only as_dataarray dropped the broadcasting these setters
relied on (e.g. a MultiIndex-level-indexed rhs failed with an xarray
AlignmentError instead of being projected onto the stacked dim).

Use broadcast_to_coords instead. The other as_expression callers pass
only dims (no coords), for which both rungs behave identically.

Adds regression tests for the rhs setter: missing-dim broadcast and
MultiIndex-level projection.

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

* refactor(common): rename align_to_coords to strict_broadcast_to_coords

The function never aligns anything — it broadcasts and raises on any
mismatch it cannot resolve by broadcasting alone. "Align" is also the
word that invites join= proposals (aligns take joins, broadcasts do
not), so the name now states what it is: the same broadcast as
broadcast_to_coords with a strict failure mode (zip(strict=True)
semantics).

Error messages keep the "could not be aligned to coords" wording so
tests in the base branch (#732) stay untouched.

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

* refactor(common): apply review polish to the strictness ladder

- Document the one non-obvious policy in strict_broadcast_to_coords:
  partial-level broadcasts are silent (bounds-broadcast feature), unlike
  the warning on the broadcast rung.
- Unify the first parameter name across the ladder (value -> arr).
- Un-invert the warning-policy loop in broadcast_to_coords.
- Rename the test whose name forced an awkward signature wrap to a
  behavior-oriented name (test_extra_dims_pass_broadcast_rung_fail_strict_rung).

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

* docs(common): add numpydoc Parameters/Returns to the three public rungs

Parameter entries carry descriptions only — types live in the function
signatures.

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

* refactor(common): unify the broadcast rungs into broadcast_to_coords(strict=...)

Per review discussion: one public function instead of two, with strict as
a keyword flag.

- strict=True (default): any mismatch with coords raises, naming label in
  the error — the former strict_broadcast_to_coords.
- strict=False: mismatches pass through for downstream xarray alignment —
  the former loose broadcast_to_coords, used by arithmetic.

Strict is the default so that forgetting the flag adds safety rather than
silently dropping validation. MI handling preserved exactly per mode
(strict: silent partial / raise on gap; non-strict: EvolvingAPIWarning) —
the scenario-B deprecation warnings land separately in #732.

Call sites: model.py bounds/mask drop the long name (strict is default);
arithmetic and as_expression pass strict=False explicitly.

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

* feat(common): require label when broadcast_to_coords is strict

Restores the contract align_to_coords always had: strict-mode errors must
name their subject ("lower bound could not be aligned..." rather than
"Value could not be aligned..."). Enforced both statically (overloads:
strict=True requires label: str, strict=False forbids it) and at runtime
(TypeError).

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

* feat(common): deprecate implicit MI-level projection everywhere (scenario B)

Per the #737 review discussion and Fabian's decision: implicit level
projection is deprecated and will raise under the v1 convention, so the
EvolvingAPIWarning now fires in both modes of broadcast_to_coords — the
MI check is the same for every use case:

- input missing a whole level: warn (strict and non-strict)
- coverage gap (level combinations without a value): warn (non-strict) /
  raise (strict — no downstream layer to defer the NaN to)

Warning emission lives in one helper, _warn_implicit_projections, with a
TODO(#738) to migrate to LinopySemanticsWarning once #717 lands.

Also clarifies the MultiIndex terminology everywhere: an MI dim has
*levels* and *level combinations* (one tuple per position). Docstrings
carry the glossary, the coverage-gap error names the missing
combinations explicitly, and "entry" is gone from messages.

User-facing: add_variables / add_constraints with per-period-style
bounds now emit the deprecation warning (PyPSA multi-investment).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FabianHofmann added a commit that referenced this pull request Jun 2, 2026
…ints (#732)

* fix(variables): broadcast and order pandas/DataArray bounds in coords

`add_variables` had two related bugs when `lower`/`upper` were arrays:

- pandas Series/DataFrame bounds missing a dimension in `coords` had
  the missing dimension silently dropped (#709), unlike DataArray
  bounds which were already broadcast.
- DataArray bounds missing a dimension were expanded with
  `DataArray.expand_dims`, which prepends new dimensions and produces
  a `coords`-mismatched dimension order in the resulting variable
  (#706). The order depended on the type of the bounds, so scalar
  bounds worked but two array bounds missing the same dimension did
  not.

Replace `_validate_dataarray_bounds` plus the downstream
`as_dataarray(..., coords)` call with a single helper
`_as_dataarray_in_coords`. It converts any input (pandas with named
axes via `to_xarray`, otherwise via `as_dataarray`), validates the
result against `coords`, expands missing dims, transposes to coords
order, and reconstructs the coord variables in that order.
`expand_dims` and `transpose` are no-ops when the array already
matches, so scalar / full-dim DataArray bounds keep their fast path.

Also fix `linopy.piecewise._broadcast_points`, which built the
`expand_dims` map from a `set`, producing a hash-randomized dimension
order across processes. Iterate expressions and dims in declaration
order instead.

Closes #706 and #709. Supersedes #710 and #719.

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

* docs(variables): frame add_variables coords as source of truth

Restate #706/#709's fix as a single principle in the docstring,
release note, and `_as_dataarray_in_coords` helper docstring:
when `coords` is provided to `add_variables`, it is the source of
truth for dimensions, dimension order, and coordinate values, and
`lower` / `upper` are broadcast and aligned to match.

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

* docs: frame bounds fix as extending 0.7.0's coords-as-truth fix

0.7.0 already shipped "add_variables no longer ignores coords when
lower / upper are DataArrays". Recast the new bullet as extending
that fix to the remaining gaps (pandas bounds; dim order across
bound types) so the continuity is visible from the release notes.

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

* docs: reword as "extend and finalize", emphasize hardening

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

* docs: rephrase as "0.7.0 made ... this release closes the two remaining gaps"

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

* docs: spell out dims/order/values in coords-as-truth bullet

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

* test(variables): cover pandas MultiIndex bounds and dim reindex

- Parametrize test_bound_broadcast_missing_dim with three additional
  cases: Series with MultiIndex(time, colour), DataFrame with
  MultiIndex columns(space, colour), and DataFrame with MultiIndex
  index(time, space). Exercises the `while DataFrame: unstack()`
  loop and the MultiIndex branch of `_named_pandas_to_dataarray`.
- Add test_dataarray_coord_reorder for the same-values-different-order
  reindex branch (previously only the unequal-values raise was
  covered).

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

* refactor: move as_dataarray_in_coords to common.py

Relocate `_as_dataarray_in_coords` and its helpers
(`_coords_to_dict`, `_named_pandas_to_dataarray`) from `model.py`
into `common.py`, alongside the existing `as_dataarray` they
parallel. Rename to `as_dataarray_in_coords` (no leading underscore)
since it is no longer file-local — other modules can import the
strict-coords variant when migrating call sites.

Pure relocation: no behavior change, no call-site changes beyond
`add_variables`'s import. Refs #723.

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

* refactor(common): simplify _named_pandas_to_dataarray + cover edge branches

Replace the unstack-while-loop / split named-check structure with a
single up-front "all axes named" check and a single
``DataFrame.stack(level=list(range(nlevels)), future_stack=True)``
call that collapses all column levels into the row MultiIndex in
one shot. Same observable behaviour, fewer moving parts, no
defensive unreachable branches.

Add tests covering the unnamed-axis fall-through path, the
empty-coords short-circuit in ``as_dataarray_in_coords``, and the
``MultiIndex``-on-a-dim ``continue`` in the validation loop.
Together with the restructure these bring the new helper code to
full patch coverage.

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

* fix(common): only accept string axis names in _named_pandas_to_dataarray

Pandas allows any hashable in ``pd.Index.names`` (tuples, ints,
etc.), but only strings map cleanly to xarray dim names. Reject
anything non-string up front so the pandas falls back to
``as_dataarray`` instead of producing a DataArray with an awkward
non-string dim name that downstream validation would reject with a
confusing "extra dimensions" error.

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

* fix(common): align positional inputs to coords, with clear shape errors

Inputs without their own meaningful labels — numpy arrays, polars
Series, pandas with unnamed axes — fell through ``as_dataarray_in_coords``
via a short-circuit return. That meant:

- The default ``dim_0`` / ``dim_1`` axis names from ``as_dataarray``
  leaked into the result, so a pandas Series without an index name
  combined with another bound carrying a named coord produced a
  spurious 2-D variable.
- Shape mismatches surfaced further downstream as confusing
  "coordinates do not match" errors against the auto-generated
  ``RangeIndex``.

The fall-through now: (a) defaults ``dims`` to coords' keys so axes
get labelled correctly; (b) runs the same validate / expand /
transpose path as labelled inputs; (c) re-assigns coords from
``expected`` on the resulting DataArray so positional inputs align
to coords by position. A shape mismatch surfaces as xarray's clear
``conflicting sizes`` from ``assign_coords``. MultiIndex coords are
left alone (re-assigning a PandasMultiIndex emits a FutureWarning).

Replaces the tautological ``test_pandas_bound_with_unnamed_axis_falls_through``
(which sneaked past by naming the coord ``"dim_0"`` to match the
auto-generated dim) with ``test_positional_bound_aligns_to_coords``
that asserts actual positional alignment across numpy / Series /
DataFrame, plus ``test_positional_bound_wrong_size_raises_clear_error``
for the shape-mismatch path.

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

* fix(sos): use var.indexes[d] for reformulated bounds; widen _coords_to_dict

``reformulate_sos1`` / ``reformulate_sos2`` built the coords for
the indicator variable as ``[var.coords[d] for d in var.dims]``,
which is a list of ``xarray.DataArray`` coord objects. The rest
of linopy passes ``coords`` as a list of ``pd.Index``. The mix
slipped through under the old short-circuit fall-through but
broke once the helper started defaulting ``dims`` from
``_coords_to_dict(coords)`` — non-``pd.Index`` entries were
silently dropped, so ``len(dims) < len(coords)`` and xarray
raised ``different number of dimensions on data and dims: 2 vs 1``.

Use ``var.indexes[d]`` instead — it returns the actual
``pd.Index`` (regular or MultiIndex) for the dim and preserves
structure that ``pd.Index(coord.values, ...)`` would flatten.

Also widen ``_coords_to_dict`` to accept any entry with a
``.name`` (xarray DataArrays included) so a future caller passing
mixed types doesn't silently lose coords. The reformulator fix
removes the only known producer of mixed-type coords; this is
belt-and-suspenders.

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

* fix(common): tighten _coords_to_dict to raise on non-pd.Index entries

Replace the permissive ``getattr(c, "name", None)`` check with an
explicit allow-list: ``pd.Index`` (named or not — unnamed silently
skip as before) and unnamed sequences (``list`` / ``tuple`` /
``range`` / ``numpy.ndarray``). Any other type (notably
``xarray.DataArray``, but also ``pd.Series`` and friends) now
raises ``TypeError`` with a hint to pass ``variable.indexes[<dim>]``
instead. This would have caught the SOS-reformulator bug at the
source instead of letting it surface as a confusing xarray error
about mismatched dim counts ten frames down.

Drop ``DataArray`` from the matching ``coords`` type hints in
``model.py`` / ``expressions.py`` so the documented and runtime
type sets agree.

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

* fix(common): proper MultiIndex support in coords helpers (#729)

- _coords_to_dict: explicitly handle pd.MultiIndex — register under
  .name if set, raise TypeError with guidance if .name is missing
- _named_pandas_to_dataarray: use DataArray(df) directly for
  single-level DataFrames; reserve stack() for MultiIndex axes
- as_dataarray_in_coords: validate MultiIndex dims with .equals()
  instead of silently skipping them
- Move MultiIndex tests into dedicated TestAddVariablesMultiIndexCoords
  class with shared fixture

* fix: apply coords-as-truth rule to mask in add_variables/add_constraints (#725)

* fix(model): apply coords-as-truth rule to mask in add_variables/add_constraints

Routes ``mask`` through ``as_dataarray_in_coords(mask, data.coords)``
instead of ``as_dataarray(...) + broadcast_mask(...)``, so pandas
``Series`` / ``DataFrame`` masks missing a dimension are broadcast
to the variable / constraint shape (parallel to the bounds fix in
the previous PR). The ``add_variables`` ``mask`` type hint widens
to ``MaskLike`` to match ``add_constraints``.

The deprecation announced via ``FutureWarning`` in ``broadcast_mask``
("Missing values will be filled with False ... In a future version,
this will raise an error") is now in effect: masks whose
coordinates are a sparse subset of the data's coordinates raise
``ValueError`` instead of silently filling missing entries.
Mask dims not in the data raise ``ValueError`` instead of
``AssertionError`` for consistency with the bounds path.

``broadcast_mask`` had no other callers and is removed.

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

* Update doc/release_notes.rst

Co-authored-by: Fabian Hofmann <fab.hof@gmx.de>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Fabian Hofmann <fab.hof@gmx.de>

* refactor: unify as_dataarray; split broadcasting from coords validation (#726)

* fix(model): apply coords-as-truth rule to mask in add_variables/add_constraints

Routes ``mask`` through ``as_dataarray_in_coords(mask, data.coords)``
instead of ``as_dataarray(...) + broadcast_mask(...)``, so pandas
``Series`` / ``DataFrame`` masks missing a dimension are broadcast
to the variable / constraint shape (parallel to the bounds fix in
the previous PR). The ``add_variables`` ``mask`` type hint widens
to ``MaskLike`` to match ``add_constraints``.

The deprecation announced via ``FutureWarning`` in ``broadcast_mask``
("Missing values will be filled with False ... In a future version,
this will raise an error") is now in effect: masks whose
coordinates are a sparse subset of the data's coordinates raise
``ValueError`` instead of silently filling missing entries.
Mask dims not in the data raise ``ValueError`` instead of
``AssertionError`` for consistency with the bounds path.

``broadcast_mask`` had no other callers and is removed.

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

* refactor: unify as_dataarray; split broadcasting from coords validation

Closes #723. Folds the body of `as_dataarray_in_coords` into `as_dataarray`
and extracts the contract checks into `assert_compatible_with_coords`, so
linopy now has one broadcasting primitive and one validation companion.

`as_dataarray(arr, coords)` aligns the result against `coords` for every
input type: labels positional inputs (numpy / unnamed pandas / scalar) by
position, reindexes same-values-different-order, expands missing dims,
and transposes to coords order. Extra dims and disagreeing value sets on
shared dims pass through unchanged, so xarray broadcasting in expression
arithmetic keeps working.

`assert_compatible_with_coords(arr, coords)` enforces the strict contract
(`arr.dims ⊆ coords.dims`, plus exact coord-value equality on shared
dims). `add_variables` and `add_constraints` now call it after
`as_dataarray` for `lower` / `upper` / `mask`, replacing the deleted
`as_dataarray_in_coords` helper.

`_coords_to_dict` filters MultiIndex level coords out of
`xarray.Coordinates` inputs so the new strict-by-default path treats
`station` (and not its derived `letter` / `num` levels) as the dim.

Test suite: 3698 passed (no regressions). Two existing tests were
updated to reflect the new "coords is source of truth" semantics:
`test_as_dataarray_with_ndarray_coords_dict_set_dims_not_aligned`
(extra coord entries now broadcast in) and
`test_dataarray_extra_dims` (now triggers the subset check rather than
the value-mismatch check).

Microbenchmark in dev-scripts/benchmark_as_dataarray.py shows flat
timings vs the base branch on both add_variables-heavy and arithmetic-
heavy workloads.

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

* feat: dims= names unnamed coords; doctest the add_variables contract

Closes a silent-failure gap in the strict coords-as-truth path: when the
caller passed ``coords=[[1, 2, 3]], dims=["x"]`` to ``add_variables``,
``_coords_to_dict`` returned an empty mapping (unnamed sequences carry
no dim name), so the strict checks short-circuited and bounds with
extra dims or mismatched values flowed through unchecked, producing
variables with frankenstein outer-joined coord values.

``_coords_to_dict`` now accepts an optional ``dims`` argument that
names unnamed sequence entries by position. ``as_dataarray`` and
``assert_compatible_with_coords`` plumb it through; ``add_variables``
forwards ``kwargs.get("dims")`` to the assertions for ``lower`` and
``upper``. ``coords=[[1, 2, 3]], dims=["x"]`` now enforces the same
contract as ``coords={"x": [1, 2, 3]}`` or
``coords=[pd.Index([1, 2, 3], name="x")]``.

Docstring of ``add_variables.coords`` documents the contract
(subset-of-dims, dim order, value match with auto-reindex, missing-dim
broadcast) and includes four doctests pinning it: the extra-dim raise,
the value-mismatch raise, the same-values-different-order auto-reindex,
and the unnamed-coords-plus-dims opt-in.

Test suite: 3698 passed (parity with the previous commit on this
branch). ``pytest --doctest-modules linopy/model.py -k add_variables``
also green.

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

* feat: add align_to_coords with semantic validation error messages

Introduce align_to_coords to wrap as_dataarray and assert_compatible_with_coords
with user-facing labels (lower bound, upper bound, mask). Errors now name the
argument and distinguish extra dimensions, coordinate mismatches, and conversion
failures. Extend mask validation to use coords+dims= when provided.

Co-authored-by: Cursor <cursoragent@cursor.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor(model): simplify mask align; preserve TypeError in align_to_coords

Three cleanups on top of align_to_coords:

- Drop the trailing ``.broadcast_like(data.labels)`` in ``add_variables``
  and ``add_constraints`` mask paths. ``as_dataarray`` already expands
  missing dims to ``coords`` shape, so the broadcast was a no-op.
- Stop overriding the caller's ``dims=`` in the ``add_variables`` mask
  path when ``coords is None``. The previous code stripped ``dims`` and
  forced ``dims=data.dims``; with ``data.coords`` being an xarray
  ``Coordinates`` with already-named dims, the user's ``dims`` is
  harmless to forward and the override was just hiding intent. Mask
  now goes through one ``align_to_coords`` call regardless of whether
  ``coords`` is supplied.
- Split the exception handler in ``align_to_coords``: ``TypeError`` from
  unsupported input types is re-raised as ``TypeError`` (still labeled),
  while ``ValueError`` / ``CoordinateValidationError`` stay
  ``ValueError``. Preserves the original type signature for callers
  that want to ``except TypeError``.

New test ``test_align_to_coords_preserves_type_errors`` pins the
TypeError pass-through. Suite: 3703 passed.

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

* refactor: rename assert_compatible_with_coords to validate_alignment

Per PR review: align on the project's `validate_*` naming convention
and remove the implicit "AssertionError" connotation of `assert_*`.
Pairs naturally with `align_to_coords`.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* test(repr): set .name on MultiIndex coord

#729 made `.name` required on `pd.MultiIndex` sequence-form coord entries
(xarray needs a single dim name for the flattened index). test_repr.py was
the only remaining call site missing the assignment.

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

* fix(types): widen _coords_to_dict to Hashable; sort with key=str

`xarray.Coordinates.dims` is typed `Hashable`, so the dict-comprehension
return and the `sorted()` calls in the validation message tripped mypy.
The function's other branches already accept `c.name` / `dim_names[i]`
(both Hashable), so widening the return type is the honest signature.

Also: drop `.data` from the add_variables doctest — use the public
`v.lower` property instead.

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

* refactor(common): clarify coords-entry rules and tighten error labels (#733)

* refactor(common): clarify coords-entry rules and tighten error labels

Stacks on top of #732. Three small follow-ups from PR review:

- Remove dead `broadcast_mask` (claimed removed in #732, was still present).
- `as_dataarray`: normalize bare-tuple coord entries to lists so
  `coords=[(0, 1, 2)]` behaves identically to `coords=[[0, 1, 2]]`
  (xarray reads `(a, b)` as `(dim_name, values)` and would otherwise
  raise a confusing error).
- `align_to_coords`: pre-validate coords via `_coords_to_dict` so
  TypeErrors from a bad `coords` argument propagate with their own
  message instead of being relabeled "<label> could not be aligned to
  coords: ...", which previously misdirected users to inspect the
  bound/mask.

Docs: replace the prose paragraph in `_coords_to_dict`'s docstring
with an explicit rules table covering every container form and
sequence-entry case (named/unnamed `pd.Index`, `pd.MultiIndex`, bare
sequences, with/without positional `dims=`).

Tests: new `TestCoordsToDictRules` class in `test_common.py` mirrors
the docstring table one-test-per-rule so the executable spec stays
visibly aligned with the documented contract.

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

* feat(common): allow dims= to name an unnamed pd.MultiIndex

Mirrors the existing rule for unnamed pd.Index: an unnamed MultiIndex
paired with a positional dims=[i] entry now gets its flat .name set
to dims[i] on a shallow copy (caller's MultiIndex is not mutated).
Per-level names are preserved.

Removes the asymmetry between Index and MultiIndex in _coords_to_dict:
both can now be named either inline (.name) or by position (dims=[i]).
An unnamed MultiIndex with no positional dims still raises TypeError
since xarray requires a single flat name.

Adds one rule-table row and two tests
(test_unnamed_multiindex_with_dims_uses_dims,
test_unnamed_multiindex_without_dims_raises).

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

* refactor(common): scope tuple-normalize check to lists/tuples with tuple entries

The previous `not isinstance(coords, Coordinates | Mapping)` form was
broad and rebuilt `coords` as a fresh list on every call (even when no
tuple entries were present). Switch to a positive
`isinstance(coords, list | tuple)` guard with a short-circuit
`any(isinstance(c, tuple) for c in coords)` check, so the comprehension
only runs when there is actually a tuple to normalize.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(release_notes): restore lost bullets, surface coords breaking changes

Two pre-existing Upcoming-Version bullets from master had been
dropped on this branch, most likely as merge-conflict casualties:

- ``LinearExpression.where`` doc + ``BaseExpression.variable_names`` entry
- Mosek basic/IPM solution-inspect fix

Restore both verbatim from master.

Also add an explicit Breaking Changes bullet for the coord-as-truth
behaviour changes that previously lived only under Bug Fixes: the
mask FutureWarning -> ValueError flip, the AssertionError -> ValueError
flip on extra mask dims, and the new TypeError on an unnamed
pd.MultiIndex without a positional dims=[i] entry. The Bug Fixes
entries still carry the migration detail; the Breaking Changes bullet
points there so readers scanning by section don't miss the rename of
warnings to errors.

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

* docs(release_notes): condense coords-as-truth entries

- Merge the two Bug Fixes bullets (bounds + mask) into one. The
  separation read as "same fix, applied twice" without adding info;
  one bullet covers both with the same migration detail.
- Shorten the Breaking Changes bullet — it duplicated the v0.6.3
  ``FutureWarning`` and ``AssertionError`` parentheticals already
  in Bug Fixes; keep only the FutureWarning summary and the
  pd.MultiIndex addition.
- Collapse the Internal as_dataarray bullet from 8 wrapped lines to
  one, and drop the "Validation errors name the argument" UX
  detail — accurate but not structural enough for a release note.

No facts removed.

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

* fix(common): preserve MultiIndex levels when broadcasting a missing dim

as_dataarray used expand_dims to add a coords dim absent from the input,
which silently dropped MultiIndex level coords and left a degenerate flat
index that failed to align downstream (PyPSA multi-investment). Broadcast
MultiIndex-backed dims against a Coordinates template instead, falling back
to expand_dims when the input already carries a level name as its own coord.

Also narrow CoordsLike to drop the DataArray sequence entry (rejected by
_coords_to_dict), and give align_to_coords an explicit dims parameter.

* feat(common): project pandas inputs onto stacked-MultiIndex coords dims

Map arr dims that name levels of a stacked-MultiIndex coords dim onto
that dim: a level subset broadcasts, the full set aligns element-wise.
Strict callers (add_variables/add_constraints) enforce full coverage;
arithmetic keeps NaN-filling. Fixes PyPSA multi-investment regressions.

* feat(common): warn on implicit MultiIndex-level projection in arithmetic

The level-projection result is already convention-shaped (levels stay as
aux coords on the MI dim). On the arithmetic path, flag the cases the v1
arithmetic convention will require to be explicit — subset-level broadcast
and NaN-fill of uncovered entries — with EvolvingAPIWarning. Full-level,
full-coverage alignment and the strict bounds path stay silent.

* use lax dataarray in matmul

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor(common): split DataArray conversion into a 3-rung strictness ladder (#737)

* refactor(common): split DataArray conversion into a 3-rung strictness ladder

Replace the as_dataarray + _as_dataarray_lax pair (and the
enforce_level_coverage flag) with three public entry points, each
including the previous one:

- as_dataarray: convert only (the former _as_dataarray_lax). Used by
  __matmul__, where dims missing from the constant must not be
  broadcast in (they would be contracted away as common dims).
- broadcast_to_coords: convert + broadcast against coords (the former
  broadcasting as_dataarray). Used by expression arithmetic.
- align_to_coords: convert + broadcast + enforce the coords contract.
  Used by add_variables / add_constraints (unchanged signature).

The broadcasting mechanics live in one shared private core
(_broadcast_core) that reports MultiIndex-level projections instead of
applying policy. The entry points decide what a partial projection or
coverage gap means: broadcast_to_coords warns (arithmetic convention),
align_to_coords raises (coords contract). This removes the
enforce_level_coverage flag and keeps validation concerns out of the
broadcasting layer.

No behavior changes; all call sites keep their semantics. New tests pin
the ladder contrasts and the matmul dim-contraction rules.

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

* docs: shorten release-notes bullet on conversion helpers

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

* refactor(common): rename _broadcast_core to _broadcast_to_coords

Private-twin convention: _broadcast_to_coords is the raw implementation
of broadcast_to_coords (returns projection events instead of applying
policy), shared with align_to_coords.

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

* fix(expressions): as_expression converts constants with the broadcast rung

The constraint lhs/rhs setters call as_expression(value, model,
coords=self.coords, dims=self.coord_dims); forwarding those kwargs to
the convert-only as_dataarray dropped the broadcasting these setters
relied on (e.g. a MultiIndex-level-indexed rhs failed with an xarray
AlignmentError instead of being projected onto the stacked dim).

Use broadcast_to_coords instead. The other as_expression callers pass
only dims (no coords), for which both rungs behave identically.

Adds regression tests for the rhs setter: missing-dim broadcast and
MultiIndex-level projection.

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

* refactor(common): rename align_to_coords to strict_broadcast_to_coords

The function never aligns anything — it broadcasts and raises on any
mismatch it cannot resolve by broadcasting alone. "Align" is also the
word that invites join= proposals (aligns take joins, broadcasts do
not), so the name now states what it is: the same broadcast as
broadcast_to_coords with a strict failure mode (zip(strict=True)
semantics).

Error messages keep the "could not be aligned to coords" wording so
tests in the base branch (#732) stay untouched.

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

* refactor(common): apply review polish to the strictness ladder

- Document the one non-obvious policy in strict_broadcast_to_coords:
  partial-level broadcasts are silent (bounds-broadcast feature), unlike
  the warning on the broadcast rung.
- Unify the first parameter name across the ladder (value -> arr).
- Un-invert the warning-policy loop in broadcast_to_coords.
- Rename the test whose name forced an awkward signature wrap to a
  behavior-oriented name (test_extra_dims_pass_broadcast_rung_fail_strict_rung).

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

* docs(common): add numpydoc Parameters/Returns to the three public rungs

Parameter entries carry descriptions only — types live in the function
signatures.

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

* refactor(common): unify the broadcast rungs into broadcast_to_coords(strict=...)

Per review discussion: one public function instead of two, with strict as
a keyword flag.

- strict=True (default): any mismatch with coords raises, naming label in
  the error — the former strict_broadcast_to_coords.
- strict=False: mismatches pass through for downstream xarray alignment —
  the former loose broadcast_to_coords, used by arithmetic.

Strict is the default so that forgetting the flag adds safety rather than
silently dropping validation. MI handling preserved exactly per mode
(strict: silent partial / raise on gap; non-strict: EvolvingAPIWarning) —
the scenario-B deprecation warnings land separately in #732.

Call sites: model.py bounds/mask drop the long name (strict is default);
arithmetic and as_expression pass strict=False explicitly.

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

* feat(common): require label when broadcast_to_coords is strict

Restores the contract align_to_coords always had: strict-mode errors must
name their subject ("lower bound could not be aligned..." rather than
"Value could not be aligned..."). Enforced both statically (overloads:
strict=True requires label: str, strict=False forbids it) and at runtime
(TypeError).

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

* feat(common): deprecate implicit MI-level projection everywhere (scenario B)

Per the #737 review discussion and Fabian's decision: implicit level
projection is deprecated and will raise under the v1 convention, so the
EvolvingAPIWarning now fires in both modes of broadcast_to_coords — the
MI check is the same for every use case:

- input missing a whole level: warn (strict and non-strict)
- coverage gap (level combinations without a value): warn (non-strict) /
  raise (strict — no downstream layer to defer the NaN to)

Warning emission lives in one helper, _warn_implicit_projections, with a
TODO(#738) to migrate to LinopySemanticsWarning once #717 lands.

Also clarifies the MultiIndex terminology everywhere: an MI dim has
*levels* and *level combinations* (one tuple per position). Docstrings
carry the glossary, the coverage-gap error names the missing
combinations explicitly, and "entry" is gone from messages.

User-facing: add_variables / add_constraints with per-period-style
bounds now emit the deprecation warning (PyPSA multi-investment).

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

---------

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

* docs(release_notes): surface the MI-projection deprecation and DataArray-coords breaking change

- The implicit MultiIndex-level projection deprecation (scenario B) now
  has its own entry under Deprecations, where PyPSA users scanning for
  upcoming warnings will find it.
- Breaking Changes gains the CoordsLike narrowing: DataArray entries in
  sequence-form coords raise TypeError (pass variable.indexes[dim]
  instead).

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

* fix(common): reject unnamed-MultiIndex inputs in strict validation

validate_alignment unwrapped only bare pd.MultiIndex coord entries, so
Coordinates-backed (DataArray) MI dims read as non-MI and skipped the
equality check. Use _as_multiindex on both sides to catch mismatches
regardless of level names.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Fabian Hofmann <fab.hof@gmx.de>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.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.

3 participants