From 57642b04007b59457511848322e86e86206feda9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:01:53 +0200 Subject: [PATCH 1/2] fix(v1): make Variable.fillna(scalar) resolve absence under both conventions (#847, #848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variable.fillna() is the documented resolution for absent slots (from shift/where/reindex/mask), but under legacy it misbehaved two ways: - #847: it warned. fillna internally calls to_linexpr(), whose legacy path emits the masked-variable LinopySemanticsWarning — even though fillna is itself the resolution that warning points to. So the documented fix couldn't be written warning-free on both conventions. - #848: it silently dropped the fill value. Legacy to_linexpr marks absent const as 0 (not NaN), so the subsequent LinearExpression.fillna had nothing to fill; fillna(5) left 0 at absent slots while v1 put 5. The v1 path already behaved correctly, so this is a pure legacy workaround (marked LEGACY: remove at 1.0): keep the clean one-liner under v1, and under legacy place the value at the -1 labels directly and skip the absence warning. Result: `var.shift(1).fillna(v)` is now a single form, identical under both conventions (same vars/const; only the immaterial phantom coeff differs), so downstream migrating to v1 no longer has to version-gate the expression. Co-Authored-By: Claude Opus 4.8 (1M context) --- linopy/variables.py | 13 +++++++++++-- test/test_legacy_violations.py | 20 +++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/linopy/variables.py b/linopy/variables.py index 8a2f0734f..4c3af18b5 100644 --- a/linopy/variables.py +++ b/linopy/variables.py @@ -325,6 +325,8 @@ def to_pandas(self) -> pd.Series: def to_linexpr( self, coefficient: ConstantLike = 1, + *, + _warn_absence: bool = True, # LEGACY: remove at 1.0 — gates the legacy absence warning ) -> expressions.LinearExpression: """ Create a linear expression from the variables. @@ -380,7 +382,7 @@ def to_linexpr( # the origin of the most common legacy↔v1 divergence (masked # variables in arithmetic) that no other warn-site catches. has_absence = bool((self.labels == -1).any()) - if has_absence: + if has_absence and _warn_absence: warn_legacy(_legacy_masked_variable_message(self.name)) coefficient = reindex_like_if_needed(coefficient, self.labels, 0) coefficient = coefficient.fillna(0) @@ -1319,7 +1321,14 @@ def fillna( Value to fill the absent slots with. """ if isinstance(fill_value, int | float | np.integer | np.floating): - return self.to_linexpr().fillna(fill_value) + if is_v1(): + return self.to_linexpr().fillna(fill_value) + # LEGACY: remove at 1.0 — legacy to_linexpr marks absent const as 0 + # (not NaN), so LinearExpression.fillna can't fill it and the fill is + # silently dropped. Place fill_value directly to match v1, and skip + # the absence warning fillna is itself the documented resolution for. + lin = self.to_linexpr(_warn_absence=False) + return lin.assign(const=lin.const.where(self.labels != -1, fill_value)) return self.where(~self.isnull(), fill_value) def ffill(self, dim: str, limit: None = None) -> Variable: diff --git a/test/test_legacy_violations.py b/test/test_legacy_violations.py index c23e249ff..fe1af6ea4 100644 --- a/test/test_legacy_violations.py +++ b/test/test_legacy_violations.py @@ -1359,13 +1359,27 @@ def test_legacy_expr_fillna_is_noop(self, xs: Variable) -> None: assert not bool(result.isnull().values.any()) @pytest.mark.legacy - def test_legacy_variable_fillna_numeric_is_noop(self, xs: Variable) -> None: - """Same no-op on the Variable path: the fill value is ignored.""" + def test_legacy_variable_fillna_numeric_fills_like_v1(self, xs: Variable) -> None: + """ + #848 — unlike the raw ``to_linexpr().fillna`` two-step above, + ``Variable.fillna`` still holds the absent (-1) labels, so it places + the fill value directly and honours it under legacy too, matching v1. + This is the single cross-convention form the migration relies on. + """ from linopy import LinearExpression result = xs.fillna(42) assert isinstance(result, LinearExpression) - assert result.const.values[0] == 0.0 + assert result.const.values[0] == 42.0 + + @pytest.mark.legacy + def test_legacy_variable_fillna_does_not_warn( + self, xs: Variable, unsilenced: None + ) -> None: + """#847 — the documented absence resolution must not itself warn.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", LinopySemanticsWarning) + xs.fillna(0) @pytest.mark.legacy def test_legacy_outer_fillna_then_add_double_counts( From 7712c87927918a84a1a23366e0cf673d1df63b4b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:26:39 +0200 Subject: [PATCH 2/2] docs(v1): note legacy no-op of LinearExpression.fillna on absent slots Document why var.to_linexpr().fillna(v) is a no-op under legacy (absence is already materialised as const=0, so there is no NaN to fill) and point at Variable.fillna as the cross-convention resolution. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- linopy/expressions.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/linopy/expressions.py b/linopy/expressions.py index c00cb6687..cdee51a0d 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -1735,6 +1735,16 @@ def fillna( ------- linopy.LinearExpression or linopy.QuadraticExpression A new object with missing values filled with the given value. + + Notes + ----- + This fills ``NaN`` entries only. Under legacy semantics an absent slot + of a variable is already materialised as ``const = 0`` by the time it + reaches an expression, so there is no ``NaN`` here to fill and the value + is a no-op (under v1 absence is carried as ``NaN`` and is filled). To + resolve a variable's absent slots to a constant on both conventions, use + :meth:`Variable.fillna` (or resolve on the variable before + ``to_linexpr``), which still holds the absence labels. """ value = _expr_unwrap(value) if isinstance(value, DataArray | np.floating | np.integer | int | float):