refactor(common): split DataArray conversion into a 3-rung strictness ladder - #737
Conversation
… 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>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@FabianHofmann For me the whole broadcasting and alignment in linopy is at least as difficult to undnerstand as the arithmetics. And arithmetics also rely on this broacasting. SO i think getting this right, with clear methods that have distinct, understandable roles is a really important step. Thats why im putting this much effort into it. And thanks for your work aout Multiindex. Im not really that good in that area |
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>
… 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>
|
@FBumann thanks for taking another look, I read the code and the tests. it all makes sense. it is a complicated thing and the time spent here is definitely worth it. I am wondering whether we are already at the optimum. still thinking about the arithmetics the generalization we have there is the I would see that the final API could be equivalent to most of the calls of as_dataarray + reindex/broadcase calls that we have could be replaced by one DetailsConvergence targetas_dataarray(arr, coords, dims) # convert only — matmul
align_to_coords(arr, coords, *, join="defer", # convert + broadcast + reconcile [+ validate]
enforce_dims=False, fill_value=NA,
dims=None, label=None)Two public functions instead of three. What to change in #7371. Don't ship
2. Put for dim, coord_values in expected.items():
...
if actual_idx.equals(expected_idx):
continue
same_set = len(actual_idx) == len(expected_idx) and set(actual_idx) == set(expected_idx)
if join == "defer":
if same_set: # today's behavior verbatim
arr = arr.reindex({dim: expected_idx})
elif join == "left":
arr = arr.reindex({dim: expected_idx}, fill_value=fill_value) # NEW
elif join == "override":
if len(actual_idx) == len(expected_idx):
arr = arr.assign_coords({dim: expected_idx})
# join == "exact": leave values; the contract check raises (see #3)
3. Make the policy a parameter keyed off enforce_dims: Literal[False, "warn", "raise"] = False
The 4. Exclude 5. Migrate call sites in #737 (atomically).
6. Demonstrate
This makes the What stays out of scope (and why — confirmed by the exploration)
Net effect on #737Same diff size, same zero-behavior-change guarantee, same matmul/flag fixes — but the public surface lands as Two decisions for you
Want me to check out |
|
@FabianHofmann I think the important thing is to get the mental model right and to know what is done where in the codebase. This will be achieved with this PR i think. After that, refactoring to the proposed design seems much simpler. However, Im not sure of going from 3 back to 2 methods is desireable. Its 3 very distinct methods. A join parameter in general is a good idea I think, but if we add an ambigous option like "defer", which is not unknown for a join, this creates new ambiguity i think. My proposal would be: Lets merge this PR, then look if we need a refactoring back to 2 mehods and a join parameter after all. |
|
@FabianHofmann I thought about the The pattern:
|
| operation | reference = another object | reference = explicit coords |
|---|---|---|
| convert | — | as_dataarray(arr, coords, dims) |
| broadcast — make dims agree | Variable.broadcast_like(other) ✓ |
broadcast_to_coords(arr, coords) |
| reindex — make entries agree, fill gaps | reindex_like(other) on expressions / constraints ✓ |
reindex_to_coords(arr, coords, fill_value=…) ← NEW (your join="left") |
| exact check — entries must agree, else raise | align(a, b, join="exact") ✓ |
strict_broadcast_to_coords(arr, coords, label=…) (today's align_to_coords) |
| inner / outer join — both sides change | align(a, b, join=…) ✓, operator join= ✓ |
impossible |
Three things the matrix shows:
1. The family completes what linopy already half-has. Each _to_coords function is the coords-flavored sibling of an existing method. Nothing exotic.
2. Why there's no join= parameter in the right column. A join that changes both sides (inner / outer) cannot be completed there: coords is frozen, and the expression it came from isn't in the function's hands — it can't be grown to match. The only place holding both sides is the operator (.add / .mul / .le (..., join=) → _align_constant), which is where inner / outer already live. What remains expressible against a frozen reference is exactly: check (exact) or conform (left) — two functions, not a parameter.
3. A purity note. xr.broadcast / broadcast_like quietly outer-align conflicting shared-dim entries before broadcasting (they can — they hold both objects). broadcast_to_coords deliberately doesn't: a half-completed join is worse than none, so entry conflicts pass through untouched to the operator's join=. This matches v1 §9, where broadcasting is dims-only and entry conflicts are §8's business.
The rename: align_to_coords → strict_broadcast_to_coords
The strict rung never aligns anything — it checks and raises. "Align" is also exactly the word that invited the join= idea: aligns take joins, broadcasts don't. Naming it as what it is — the same broadcast with a strict failure mode — makes the no-join design self-enforcing, and puts the required label argument on the one function that needs it.
Follow ups
The join="left" capability — follow-up PR as reindex_to_coords, migrating the two broadcast + reindex_like call sites (to_linexpr coefficient, to_constraint rhs) so it ships with real users. #737 stays purely structural.
Refined with Claude Code.
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>
|
I prototyped Reason 1: Reason2: |
- 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>
Parameter entries carry descriptions only — types live in the function signatures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
sounds good. we are moving in the right direction. strict_broadcast and broadcast are differing in the MI checks. I would argue the latter should go to both. Second, and that is on me, the partial level coverage should be supported in future (I have to check compliance with v1 conventions again quickly). that said, I still think the two should live in one function |
I would leave the MI stuff up to you. I never use MI and therefore dont know whats needed. If it can be the same/extracted, thats a win i think! And if the MI stuff is equal, both methods could be one. I'd suggest the signature: broadcast_to_coords(arr, coords=None, dims=None, *, strict=True, label=None, **kwargs)
That said, we can only unify the methods if we actually unify what strict means:
So I'd wait for your v1 check — once strict means one thing, the merge is mechanical. EDIT |
|
great, let's go |
|
@FabianHofmann You where to fast. I edited it a bit. Did you check v1? |
|
@FBumann let me reconcile my thoughts on the partial coverage. you do the signature change, use MI handling of today's strict version and I make some research again how MI is supported in upcoming xarray versions (which could change the picture). so don't mind the MI handling (perhaps don't deleted the checks as well), merge this one as soon as you feel ready and I take another look at MI in #732 |
|
Note Claude Code summary — structuring the open MI question so it's a one-table decision. The
So the unified A — v1 allows partial-level projection (stays a feature)
→ the #732 partial-level warning is removed; #717 needs an amendment legitimizing the projection (§9 extended to MI levels). B — v1 forbids implicit projection (#717 as written, §8/§11)
→ per-period bounds (PyPSA multi-investment) become deprecated usage with a migration path before v1; #717 stays as written. Bottom line: pick A or B; the merge into one function is mechanical after that. A = amend the convention. B = deprecate what #732 just shipped. @FabianHofmann this really helped me |
|
we should learn from xarry community which struggled a lot with MI's it seems - let's pick B! |
|
Note Claude Code reference note — the MultiIndex representation that underlies every MI issue in this stack. Useful background for the #732 MI follow-up. Click to expandA pandas MultiIndex has two xarray representations, and the friction between them is the root of all the MI complexity here: Stacked: 1 dim + level coords (what linopy / PyPSA use)One dimension; each level is a non-dimension (auxiliary) coordinate attached to it. Can represent sparse indexes (only the combinations that exist). Unstacked: 2 dims (what
|
| Issue | Cause |
|---|---|
| Partial-level broadcast (per-period bounds) | input has period as a dim; target has period as a level coord. Same name, different role → the projection translates between the two representations |
| v1 §11 applies (aux-coord conflict) | level coords are auxiliary coords — a level-indexed operand is exactly the §11 case |
| pydata/xarray#11368 (reindex fails) | reindexing the stacked dim must also rewrite the level coords; raw indexers don't know they exist |
expand_dims workaround in #732 |
expanding a missing MI dim must create dim + all level coords, not just the dim |
| Coverage gaps | only exist when conforming a sparse stacked index to a full one — the unstacked form can't even express sparsity except as NaN |
So for the #732 MI follow-up: the question "how is MI supported in upcoming xarray versions" is concretely "does xarray's indexer API learn to handle level coords" (#11368) — the stacked form itself is stable; it's every operation that crosses between the two forms that needs hand-holding.
yes, that is all correct but no blocker right? atm we support partial level coverage ie. allowing levels as indexes, but this will change in future (warn now, raise later) |
Yes, i just wanted to dump some context into this PR. Not blocking |
…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>
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>
…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 Ready |
…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>
…cenario B v1) Closes the two integration points between the alignment layer (#732/#742) and the v1 semantics infrastructure: - _warn_implicit_projections -> _enforce_implicit_projections: under legacy semantics, the implicit MultiIndex-level projection deprecation now goes through warn_legacy() / LinopySemanticsWarning (#738, replacing EvolvingAPIWarning, which stays piecewise-only); under the v1 convention it raises ValueError (sections 8 and 11) — the projection must be written explicitly (scenario B of the #732/#737 discussion). - as_expression no longer swallows the underlying conversion error: "Cannot convert to LinearExpression: <original message>" so the v1 guidance reaches the user. Tests: the MI-projection deprecation tests in test_alignment, test_variable, test_constraint, and test_linear_expression are marked @pytest.mark.legacy and assert LinopySemanticsWarning; each gains a @pytest.mark.v1 counterpart asserting the v1 raise. Full suite under both semantics: 6446 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The convention was silent on inputs indexed by levels of a stacked MultiIndex dimension — the question resolved as scenario B in the #732/#737 discussion. Now written into section 11: - level coords are auxiliary coordinates, so a level-named operand dim is a section-11 conflict: it raises, with the explicit .sel() projection as the documented recipe; - a full reconstruction of the MultiIndex is not a conflict (same coordinate spelled differently, aligns under section 8); - legacy projects implicitly and warns; the projection is removed at 1.0 (added to legacy-removal.md). Also: the #736 TODO no longer claims #732 is unmerged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts operand The master merge updated _add_constant_legacy to broadcast_to_coords(strict=False) (it was in the conflict region) but _add_constant_v1 was added by this branch in a region master never touched, so it kept calling as_dataarray — which since #737 is convert-only and no longer broadcasts. A v1 addition of an array operand needing dim expansion failed with "conflicting sizes". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add arithmetic-convention goals
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>
* docs: add convention.md placeholder
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>
* docs: write the v1 arithmetic convention spec
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>
* docs: drop the narrow "arithmetic" framing from the spec
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>
* feat: add semantics option and convention test harness
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>
* feat: v1 §5 and §8 on expression-OP-constant path
`_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>
* feat: v1 §8 on the expr+expr / var+var merge path
`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>
* feat: v1 §6 absence propagation through every operator
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>
* feat: Variable.reindex / .reindex_like (§4 absence creation)
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>
* fix: enforce v1 dead-term invariant in merge
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>
* feat: v1 §10 named-method join + §12 constraint RHS
`.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>
* feat: make piecewise and SOS2 reformulation v1-aware
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>
* test: pin v1 §13 reductions skip absent
§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>
* feat: v1 §11 raises on auxiliary-coordinate conflicts
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>
* test: pin aux-coord propagation guarantees (§11)
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>
* test: pin v1 dead-term invariant, == constraints, §11 ops, end-to-end 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>
* refactor: extract v1 semantics helpers into linopy/semantics.py
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>
* test: parameterize the three operator-uniform v1 test groups
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>
* refactor: split _add_constant / _apply_constant_op for clean 1.0 removal
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>
* fix(ci): defer linopy import in conftest + add missing type annotations
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>
* feat: self-describing v1 error messages
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>
* test: round out v1 coverage gaps + fix Variable.unstack absence sentinel
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>
* test: pin upstream catches for objective and constraint-LHS NaN
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>
* feat: site-specific, actionable legacy warnings (goal #2)
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>
* feat: full-text warning assertions + stdlib stacklevel + docs-plan
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>
* fix: §11 aux-coord check fires on every join, not just join=None
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>
* fix: §6 absence propagates through quadratic factor product
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>
* test: parametrize §6 quadratic propagation across entry points
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>
* fix: §5 user-NaN check on Variable.to_linexpr(coefficient)
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>
* fix: §10 join='override' rejects shared-dim size mismatch
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>
* fix: split legacy RHS warnings — coord-mismatch vs user-NaN
``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>
* fix: structural §8 pre-check, drop brittle xarray exception parse
``_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>
* docs: lock §5 — user NaN raises, close #627 alternative
#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>
* perf: hoist semantics imports out of Variable.to_linexpr hot path
``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>
* refactor: thread op_kind explicitly through _apply_constant_op
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>
* fix: §11 aux-coord — document asymmetric presence, split shape vs value, 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>
* fix(types): sort with key=str so override gate is Hashable-safe
``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>
* docs: trim docs-plan to an early-stage outline
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>
* refactor: dedupe v1-semantics helpers
- 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>
* docs: define object scope and the unlabeled-operand pairing rule
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>
* docs: link unlabeled-pairing TODO to tracking issue #736
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(alignment): make implicit MI projection semantics-aware (#738, scenario B v1)
Closes the two integration points between the alignment layer (#732/#742)
and the v1 semantics infrastructure:
- _warn_implicit_projections -> _enforce_implicit_projections: under
legacy semantics, the implicit MultiIndex-level projection deprecation
now goes through warn_legacy() / LinopySemanticsWarning (#738,
replacing EvolvingAPIWarning, which stays piecewise-only); under the
v1 convention it raises ValueError (sections 8 and 11) — the
projection must be written explicitly (scenario B of the #732/#737
discussion).
- as_expression no longer swallows the underlying conversion error:
"Cannot convert to LinearExpression: <original message>" so the v1
guidance reaches the user.
Tests: the MI-projection deprecation tests in test_alignment,
test_variable, test_constraint, and test_linear_expression are marked
@pytest.mark.legacy and assert LinopySemanticsWarning; each gains a
@pytest.mark.v1 counterpart asserting the v1 raise. Full suite under
both semantics: 6446 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(convention): spec the stacked-MultiIndex rule (scenario B)
The convention was silent on inputs indexed by levels of a stacked
MultiIndex dimension — the question resolved as scenario B in the
#732/#737 discussion. Now written into section 11:
- level coords are auxiliary coordinates, so a level-named operand dim
is a section-11 conflict: it raises, with the explicit .sel()
projection as the documented recipe;
- a full reconstruction of the MultiIndex is not a conflict (same
coordinate spelled differently, aligns under section 8);
- legacy projects implicitly and warns; the projection is removed
at 1.0 (added to legacy-removal.md).
Also: the #736 TODO no longer claims #732 is unmerged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(expressions): close merge gap — _add_constant_v1 must broadcast its operand
The master merge updated _add_constant_legacy to
broadcast_to_coords(strict=False) (it was in the conflict region) but
_add_constant_v1 was added by this branch in a region master never
touched, so it kept calling as_dataarray — which since #737 is
convert-only and no longer broadcasts. A v1 addition of an array
operand needing dim expansion failed with "conflicting sizes".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat: unlabeled-operand pairing by size (#736)
Closes #736. Stacked on #717 (`feat/arithmetic-convention`).
> **Draft:** review-ready and design-final, but the diff is against #717, so it can't merge to master until #717 does. (Not gated by the #744 MultiIndex decision — this PR's size-pairing never touches MultiIndex level coords; its `HELPER_DIMS` usage is `_term` / `_factor`, orthogonal to how MultiIndexes are stored.)
Implements the last unimplemented rule of the v1 convention's coordinate-alignment intro: **unlabeled operands pair with the linopy operand's dimensions by size.**
## The rule
numpy arrays, lists, and polars Series carry no dimension labels, so their axes adopt the operand's dims **by size** — the same rule for arithmetic operands *and* for `add_variables` / `add_constraints` bounds and masks (no positional carve-out for construction):
```python
x = m.add_variables(coords=[a4, time5]) # dims (a: 4, time: 5)
(1 * x) + np.arange(5) # length-5 → pairs with `time`
(1 * x) @ np.arange(5) # matmul contracts `time`, result keeps `a`
```
Ambiguity raises (v1), pointing at the explicit fix:
```python
y = m.add_variables(coords=[p4, q4]) # both dims size 4
(1 * y) + np.arange(4)
# ValueError: ... could pair with any of ['p', 'q'] — sizes alone cannot decide.
# Wrap the array in an xarray.DataArray with explicit dims.
```
| Case | v1 | legacy |
|---|---|---|
| unique size match | pair by size | pair positionally (agrees → silent) |
| size matches a non-leading dim | pair by size | pair positionally, **warn** (v1 differs) |
| ambiguous (shared size / square) | **raise** | pair positionally, **warn** |
| no size match | **raise** | pair positionally, **warn** |
The rule is uniform: `add_variables(coords=[a:4, time:5], lower=np.arange(5))` now resolves `lower` to `time` (positional pairing errored on `a`); a square/ambiguous bare-numpy bound raises asking for a `DataArray` wrap, instead of silently guessing.
## Implementation (`linopy/alignment.py`)
- `_pair_axes_by_size` + `_dims_for_unlabeled_operand` — the size-pairing with the legacy/v1 fork.
- **`as_constant`** — normalizes degenerate operands *on entry*: a Python `list` → numpy array (lists have no numeric operators), a 0-d array → Python scalar (takes the scalar fast-path, never pairs). `ConstantLike` stays numeric-only.
- `_broadcast_to_coords` gains `unlabeled_pairing="semantic"` for the arithmetic path; explicit-coords callers (`add_variables`) stay positional.
- **Two conversion fixes** the seam exposed: `as_dataarray`'s scalar branch and the positional fallback now exclude `HELPER_DIMS`, so a scalar never broadcasts over `_term` / `_factor`. (`HELPER_DIMS` was already the global registry; `_group` is transient and correctly excluded.)
## Why `dims=self.coord_dims` was dropped from the operator calls
The arithmetic operators (`expressions.py` `_add_constant`, `_apply_constant_op`, `to_constraint`; `variables.py` `to_linexpr`; both `__matmul__`) previously passed `dims=self.coord_dims` into `broadcast_to_coords`. That argument **pinned an unlabeled array's axes to those dims by position** — which is exactly the behavior #736 replaces. While it's passed, size-pairing can never run (an explicit `dims` means "the caller named the axes").
So every arithmetic call site drops it. This is safe because `coords=self.coords` already carries the same dim information, and `dims` only ever affected *unlabeled* inputs:
- **DataArray / named-pandas operands** ignore `dims` entirely (they carry their own labels) — unchanged.
- **Unlabeled operands** (numpy / list / polars) now reach `_dims_for_unlabeled_operand` and pair by size — the point of the PR.
`dims=self.coord_dims` did do one extra thing worth calling out: it excluded helper dims (`_term`, `_factor`) from the positional labeling. Dropping it surfaced three latent `HELPER_DIMS` leaks (a scalar gaining `_term`, etc.), now fixed in the conversion layer (`as_dataarray`'s scalar branch and the positional fallback both exclude `HELPER_DIMS`). `as_constant` on entry and `UNLABELED_TYPES` as the single dispatch source complete the picture; Variable operators inherit it all via delegation.
## Tests
- `TestUnlabeledPairing` (test_legacy_violations) — parametrized over numpy / list / polars, with `@pytest.mark.legacy` / `@pytest.mark.v1` pairs covering pair-by-size, order-independence, ambiguity, no-match, the DataArray escape hatch, and matmul.
- The two pre-existing unlabeled-rhs constraint tests forked legacy/v1.
- `benchmark_model` example named its rhs axis (it used an ambiguous square-dim rhs — now demonstrates the convention).
## Verification
Full suite under both semantics: **6458 passed, 548 skipped**. mypy + pre-commit clean. convention.md §736 TODO resolved; legacy-removal.md updated.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: align reordered shared-dim coords by label in merge (#550) (#758)
* fix(alignment): align reordered shared-dim coords by label in merge (#550)
§8 aligns by label, not position, so the same labels in a different order are
the same coordinate. The constant path already reindexed a pure reorder, but
the expression-merge path raised under v1 and silently misaligned under legacy.
merge() now conforms shared user dims to the first operand's order before the
§8/§11 checks, so a reorder reindexes (correct under both semantics) while a
differing label set still raises. Aux coords ride along the reindex, so §11
conflicts are preserved.
Spec: convention.md §8 now states order-independence explicitly and is retitled
"Shared dimensions must carry the same labels" (was framed as xarray's
order-sensitive `exact`).
Supersedes #550.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(merge): fold reorder-conform and §8 mismatch detection into one pass
The reorder fix added a second walk over the shared user dims (one to reindex
reordered coords, one to detect a genuine mismatch), duplicating the per-dim
.equals() work on every join=None merge — the hot path during model building.
conform_merge_dims does both in a single pass and replaces
merge_shared_user_coord_mismatch + the separate conform helper. Behaviour is
unchanged (full suite 6476 passed under both semantics).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(merge): pin reorder behaviour on multi-operand, quadratic, and MultiIndex paths
Regression guards for the §8 by-label alignment beyond the 2-operand case:
multi-operand merge([a,b,c]) pairs by label, quadratic merge aligns reordered
dims, and a reordered stacked MultiIndex raises (xarray cannot reindex it by
tuple — left to §11; tied to the #744 MultiIndex-storage decision).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(merge): align reordered stacked MultiIndex by tuple (resolve §8/§11 gap)
A reordered full MultiIndex is "the same coordinate spelled differently" and
per §8/§11 should align — but reindex cannot reorder a stacked MI by tuple, so
it previously fell through to a confusing §11 aux-coord raise. conform_merge_dims
now permutes via positional isel using get_indexer, which works uniformly for a
plain index and a MultiIndex's tuples (and get_indexer doubles as the same-set
test, replacing the set() comparison — cheaper). A genuinely different label set
still raises the §8 mismatch. convention.md §11 now states order-independence
for the full-MI case explicitly.
Only the MultiIndex case was affected; a plain dim with aux coords already
reordered correctly (the aux coord rides along the permute).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(merge): raise dim mismatch before the aux-coord check
A shared-dim label mismatch is the root cause, so report it as a dim conflict
rather than letting the §11 aux check fire first — a different stacked
MultiIndex otherwise surfaced as its level coords conflicting (the wrong
message). Aux conflicts still raise once the dims agree. Adds a routing test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(merge): assert via public .indexes, not .coeffs.coords
The reorder tests reached through the internal term storage (.coeffs.coords)
for coordinates that the expression exposes directly via .indexes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(merge): make reorder-align v1-only; legacy keeps positional + warns
Per the transitioning contract, legacy must not change: reordering coords on
a shared dim was always positional in expression merges (the constant path,
by contrast, has always aligned labelled operands by label — that asymmetry is
genuine legacy and is what v1 unifies). conform_merge_dims now permutes only
under v1; under legacy it leaves the operands positional and the caller warns
with a reorder-specific message (v1 would align by label, a different result).
Tests: the align cases are now @pytest.mark.v1; added legacy guards for the
positional result and the full warning text.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: pin legacy side of §13 reductions-over-absent divergence
TestReductionsSkipAbsent only asserted the v1 side (sum/groupby.sum skip
absent slots). Add the matching @pytest.mark.legacy tests so the silent
result divergence is pinned on both sides: legacy fills the absent slot
with 0, so the extra term is counted (sum → 25, group 0 → 10) where v1
skips it (sum → 20, group 0 → 5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: pin legacy side of remaining v1/legacy result divergences
Extends the §13 work to the other classes where legacy silently computes
a different result than v1 (not just raises/warns):
- TestAbsencePropagation: legacy fills absent slots with 0 / keeps live
terms (to_linexpr, scalar ops, var+var & multi-operand merge, quadratic).
- TestFillnaResolves: fillna is a no-op under legacy (slot already 0);
outer fillna-then-add double-counts.
- TestVariableReindex: reindex-introduced absence collapses to 0.
- TestNamedMethodJoin: .le(join="inner") keeps all coords (NaN at gaps).
- TestConstraintRHS: absent LHS keeps RHS, constraint not dropped.
Verified the all-absent sum is *equal* under both modes (sum-of-none vs
sum-of-zeros both → 0), so it carries no legacy twin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: unmark equal-behaviour tests so they run under both semantics
21 tests were marked @pytest.mark.v1 but assert behaviour that is
identical under v1 and legacy. Verified against master (6a5d748) that
legacy reproduces master for every one — no accidental behaviour change.
Removing the marker lets the autouse `semantics` fixture exercise them
under both modes.
Covers TestObjectScope (arithmetic identities), TestExactAlignmentMerge
(var+var merge already aligns by label on master; only the constant path
diverges and stays v1-marked), TestNamedMethodJoin (explicit join=),
TestAuxCoordConflict escape hatches, TestFillnaResolves shared cases, and
the dataarray-wrapping / fillna-binds tests (dropping their _v1 suffixes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: re-mark reordered-merge tests v1 and pin the legacy pairing
Correction to the previous unmark commit. test_var_plus_var_reordered_
labels_align and test_quadratic_merge_reordered_aligns only asserted
.indexes, which is identical under both semantics — so they looked like
equal-behaviour tests, but assert_linequal/assert_quadequal show the
*variable pairing* genuinely diverges: v1 pairs by label, legacy pairs
positionally (preserving master's #550 behaviour).
Re-mark both v1, strengthen them to assert the by-label pairing (so they
actually verify the #550 fix, not just the index order), and add legacy
twins pinning the positional pairing.
Found by re-verifying the unmark set with linopy.testing's strict
structural comparison instead of trusting each test's own assertions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: re-mark fillna-on-masked tests v1 (strict-equality bar)
The three fillna(0)-on-masked tests produce an identical solver model
under both semantics, but differ in dead _term padding (v1 leaves the
resolved slot's sentinel term with coeff NaN; legacy with 1.0), which
assert_linequal/assert_conequal treat as unequal. Adopting strict
structural equality as the "equal behaviour" bar, mark them v1:
- test_variable_fillna_zero_revives_slot_as_present_zero
- test_masked_variable_constraint_via_fillna
- test_masked_variable_model_v1_fillna_binds (restored _v1 suffix)
Restores their v1-specific docstrings (they document the §7 resolve
mechanism, not shared behaviour).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: add strict v1/legacy equivalence guard
A per-op test that under-asserts (e.g. checks only .indexes) can pass
under both semantics while the result silently diverges — that is how the
reordered-merge mispairing slipped through. Add a parametrized guard that
builds each mode-invariant operation under BOTH semantics and compares
with linopy.testing's strict structural helpers (assert_linequal /
assert_quadequal / assert_conequal). A regression that makes one of these
paths semantics-dependent now fails loudly. Verified it catches the
reordered-merge divergence as a negative control.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(types): drop now-unused rhs-setter type-ignores and cast test helpers
After merging master, the Constraint.rhs setter accepts a DataArray, so the
`# type: ignore` on the MI-level rhs tests is unused (mypy --warn-unused-ignores).
Also cast the legacy-violation `_op_*` helpers to their documented runtime
type, which the paired assert_linequal/assert_quadequal already verify, so
`mypy .` is clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(benchmarks): v1-vs-legacy build report + v1 benchmark coverage
Add a transitional "Semantics report" workflow: it builds every benchmark spec
twice — the default semantics (legacy) and LINOPY_BENCH_SEMANTICS=v1 — under the
same node ids, then posts a v1-vs-legacy A/B to the PR: side-by-side peak & time
relative-change plots (SVG, hosted on an auto-created assets branch) with the
full text table collapsed below, plus an artifact (interactive HTML plots + CSV
+ text table). Report-only, this-commit only — the gate and the vs-master
history stay with CodSpeed, whose ids are untouched (legacy is the unsuffixed
default).
Run benchmark-smoke under both conventions too; that surfaced specs that only
built under legacy, fixed here:
- expression_arithmetic / milp: label the square coeff arrays so v1 doesn't have
to pair their equal-length axes by size (legacy guessed)
- pypsa_carbon_management: skip under v1 — PyPSA emits NaN-valued constants v1
rejects by design (keys on linopy.options['semantics'])
Pins pytest-benchmem[plot-static]==0.4.7 (benchmem compare/plot + kaleido SVG; clearer plot labels).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(arithmetics): track open-items.md rollout checklist (#744)
Commit the v1-rollout checklist (previously an untracked scratch file) as the
shared per-stage status tracker alongside goals.md/convention.md. Organised by
goals.md's three stages (opt-in / default / 1.0); #744 (MultiIndex storage) is
the one open design decision, gating the default flip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(convention): scope §13 to implemented reductions; defer rest to #703
§13's skip-absent principle is live v1 semantics for the reductions that exist
(`sum`, `groupby.sum`, the objective total), so it stays in the convention.
`mean` / `resample` / `coarsen` are not in linopy yet — specifying their exact
semantics here is spec-ahead-of-code, so trim them to a forward-pointer to #703
(xarray method coverage), where they land as additive features that follow the
same skip-absent rule. Keeps the convention == shipped behaviour.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(convention): note §13's future reductions are v1-only (#703)
mean/resample/coarsen are added under v1 only — legacy is a frozen compatibility
layer removed at 1.0, so it never gains new operations (and needs no fill-0
semantics designed for them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf: fix v1 build-memory regressions (exact-join reindex + Variable×const routing) (#804)
* perf(v1): skip no-op exact-join reindex in constant ops
_apply_constant_op_v1 / _add_constant_v1 resolve join=None to "exact", then
_align_constant returned needs_data_reindex=True unconditionally — so a plain
`coeffs * expr` ran xr.align + self.data.reindex_like (two full-dataset
deepcopies) even though the factor was already broadcast to self.coords, making
the exact-join alignment a no-op.
first_mismatched_dim is the §8 exact check (order/label-strict via
indexes.equals), so when it finds no mismatch the align changes nothing: return
needs_data_reindex=False and take the cheap assign() path, skipping both copies.
v1 build peak: isolated multiply -56%; full-build v1/legacy median 1.20x -> 1.00x
(legacy unchanged — its default path already returned False). Full suite: 7629
passed under both semantics.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf: fold Variable*constant into one construction step (both modes…
Stacked on #732. Resolves the open questions from the #732 review thread by re-layering
linopy.common's DataArray conversion. The design was developed in this PR's comment thread.What
Two public entry points — replacing the
as_dataarray-with-flag + private_as_dataarray_lax+align_to_coordstrio:strictdecides what happens to anything broadcasting alone cannot resolve (extra dims, disagreeing coord values, MI coverage gaps):strict=True(default): raise, naminglabelin the error (labelis required in this mode — enforced via overloads and at runtime). Forgetting the flag adds safety instead of silently dropping validation.strict=False: pass through for downstream xarray alignment (the operator'sjoin=owns reconciliation).Backed by one private mechanics function (
_broadcast_to_coords) that reports MultiIndex projections (_LevelProjection) instead of deciding what they mean; the public function applies policy per mode.Caller profiles
__matmul__(2 sites) — broadcasting would contracta=(time,name) @ b=(name,location)to(location)instead of(time,location)as_dataarray(other, coords=…, dims=…)add_variables/add_constraintsbounds + maskbroadcast_to_coords(lower, coords, label="lower bound")to_linexpr,as_expression(constraintlhs/rhssetters)broadcast_to_coords(other, coords=…, strict=False)MultiIndex policy (scenario B — decided in this thread)
Terminology: a stacked MultiIndex dim has levels (its component index names, e.g.
period/timestep) and level combinations (its elements — one tuple per position, e.g.(2030, 't1')).Implicit level projections are deprecated everywhere and will raise under the v1 convention — the MI check is the same in both modes:
strict=False(arithmetic)strict=True(bounds/mask)EvolvingAPIWarningEvolvingAPIWarningEvolvingAPIWarningValueError— the error lists the missing combinationsThe warning channel carries a
TODO(#738): migrateEvolvingAPIWarning→LinopySemanticsWarningonce #717 lands.This removes the
enforce_level_coverageflag and the cross-module use of a private helper — the two things the #732 thread flagged.Behavior changes
One, deliberate (scenario B):
add_variables/add_constraintswith inputs indexed by a subset of a MultiIndex's levels (e.g. PyPSA's per-period bounds) now emit theEvolvingAPIWarningdeprecation warning. Everything else keeps the semantics it has on #732. (Also caught in review and fixed: the constraintlhs/rhssetters go throughas_expression, which now usesstrict=False— with regression tests.)Tests
as_dataarraydoesn't expand /broadcast_to_coords(strict=False)passes mismatches through /strict=Truerejects them with labeled errors, stays silent on partial-level bounds.test_matmul_contracts_only_shared_dims: pins(dim_0,dim_1) @ (dim_1,location) → (dim_0,location).rhssetter regressions: missing-dim broadcast + MultiIndex-level projection.Out of scope (agreed in the thread)
reindexonto a stacked MI is broken upstream).reindex_to_coords— deferred until a coords-only caller exists; blocked on Cannot reindex onto a stacked MultiIndex via indexers — only reindex_like works pydata/xarray#11368.EvolvingAPIWarning→LinopySemanticsWarning) — Unify deprecation warning channels before v1: EvolvingAPIWarning vs LinopySemanticsWarning #738, blocked on feat: v1 semantic convention #717.🤖 Generated with Claude Code