Skip to content

refac: Harmonize linopy operations and introduce a new predictable and strict convention - #591

Closed
FBumann wants to merge 83 commits into
masterfrom
harmonize-linopy-operations-mixed
Closed

refac: Harmonize linopy operations and introduce a new predictable and strict convention#591
FBumann wants to merge 83 commits into
masterfrom
harmonize-linopy-operations-mixed

Conversation

@FBumann

@FBumann FBumann commented Feb 20, 2026

Copy link
Copy Markdown
Collaborator

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:

x = m.add_variables(coords=[["costs", "penalty"]], name="x")
factors = xr.DataArray([2.0, 1.0], dims=["effect"],
                       coords={"effect": ["penalty", "costs"]})
x * factors
# Result: x["costs"] * 2.0, x["penalty"] * 1.0  — SWAPPED!
# Expected: x["costs"] * 1.0, x["penalty"] * 2.0

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 has 3 time steps, b has 5, factor has 5
a + factor + b    # factor at time=3,4 → 40, 50  ✓
a + b + factor    # factor at time=3,4 → LOST     ✗

(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:

data = xr.DataArray([1.0, np.nan, 3.0], ...)
x + data    # NaN → 0 (silent)
x * data    # NaN → 0 (kills the variable!)
x / data    # NaN → 1 (leaves variable unchanged!)

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 and fillna() cannot distinguish "absent" from "zero":

xs = x.shift(time=1)   # time=0 is absent
xs * 3                  # absent slot looks like 0 — not NaN
xs.fillna(42)           # no-op — nothing to fill

5. Inconsistent Variable vs Expression paths (#569, #571)

x * subset_constant and (1 * x) * subset_constant previously 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. Emits LinopyDeprecationWarning on every legacy codepath.
  • "v1" — strict coordinate matching, explicit NaN handling. Silent bugs become loud errors.
linopy.options["arithmetic_convention"] = "v1"

Strict coordinate matching

Arithmetic operators (+, -, *, /) require matching coordinates on shared dimensions. Mismatched coordinates raise ValueError with suggestions:

x[i=0,1,2] + y[i=1,2,3]  # ValueError: use .add(y, join="inner")

Named methods with explicit join — .add(), .sub(), .mul(), .div(), .le(), .ge(), .eq() accept join= parameter:

x.add(y, join="inner")      # intersection
x.add(y, join="outer")      # union with fill
x.add(y, join="left")       # keep x's coordinates
x.add(y, join="override")   # positional alignment (opt-in only)

Named methods with fill_value — .add(), .sub(), .mul(), .div() accept fill_value= for explicit NaN filling before the operation (see PR #620):

expr.add(5, fill_value=0)      # fill NaN const with 0 before adding
expr.mul(factor, fill_value=1) # fill NaN const with 1 before multiplying

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:

  • Commutativity: a + b == b + a, a * c == c * a
  • Associativity: (a + b) + c == a + (b + c)
  • Distributivity: c * (a + b) == c*a + c*b, (a + b) / c == a/c + b/c
  • Identity, negation, zero

Legacy 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:

Operation Absent slot Why
shifted + 5 +5 Fills const with 0 (additive identity), required for associativity
shifted - 5 -5 Same — subtraction is addition of negation
shifted * 3 absent NaN propagates — no correct implicit fill (0 kills, 1 preserves)
shifted / 2 absent Same as multiplication

When 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:

x + data.fillna(0)           # NaN = "no offset"
x * factor.fillna(1)         # NaN = "no scaling"
expr.mul(3, fill_value=0)    # fill_value= shorthand on named methods

fillna()Variable.fillna(numeric) returns LinearExpression, Variable.fillna(Variable) returns Variable, Expression.fillna(value) fills const at absent slots.


Source changes

File Change
config.py LinopyDeprecationWarning, arithmetic_convention setting
expressions.py All arithmetic paths branch on convention; merge() pre-validates user-dim coords under v1; merge() preserves NaN const when all inputs NaN; _add_constant fills const with additive identity; to_constraint has separate legacy/v1 paths; NaN validation at API boundaries; fill_value= on .add()/.sub()/.mul()/.div()
common.py align() reads convention (legacy→inner, v1→exact)
variables.py Scalar fast path in __mul__, explicit TypeError in __div__, .reindex() methods, Variable.fillna(numeric) returns LinearExpression, to_linexpr() sets const=NaN at absent slots in v1
piecewise.py .fillna(0) on breakpoint data for v1 compatibility
monkey_patch_xarray.py DataArray/Dataset arithmetic with linopy types
model.py Convention-aware model methods

Documentation

Notebook Content
arithmetic-convention.ipynb Coordinate alignment rules, join parameter, migration guide
missing-data.ipynb NaN convention principles, fillna patterns, masking with .sel() and mask=, legacy comparison
_nan-edge-cases.ipynb Dev notebook: shift, roll, where, reindex, isnull, absent slot arithmetic, fillna/fill_value API, FILL_VALUE internals

Test structure

  • Marker-based separation: @pytest.mark.v1_only and @pytest.mark.legacy_only for convention-specific tests
  • Shared fixtures in conftest.py with auto-convention switching
  • test_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.
  • Additional convention-specific tests in test_linear_expression.py, test_constraints.py, test_convention.py

Rollout plan

  1. This PR: Default "legacy" — nothing breaks
  2. Downstream: Users opt in with linopy.options["arithmetic_convention"] = "v1"
  3. linopy v1: Flip default to "v1" and drop legacy mode

Open questions

  • from_tuples / linexpr() — Currently follows the global convention. In practice always called with same-coord variables, so convention doesn't matter. Low-priority.
  • Pipe operator — Only linopy objects, or also constants? (follow-up PR)

Sub-PRs

Test plan

  • All tests pass under both conventions
  • Legacy tests validate backward compatibility
  • v1 tests validate strict coordinate matching and NaN raises
  • Algebraic properties verified (92 tests): all laws hold under both conventions
  • Legacy violations documented (23 tests): concrete bugs with issue references
  • Piecewise and SOS tests run under both conventions
  • Documentation notebooks execute cleanly

🤖 Generated with Claude Code

FabianHofmann and others added 19 commits February 9, 2026 14:28
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>
@FBumann

FBumann commented Feb 20, 2026

Copy link
Copy Markdown
Collaborator Author

@FabianHofmann Im quite happy with the notebook now. It showcases the convention and its consequences.
Tests need some work though. And migration as well.
Looking forward to your opinion on the convention

FBumann and others added 2 commits February 20, 2026 13:51
…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
@FBumann

FBumann commented Feb 27, 2026

Copy link
Copy Markdown
Collaborator Author

The convention should be "exact" for all of +, -, *, /, with an additional check that neither side may introduce dimensions the other doesn't have — also for all operations.

Why "exact" instead of "inner" for * and /

"exact" still broadcasts freely over dimensions that only exist on one side — it only enforces strict matching on shared dimensions. So the common scaling pattern works fine:

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

"inner" is dangerous: if coords on a shared dimension don't match due to a typo or upstream change, it silently drops values. The explicit and safe way to subset before multiplying is:

capacity.sel(tech=["wind", "solar"]) * renewable_cost

No 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 + and - as to * and / — new dimensions silently expand the optimization problem in unintended ways:

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

Operation Convention
+, -, *, / "exact" on shared dims; neither side may introduce dims the other doesn't have

@coroa

coroa commented Feb 27, 2026

Copy link
Copy Markdown
Member

The convention should be "exact" for all of +, -, *, /, with an additional check that neither side may introduce dimensions the other doesn't have — also for all operations.

Let's clearly differentiate between dimensions and labels.

labels

I 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
strange at the beginning, but they grew on me:

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 outer inner join.
x.drop_extras() + y does the same, though.

I have in a different project used | 0 to indicate keep_extras ie (x + y | 0).

dimensions

i 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.

@FBumann

FBumann commented Feb 28, 2026

Copy link
Copy Markdown
Collaborator Author

Dimensions and broadcasting

I agree that auto broadcasting is helpful in some cases.
I'm happy with allowing broadcasting of constants. We could allow this always...?
But I would enforce that the constant never has more dims than the variable/expression.
Or is there a use case for this?

So the full convention requires two separate things:
1. "exact" join — shared dims must have matching coords (xarray handles this)
2. Subset dim check — the constant side’s dims must be a subset of the variable/expression (custom pre-check needed)

labels

I'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.
I would rather enforce to reindex or fill data to the correct index.
I think aligning is the correct approach:

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_aligned

Combining disjoint expressions would then still need the explicit methods though.
I'm interested about your take on this

@FBumann

FBumann commented Feb 28, 2026

Copy link
Copy Markdown
Collaborator Author

The proposed convention for all arithmetic operations in linopy:
1. "exact" join by default — shared coords must match exactly, raises on mismatch
2. Subset dim check — constants may introduce dimensions the variable/expression doesn’t have
3. No implicit inner join — use .sel() explicitly instead
4. Outer join with fill — use x + (y | 0) or .add(join="outer", fill_value=0)
The escape hatches in order of preference: .sel() for subsetting, | 0 for inline fill, named method .add(join=...) for everything else. No context manager needed.​​​​​​​​​​​​​​​​

I'm not sure how to implement the | operator yet. Might need some sort of flag/state for defered indexing

@FBumann

FBumann commented Feb 28, 2026

Copy link
Copy Markdown
Collaborator Author

I thought about the pipe operator:
I think it should only work with linopy internal types (Variables/expression), not constants (scalar, numpy, pandas, dataarray), as this would need monkey patching a lot and hard to get stable.

Would this be an issue for you?

@FBumann FBumann mentioned this pull request Mar 18, 2026
4 tasks
@FBumann

FBumann commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator Author

@brynpickering I worked on it a bit and treating nan as absent terms works quite well.
Regarding your examples, it removes the need for fillna(), except for the efficiency, where not calling fillna(1) silently masks out the constraint, which is intended, but a dangerous caveat.
Further, this would be a silent change in behaviour which would not raise.
See #627 for details.

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

@brynpickering

Copy link
Copy Markdown
Contributor

@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.

@FBumann

FBumann commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator Author

@brynpickering Thanks for the back and forth.
This really helps

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>
@MaykThewessen

Copy link
Copy Markdown
Contributor

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:

  1. NaN-mostly per-snapshot equality pins. We pin BESS terminal SoC via n.storage_units_t.state_of_charge_set as a DataFrame(NaN, columns=batteries) with exactly one non-NaN entry per chunk. PyPSA forwards this to linopy as a constraint RHS. Under the legacy "NaN silently swallowed → 0" rule (case 3 in the PR), the wrong semantics would force SoC=0 every hour. Today this works because PyPSA pre-filters NaN before constraint creation; under v1 we'd want NaN-as-absent to be a first-class signal so the pre-filter could be dropped. A regression test: build m.add_constraints(x == rhs) with rhs a DataArray that is NaN-everywhere-except-one-snapshot, confirm only one constraint row is materialized.

  2. Per-snapshot time-varying marginal_cost_t with sparse non-NaN coverage. We override foreign offshore wind mc_t to equal landing-bus LMP only on snapshots where the gen would otherwise dispatch (NaN elsewhere → static base cost). Same NaN-handling concern as (1), but on the objective side rather than the constraint side. Currently works; v1 needs to keep working.

  3. Label-indexed pandas Series multiplied by per-country/per-carrier DataFrames. Our scarcity-rent and neighbor-markup-propagation modules construct factors like pd.Series({'NL': 0.15, 'BE': 0.12, ...}) and multiply against per-snapshot generation expressions. This is exactly case 1 (positional alignment ignores labels) territory. Mixing pandas (label-aligned) and xarray (position-aligned) operands is where we feel least safe. Strict label alignment in v1 would be a clear win — we'd happily accept a ValueError on mismatch over silent swap.

  4. phase_shift_extendable PST custom constraint (PyPSA fork PR #1661, currently in our pin). Writes Transformer phase-shift variables and flow constraints with mixed label/positional coords. Would benefit from a clear v0→v1 migration story — we'd rather port once, with strict errors guiding us, than chase silent dispatch shifts later.

  5. Sweep-orchestrator multi-process reproducibility. Adjacent to fix: keep coords dimension order for DataArray bounds (#706) #710's _broadcast_points set-iteration fix: we run many variants in parallel processes. Any hash-randomized dim ordering produces apparent run-to-run drift that masks real model deltas. Strict v1 ordering is a correctness win even beyond the silent-bugs angle.

Migration concern: the breakage surface is large for downstream PyPSA-Eur derivatives. A deprecation cycle that emits a LinopyAlignmentWarning on any operation whose v0 vs v1 result differs — rather than blanket on all alignment — would let us find and port only the affected call sites. Otherwise the warning noise floor will be too high to act on.

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.

@FBumann

FBumann commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

@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?

@MaykThewessen

MaykThewessen commented May 21, 2026

Copy link
Copy Markdown
Contributor

@FBumann yes, happy to. NaN semantics are the part that touches our code most
directly — constraint-RHS side (SoC pin via state_of_charge_set) and objective
side (marginal_cost_t and p_max_pu with sparse non-NaN coverage). Async on a
dedicated issue is probably most efficient; sync call also fine if it helps
unblock design.

Concrete starting point: we ran a controlled test of #627's draft against our
SoC-pin pattern this morning.

  • Under arithmetic_convention="legacy" (default), behaviour is identical to
    current master — SoC pin holds (SoC[-1]=100.0 in a 24h LP).
  • Under arithmetic_convention="v1", the same pattern raises
    ValueError: Constraint RHS contains NaN values at expressions.py:1325,
    before PyPSA's existing NaN-RHS auto-mask runs. Loud, not silent — which is
    great. But it means every PyPSA-Eur downstream that relies on the auto-mask
    (storage with state_of_charge_set, sparse marginal_cost_t, gapped
    p_max_pu) will fail at constraint-build time the moment they flip the
    option.

The design question is where the pre-mask should live. Two options:

  1. Keep linopy strict, push mask to consumers. Every downstream
    pre-filters NaN before add_constraints. One rule, but high migration cost
    and duplicated masking logic across PyPSA, PyPSA-Eur, atlite-derived
    builders, custom forks.
  2. add_constraints accepts NaN-as-absent natively, per-call. E.g.
    add_constraints(lhs == rhs, on_nan="raise" | "skip_row" | "propagate")
    with raise as the v1 default. One implementation, fused with the existing
    xarray alignment, no global mode, explicit at each call site.

I lean toward (2) because the NaN in question is created by linopy's own
xr.align(join="outer"), not by user intent — the consumer can't distinguish
"user meant skip" from "alignment fill", only linopy sees the align op. So
handling it in the alignment layer feels structurally correct. But I see the
argument for keeping linopy policy-free.

Happy to open a discussion issue with the repro + sketch of both options.
File on linopy, or prefer to scope on your side first?

@FBumann

FBumann commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

@FBumann yes, happy to. NaN semantics are the part that touches our code most directly — constraint-RHS side (SoC pin via state_of_charge_set) and objective side (marginal_cost_t and p_max_pu with sparse non-NaN coverage). Async on a dedicated issue is probably most efficient; sync call also fine if it helps unblock design.

Concrete starting point: we ran a controlled test of #627's draft against our SoC-pin pattern this morning.

  • Under arithmetic_convention="legacy" (default), behaviour is identical to
    current master — SoC pin holds (SoC[-1]=100.0 in a 24h LP).
  • Under arithmetic_convention="v1", the same pattern raises
    ValueError: Constraint RHS contains NaN values at expressions.py:1325,
    before PyPSA's existing NaN-RHS auto-mask runs. Loud, not silent — which is
    great. But it means every PyPSA-Eur downstream that relies on the auto-mask
    (storage with state_of_charge_set, sparse marginal_cost_t, gapped
    p_max_pu) will fail at constraint-build time the moment they flip the
    option.

The design question is where the pre-mask should live. Two options:

  1. Keep linopy strict, push mask to consumers. Every downstream
    pre-filters NaN before add_constraints. One rule, but high migration cost
    and duplicated masking logic across PyPSA, PyPSA-Eur, atlite-derived
    builders, custom forks.
  2. add_constraints accepts NaN-as-absent natively, per-call. E.g.
    add_constraints(lhs == rhs, on_nan="raise" | "skip_row" | "propagate")
    with raise as the v1 default. One implementation, fused with the existing
    xarray alignment, no global mode, explicit at each call site.

I lean toward (2) because the NaN in question is created by linopy's own xr.align(join="outer"), not by user intent — the consumer can't distinguish "user meant skip" from "alignment fill", only linopy sees the align op. So handling it in the alignment layer feels structurally correct. But I see the argument for keeping linopy policy-free.

Happy to open a discussion issue with the repro + sketch of both options. File on linopy, or prefer to scope on your side first?

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.

@FBumann

FBumann commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Superseeded by #717

@FBumann FBumann closed this Jun 4, 2026
FBumann added a commit to FBumann/lpspec that referenced this pull request Jul 28, 2026
…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
FBumann added a commit to FBumann/lpspec that referenced this pull request Jul 28, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants