refac: Harmonize linopy operations and introduce a new predictable and strict convention - #591
refac: Harmonize linopy operations and introduce a new predictable and strict convention#591FBumann wants to merge 83 commits into
Conversation
…sets and supersets
Add le(), ge(), eq() methods to LinearExpression and Variable classes, mirroring the pattern of add/sub/mul/div methods. These methods support the join parameter for flexible coordinate alignment when creating constraints.
Consolidate repetitive alignment handling in _add_constant and _apply_constant_op into a single _align_constant method. This eliminates code duplication and makes the alignment behavior (handling join parameter, fill_value, size-aware defaults) testable and maintainable in one place.
numpy_to_dataarray no longer inflates ndim beyond arr.ndim, fixing lower-dim numpy arrays as constraint RHS. Also reject higher-dim constant arrays (numpy/pandas) consistently with DataArray behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use "exact" join for +/- (raises ValueError on mismatch), "inner" join for *// (intersection), and "exact" for constraint DataArray RHS. Named methods (.add(), .sub(), .mul(), .div(), .le(), .ge(), .eq()) accept explicit join= parameter as escape hatch. - Remove shape-dependent "override" heuristic from merge() and _align_constant() - Add join parameter support to to_constraint() for DataArray RHS - Forbid extra dimensions on constraint RHS - Update tests with structured raise-then-recover pattern - Update coordinate-alignment notebook with examples and migration guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@FabianHofmann Im quite happy with the notebook now. It showcases the convention and its consequences. |
…ords. Here's what changed: - test_linear_expression_sum / test_linear_expression_sum_with_const: v.loc[:9].add(v.loc[10:], join="override") → v.loc[:9] + v.loc[10:].assign_coords(dim_2=v.loc[:9].coords["dim_2"]) - test_add_join_override → test_add_positional_assign_coords: uses v + disjoint.assign_coords(...) - test_add_constant_join_override → test_add_constant_positional: now uses different coords [5,6,7] + assign_coords to make the test meaningful - test_same_shape_add_join_override → test_same_shape_add_assign_coords: uses + c.to_linexpr().assign_coords(...) - test_add_constant_override_positional → test_add_constant_positional_different_coords: expr + other.assign_coords(...) - test_sub_constant_override → test_sub_constant_positional: expr - other.assign_coords(...) - test_mul_constant_override_positional → test_mul_constant_positional: expr * other.assign_coords(...) - test_div_constant_override_positional → test_div_constant_positional: expr / other.assign_coords(...) - test_variable_mul_override → test_variable_mul_positional: a * other.assign_coords(...) - test_variable_div_override → test_variable_div_positional: a / other.assign_coords(...) - test_add_same_coords_all_joins: removed "override" from loop, added assign_coords variant - test_add_scalar_with_explicit_join → test_add_scalar: simplified to expr + 10
|
The convention should be Why
cost = xr.DataArray([10, 20], coords=[("tech", ["wind", "solar"])])
capacity # dims: (tech=["wind", "solar"], region=["A", "B"])
cost * capacity # ✓ tech matches exactly, region broadcasts freely
capacity.sel(tech=["wind", "solar"]) * renewable_costNo operation should introduce new dimensions Neither side of any arithmetic operation should be allowed to introduce dimensions the other doesn't have. The same problem applies to cost_expr # dims: (tech, time)
regional_expr # dims: (tech, time, region)
cost_expr + regional_expr # ✗ silently expands to (tech, time, region)
capacity # dims: (tech, region, time)
risk # dims: (tech, scenario)
risk * capacity # ✗ silently expands to (tech, region, time, scenario)An explicit pre-check on all operations: asymmetric_dims = set(other.dims).symmetric_difference(set(self.dims))
if asymmetric_dims:
raise ValueError(f"Operation introduces new dimensions: {asymmetric_dims}")Summary
|
Let's clearly differentiate between dimensions and labels. labelsI agree with "exact" for labels by default, but we need an easy way to have inner or outer joining characteristics. I found the pyoframe conventions x + y.keep_extras() to say that an outer join is in order and mismatches should fill with 0. x + y.drop_extras() to say that you want an I have in a different project used | 0 to indicate keep_extras ie (x + y | 0). dimensionsi am actually fond of the ability to auto broadcast over different dimensions. and would want to keep that (actually my main problem with pyoframe). your first example actually implicitly assumes broadcasting. |
Dimensions and broadcastingI agree that auto broadcasting is helpful in some cases. So the full convention requires two separate things: labelsI'm not sure if I like this approach, as it's needs careful state management of the flags on expressions. The flag (keep or drop extras) needs to be handled. import linopy
# outer join — fill gaps with 0 before adding
x_aligned, y_aligned = linopy.align(x, y, join="outer", fill_value=0)
x_aligned + y_aligned
# inner join — drop non-matching coords before adding
x_aligned, y_aligned = linopy.align(x, y, join="inner")
x_aligned + y_alignedCombining disjoint expressions would then still need the explicit methods though. |
|
The proposed convention for all arithmetic operations in linopy: I'm not sure how to implement the | operator yet. Might need some sort of flag/state for defered indexing |
|
I thought about the pipe operator: Would this be an issue for you? |
|
@brynpickering I worked on it a bit and treating nan as absent terms works quite well. I tried to address this, but its not that easy and makes the convention less clear. My resulting code in #627 pretty much only reduces the amount of fillna(0) calls in user code. But i thik its not work the complexity in the mental model and api complexity in linopy. Im quite happy with this branch #591 |
|
@FBumann agree that pre-alignment is cleanest. Thanks for the example. It's effectively what we do in the calliope optimisation backend, which is to align all relevant arrays to consistent sets for each constraint and expression. It could cause memory spikes in cases where alignment creates a very large (usually very sparse) array, which might soon after be collapsed with an aggregation. However, that's more of an issue for the user to handle in how they define their math. RE that second example. You're right, I can't reproduce it locally now either. Not sure what happened there. |
|
@brynpickering Thanks for the back and forth. I also ensure the alignment and indexing before working with linopy. This is a pain point which gets resolved a lot by the strictness of this convention. Linopy raises instead of guessing what might be correct |
Documents patterns for combining variables with different coordinate subsets and shared cost parameters, addressing feedback from PR #591. Shows four approaches: fillna+joins, dropna, scoped costs, and linopy.align() pre-alignment. Includes partial scaling factor examples. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Downstream PyPSA-Eur-style consumer perspective. We run an 8760h × NL+neighbors LP with several custom constraint modules that all sit in the v1-arithmetic blast radius described in the PR body. Sharing concrete patterns in case they're useful as regression cases. Patterns we'd test against v1:
Migration concern: the breakage surface is large for downstream PyPSA-Eur derivatives. A deprecation cycle that emits a Offer: happy to run our 8760h LP against a v1-enabled branch and report any silently shifted constraints (compare LP file hash + objective). Let us know which SHA you want exercised. |
|
@MaykThewessen Thanks for the perspective. We are working on this currently, but it will take a few weeks probably until we have it ready for you to run. Would you be open for discussion about the nan stuff? |
|
@FBumann yes, happy to. NaN semantics are the part that touches our code most Concrete starting point: we ran a controlled test of #627's draft against our
The design question is where the pre-mask should live. Two options:
I lean toward (2) because the NaN in question is created by linopy's own Happy to open a discussion issue with the repro + sketch of both options. |
Feel free to open one. I like your take on nan. Im leaning on defaulting to rais/strict, but allowing it via opt in seems reasonable. Im not sure if it should be in add_constraints though, because a user using pypsa cant reach that, and it would not work for expressions, as they are created bedore add_constraints() does its job. |
|
Superseeded by #717 |
…the answer (#236) The oracle lane runs clean under `linopy.options["semantics"] = "v1"`, which issue #8 has been parked on since the tracked PR (PyPSA/linopy#591) was closed unmerged. The live work is PyPSA/linopy#717; its spec is `doc/design/ convention.rst` on `feat/arithmetic-convention`. Two things the convention changes for us, and one it turns out it cannot: **§8 never fires.** Its "shared dimensions must carry identical labels" exists because linopy operands are arbitrary xarray objects with independent indexes, so a mismatch is ambiguous between "different data" and "subset". We resolve a master coordinate per dimension before any data binds (SPEC §8) and reindex every operand to it, so a superset already raises, a subset is filled and a reorder is fixed by label. Measured: four orderings of `a + factor + b` with a sparse factor and a masked variable produce byte-identical rows, so the associativity break v1 §8 exists to stop (PyPSA/linopy#711 — chained left-joins dropping coordinates) has no mechanism here. **§5 does fire, on our central idiom.** `load_parameters` reindexes to the master coordinates, so a parameter covering a subset of its dims arrives at linopy as NaN — and v1 refuses a NaN in a user-supplied constant, because from inside linopy a deliberate absence and a data error are indistinguishable. That is every sparse parameter, which is what SPEC §8 means by "sparse data gives sparse variables" and what a generated binder emits by the tableful. The 414 tests passing beforehand were not evidence otherwise; the suite had no sparse float parameter. It has one now, and it fails without this change. **So the answers are given per position, not once in the loader.** The same missing row means three different things: zero in a coefficient, an error in `bounds:` (unbounded is not bounded-at-zero), false in a `where` (SPEC §6's bare name is "non-null and finite"). A single fill in the loader would have to pick one and be wrong for the other two. `linopy/semantics.py` is where those answers live, mirroring linopy's own module of that name and for its reason: the evaluator stays about evaluating, and a later change to the convention is a single-file diff. All three functions are correct under the legacy convention too — they resolve absence to the value legacy reached implicitly — so none is conditional on the option. What v1 changed is that staying silent stopped being available. Verified on three configurations: released linopy 0.9.0 (415 passed), the v1 branch under `semantics="v1"` (415 passed), and the same branch under `semantics="legacy"` (413 passed, 2 failing only on `LinopySemanticsWarning` promoted to an error by our own filterwarnings). Refs #8
…ow (#234) * feat(compat): answer linopy's v1 convention where the position knows the answer The oracle lane runs clean under `linopy.options["semantics"] = "v1"`, which issue #8 has been parked on since the tracked PR (PyPSA/linopy#591) was closed unmerged. The live work is PyPSA/linopy#717; its spec is `doc/design/ convention.rst` on `feat/arithmetic-convention`. Two things the convention changes for us, and one it turns out it cannot: **§8 never fires.** Its "shared dimensions must carry identical labels" exists because linopy operands are arbitrary xarray objects with independent indexes, so a mismatch is ambiguous between "different data" and "subset". We resolve a master coordinate per dimension before any data binds (SPEC §8) and reindex every operand to it, so a superset already raises, a subset is filled and a reorder is fixed by label. Measured: four orderings of `a + factor + b` with a sparse factor and a masked variable produce byte-identical rows, so the associativity break v1 §8 exists to stop (PyPSA/linopy#711 — chained left-joins dropping coordinates) has no mechanism here. **§5 does fire, on our central idiom.** `load_parameters` reindexes to the master coordinates, so a parameter covering a subset of its dims arrives at linopy as NaN — and v1 refuses a NaN in a user-supplied constant, because from inside linopy a deliberate absence and a data error are indistinguishable. That is every sparse parameter, which is what SPEC §8 means by "sparse data gives sparse variables" and what a generated binder emits by the tableful. The 414 tests passing beforehand were not evidence otherwise; the suite had no sparse float parameter. It has one now, and it fails without this change. **So the answers are given per position, not once in the loader.** The same missing row means three different things: zero in a coefficient, an error in `bounds:` (unbounded is not bounded-at-zero), false in a `where` (SPEC §6's bare name is "non-null and finite"). A single fill in the loader would have to pick one and be wrong for the other two. `linopy/semantics.py` is where those answers live, mirroring linopy's own module of that name and for its reason: the evaluator stays about evaluating, and a later change to the convention is a single-file diff. All three functions are correct under the legacy convention too — they resolve absence to the value legacy reached implicitly — so none is conditional on the option. What v1 changed is that staying silent stopped being available. Verified on three configurations: released linopy 0.9.0 (415 passed), the v1 branch under `semantics="v1"` (415 passed), and the same branch under `semantics="legacy"` (413 passed, 2 failing only on `LinopySemanticsWarning` promoted to an error by our own filterwarnings). Refs #8 * refactor(engine): a fragment loses rows for two reasons; carry them apart Prerequisite for adopting v1's absence semantics, and inert on its own — no behaviour changes, 415 tests unmoved. A term fragment can lose rows at a coordinate for two unrelated reasons, and a constraint row has to react to exactly one of them. A **masked variable** is genuinely absent there. A **sparse parameter** is a compressed dense array whose missing rows mean a zero coefficient — SPEC §8's "sparse data gives sparse variables", and what a generated binder emits by the tableful. Once the two are multiplied into one frame the distinction is gone, which is why "drop the row where a variable is absent" could not be written as an anti-join against the term stream. So the variable's own coordinates ride alongside as `TermFragment.presence`, rewritten by the same shape operators that rewrite the term. The propagation rule follows from the convention rather than from convenience: variable leaf the variable's frame parameter, constant none — sparsity is an encoding, not absence a * b, a / b, -a the variable side's, unchanged: a sparse coefficient zeroes a term, it does not unmake the variable under it sum, group_sum cleared — §13 has reductions *skip* absent slots rather than propagate them, which is what keeps a cross-module accounting equation summing over a partly-masked dim roll, shift still to do: the coordinate map has to be applied to presence too, and shift's vacated edge unioned back in, since SPEC §7 declares it contributes zero Refs #8 * feat!: absence propagates and drops the row, on both lanes Adopts linopy's v1 reading of absence as the language's own, and makes v1 the oracle rather than a mode we happen to survive. **What changes.** A term whose variable is masked out no longer contributes zero — it makes the row absent, so `x + y >= 10` is *no constraint* where `y` is masked rather than `x >= 10`. The old reading is how x - rel_max * size <= 0 silently became `x <= 0` on an unsized component: feasible model, plausible answer, no error. That is goal 1 of the v1 convention ("no silent wrong answers") and the whole of PyPSA/linopy#712, and it was reachable here. **Two things deliberately do not propagate.** A *reduction* skips absent slots (§13), so `sum(x, over=d)` stays defined when only some of `d` exists — without that, one masked component would delete a system-wide accounting row. A *parameter* covering only some coordinates is sparse encoding, not absence: its missing rows mean a zero coefficient (SPEC §8), which is what lets a coefficient table hold live entries only. Absence is a property of variables. **How the engine tells them apart.** `TermFragment.presence` carries the variable's own coordinates beside the term stream, because once `coeff x var` are multiplied the frame cannot say which side removed a row. It is set only for a variable whose declaration has a `where` — decided off the plan, before data — so an unmasked variable never imposes the cost, and `_label_frame` keeps both of its arithmetic paths (#152, #178) for every equation that does not turn on the difference. **The oracle is v1, and it raises rather than skips.** A skip would be the worst outcome available: the suite would go green having stopped comparing the lanes on exactly the cases the convention changed. No release carries the option yet, so `[tool.uv.sources]` pins PyPSA/linopy#717 by branch — by branch and not by rev on purpose, since a stale rev would measure us against a spec that has moved. Not included, and it is the follow-up this needs: `defined(v)` (#219). Dropping the row is now the only reading available, and the way to ask for the other one is complementary `where` clauses over a variable's existence — which is not yet sayable. Until it lands, a model wanting "keep the row, treat the term as zero" has to carry a parameter mirroring the variable's mask. `test_a_constraint_row_left_with_no_variables` stays xfailed and is *not* this: raw linopy builds a term-less row under both conventions (`labels=[0,1]`, `vars=[-1]`), so that divergence lives in our own eager lane and wants its own diagnosis. Refs #8, #219 * feat: a bare variable name in a where asks whether it exists The escape hatch the previous commit made necessary. Absence now takes the row with it, so a model wanting the other reading — keep the row, treat the term as zero — needs a way to name those coordinates. Without it the only spelling is a parameter mirroring the variable's own mask: two sources for one fact, and when they drift the failure is not an error but a row quietly pinned to zero. Surface is the one the language already implies rather than a new one. A bare *parameter* name in a where asks "does this have a value here"; a bare *variable* name asks "does this exist here". Same grammar, same shape, and resolution already had a branch for the case — it just raised. - expression: x - rel_max * size <= 0 where: "size" - expression: x <= 0 where: "NOT size" Pointwise on both lanes: a semi-join against the variable's frame relationally, `labels != -1` eagerly. `_predicate_dims` reads it through the variable's `foreach` exactly as a parameter is read through its dims, so `_free_prefix` keeps its arithmetic path for the leading dims a mask cannot see. **A self-reference is now a load error**, found by the parity sweep, which masks `variables.p.where` and so asked `p` whether `p` exists. It died in linopy with `KeyError: 'p'` — nothing in this package's voice. A variable's own where cannot ask whether it exists, because that mask is what decides it. Two limits, both deliberate and both visible in the tests. The dim rule refuses a variable whose foreach exceeds the frame — masking `balance` over `[snapshot]` by `p` over `[snapshot, generator]` would silently widen the mask, and saying which reduction is meant needs ROADMAP Track 1 item 6 (`all(x, over=d)`). And the predicate has no home in the dispatch-model parity sweep for that same reason, so `COVERED_ELSEWHERE` maps it to the test that does exercise it rather than letting the coverage guard be quietly weakened. Closes #219. Refs #8
Harmonize linopy arithmetic with legacy/v1 convention transition
Why: silent bugs in the current (legacy) arithmetic
linopy's current coordinate alignment has several classes of silent correctness bugs. None of them raise errors — they produce results that look reasonable but are quietly wrong.
1. Positional alignment ignores labels (#586, #550, #257)
When two operands have the same shape on a shared dimension, linopy matches them by position, ignoring coordinate labels entirely:
This affects all arithmetic operators and constraint creation. The optimization solves without error, but with the wrong constraints.
2. Subset constants break associativity (#572)
When a constant has different-sized coordinates, a left-join drops coordinates that might be needed by a later operation:
(a + factor) + b ≠ a + (b + factor)— the result depends on operand order.3. User NaN silently swallowed (#620)
NaN in user-supplied data (often indicating missing or erroneous data) is silently filled with inconsistent neutral elements:
The fill values differ across operations with no consistent principle.
4. Absent slots indistinguishable from zero (#620)
Variable.to_linexpr()does not mark absent variable slots (from.shift(),.where()) as NaN, so multiplication andfillna()cannot distinguish "absent" from "zero":5. Inconsistent Variable vs Expression paths (#569, #571)
x * subset_constantand(1 * x) * subset_constantpreviously gave different results (one crashed, one didn't).What: the v1 convention
This PR introduces
linopy.options["arithmetic_convention"]with two modes and a non-breaking transition:"legacy"(default) — reproduces current master behavior exactly. EmitsLinopyDeprecationWarningon every legacy codepath."v1"— strict coordinate matching, explicit NaN handling. Silent bugs become loud errors.Strict coordinate matching
Arithmetic operators (
+,-,*,/) require matching coordinates on shared dimensions. Mismatched coordinates raiseValueErrorwith suggestions:Named methods with explicit join —
.add(),.sub(),.mul(),.div(),.le(),.ge(),.eq()acceptjoin=parameter:Named methods with fill_value —
.add(),.sub(),.mul(),.div()acceptfill_value=for explicit NaN filling before the operation (see PR #620):Free broadcasting — Constants can introduce new dimensions without restriction.
Algebraic laws
All standard algebraic laws hold under v1 and under legacy for same-coordinate operands:
a + b == b + a,a * c == c * a(a + b) + c == a + (b + c)c * (a + b) == c*a + c*b,(a + b) / c == a/c + b/cLegacy breaks associativity only when operands have mismatched coordinate ranges (the subset-constant case above). The v1 convention prevents this by requiring explicit coordinate handling.
v1 NaN convention
NaN means "absent term" — never a numeric value.
NaN enters only from
mask=at construction or structural operations (.shift(),.where(),.reindex(),.reindex_like(),.unstack()). Operations like.roll(),.sel(),.isel()do not produce NaN.Arithmetic with absent slots:
shifted + 5+5shifted - 5-5shifted * 3shifted / 2When merging expressions (e.g.,
x + y.shift(time=1)), NaN marks individual terms, not entire coordinates. A coordinate is only fully absent when all terms are absent —isnull()checks this.User-supplied NaN raises
ValueError— users must handle NaN explicitly before arithmetic:fillna()—Variable.fillna(numeric)returnsLinearExpression,Variable.fillna(Variable)returnsVariable,Expression.fillna(value)fills const at absent slots.Source changes
config.pyLinopyDeprecationWarning,arithmetic_conventionsettingexpressions.pymerge()pre-validates user-dim coords under v1;merge()preserves NaN const when all inputs NaN;_add_constantfills const with additive identity;to_constrainthas separate legacy/v1 paths; NaN validation at API boundaries;fill_value=on.add()/.sub()/.mul()/.div()common.pyalign()reads convention (legacy→inner, v1→exact)variables.py__mul__, explicitTypeErrorin__div__,.reindex()methods,Variable.fillna(numeric)returnsLinearExpression,to_linexpr()setsconst=NaNat absent slots in v1piecewise.py.fillna(0)on breakpoint data for v1 compatibilitymonkey_patch_xarray.pymodel.pyDocumentation
arithmetic-convention.ipynbmissing-data.ipynb_nan-edge-cases.ipynbTest structure
@pytest.mark.v1_onlyand@pytest.mark.legacy_onlyfor convention-specific testsconftest.pywith auto-convention switchingtest_algebraic_properties.py(92 tests): formal specification of algebraic laws — commutativity, associativity, distributivity, identity, negation, zero, division/subtraction distributivity, multi-step constant folding, mixed-type commutativity, expression-expression laws. All pass under both conventions except 8 NaN-propagation tests (v1 only).test_legacy_violations.py(23 tests): catalog of concrete legacy bugs with paired legacy/v1 tests — positional alignment, subset associativity, user NaN handling, variable/expression inconsistency, absent-slot propagation. Each test traces to a specific issue number.test_linear_expression.py,test_constraints.py,test_convention.pyRollout plan
"legacy"— nothing breakslinopy.options["arithmetic_convention"] = "v1""v1"and drop legacy modeOpen questions
from_tuples/linexpr()— Currently follows the global convention. In practice always called with same-coord variables, so convention doesn't matter. Low-priority.Sub-PRs
Test plan
🤖 Generated with Claude Code