diff --git a/arithmetics-design/legacy-removal.md b/arithmetics-design/legacy-removal.md index 5d563044f..460163fd0 100644 --- a/arithmetics-design/legacy-removal.md +++ b/arithmetics-design/legacy-removal.md @@ -11,7 +11,6 @@ source tree. ### `linopy/config.py` - Drop `LEGACY_SEMANTICS`, `V1_SEMANTICS`, `VALID_SEMANTICS` constants. -- Drop `LEGACY_SEMANTICS_MESSAGE`. - Drop the `LinopySemanticsWarning` class. - Remove the `semantics` key from `options`. The option no longer exists; callers don't need to opt in. @@ -53,8 +52,7 @@ source tree. propagation rule). - The trailing `if is_v1(): ds = absorb_absence(ds)` becomes unconditional. -- Drop the `LinopySemanticsWarning` / `LEGACY_SEMANTICS_MESSAGE` imports - from `expressions.py`. +- Drop the legacy `warn_legacy` message imports from `expressions.py`. ### `linopy/variables.py` diff --git a/examples/piecewise-inequality-bounds.ipynb b/examples/piecewise-inequality-bounds.ipynb index 0cadf1f69..117dd4a8e 100644 --- a/examples/piecewise-inequality-bounds.ipynb +++ b/examples/piecewise-inequality-bounds.ipynb @@ -36,17 +36,12 @@ "metadata": {}, "outputs": [], "source": [ - "import warnings\n", - "\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", "import linopy\n", "\n", - "linopy.options[\"semantics\"] = \"v1\"\n", - "\n", - "# Silence the evolving-API warning for cleaner tutorial output.\n", - "warnings.filterwarnings(\"ignore\", category=linopy.EvolvingAPIWarning)" + "linopy.options[\"semantics\"] = \"v1\"" ] }, { diff --git a/examples/piecewise-linear-constraints.ipynb b/examples/piecewise-linear-constraints.ipynb index c1a0e1729..396cb8fdb 100644 --- a/examples/piecewise-linear-constraints.ipynb +++ b/examples/piecewise-linear-constraints.ipynb @@ -35,8 +35,6 @@ "metadata": {}, "outputs": [], "source": [ - "import warnings\n", - "\n", "import matplotlib.pyplot as plt\n", "import pandas as pd\n", "import xarray as xr\n", @@ -45,9 +43,6 @@ "\n", "linopy.options[\"semantics\"] = \"v1\"\n", "\n", - "# Silence the evolving-API warning for cleaner tutorial output.\n", - "warnings.filterwarnings(\"ignore\", category=linopy.EvolvingAPIWarning)\n", - "\n", "time = pd.Index([1, 2, 3], name=\"time\")\n", "\n", "\n", diff --git a/linopy/config.py b/linopy/config.py index 13731e581..92cd09458 100644 --- a/linopy/config.py +++ b/linopy/config.py @@ -13,14 +13,6 @@ V1_SEMANTICS = "v1" VALID_SEMANTICS = {LEGACY_SEMANTICS, V1_SEMANTICS} -LEGACY_SEMANTICS_MESSAGE = ( - "The 'legacy' semantics are deprecated and will be removed in " - "linopy 1.0. Set linopy.options['semantics'] = 'v1' to opt in " - "to the new behaviour, or silence this warning with:\n" - " import warnings; warnings.filterwarnings(" - "'ignore', category=LinopySemanticsWarning)" -) - class LinopySemanticsWarning(FutureWarning): """ diff --git a/linopy/expressions.py b/linopy/expressions.py index cdee51a0d..7ef2d5273 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -100,6 +100,7 @@ conform_merge_dims, enforce_aux_conflict, first_mismatched_dim, + is_nan_scalar, is_v1, warn_legacy, ) @@ -936,7 +937,7 @@ def _add_constant_v1(self, other: ConstantLike, join: JoinOptions | None) -> Sel # §6: absence propagates — self.const NaN stays NaN, no fillna(0). # §5: user NaN raised in check_user_nan; never reaches the math here. if np.isscalar(other) and join is None: - if isinstance(other, float) and np.isnan(other): + if is_nan_scalar(other): check_user_nan() return self.assign(const=self.const + other) da = broadcast_to_coords( @@ -964,7 +965,7 @@ def _add_constant_legacy( # (additive identity) so missing data does not propagate through # arithmetic. ``check_user_nan`` only warns under legacy. if np.isscalar(other) and join is None: - if isinstance(other, float) and np.isnan(other): + if is_nan_scalar(other): check_user_nan() return self.assign(const=self.const.fillna(0) + other) da = broadcast_to_coords( @@ -1009,7 +1010,7 @@ def _apply_constant_op_v1( ) -> Self: # §6: NaN in coeffs/const propagates through op (NaN * x = NaN). # §5: user NaN raised before we get here. - if isinstance(other, float) and np.isnan(other): + if is_nan_scalar(other): check_user_nan(op_kind=op_kind) factor = broadcast_to_coords( other, coords=self.coords, strict=False, warn_reorder=True @@ -1042,7 +1043,7 @@ def _apply_constant_op_legacy( ) -> Self: # NaN values are silently filled with neutral elements before the op: # factor → fill_value (0 for mul, 1 for div), coeffs/const → 0. - if isinstance(other, float) and np.isnan(other): + if is_nan_scalar(other): check_user_nan(op_kind=op_kind) factor = broadcast_to_coords( other, coords=self.coords, strict=False, warn_reorder=True diff --git a/linopy/semantics.py b/linopy/semantics.py index 989a16e2c..f3e8fa64e 100644 --- a/linopy/semantics.py +++ b/linopy/semantics.py @@ -309,6 +309,19 @@ def is_v1() -> bool: return options["semantics"] == V1_SEMANTICS +def is_nan_scalar(value: Any) -> bool: + """ + True for a scalar float NaN of any dtype — Python ``float`` or any + ``np.floating`` (``np.float32`` / ``np.float16`` included). + + ``np.float64`` subclasses ``float``, but ``np.float32`` does not, so an + ``isinstance(value, float)`` guard alone lets a ``np.float32('nan')`` slip + past the §5 user-NaN check on a scalar fast path. The float-type guard is + still required to keep ``np.isnan`` from raising on non-numeric scalars. + """ + return isinstance(value, (float, np.floating)) and bool(np.isnan(value)) + + def check_user_nan(*, op_kind: str = "add") -> None: """ Enforce §5 for a user-supplied constant (scalar or array). @@ -412,6 +425,10 @@ def conform_merge_dims( either under v1, and warns on either under legacy. Helper dims (``_term``, ``_factor``) and the concat dim are excluded; bare dimension indexes are compared, so auxiliary coords stay §11's job. + + A non-unique shared index can't be resolved to a permutation (``get_indexer`` + requires uniqueness), so it is classified as a *mismatch* rather than a + reorder — harmless, since both raise under v1 and warn under legacy. """ datasets = list(datasets) if len(datasets) < 2: @@ -433,7 +450,10 @@ def conform_merge_dims( ref, idx = indexed[0][d], indexed[i][d] if ref.equals(idx): continue - positions = idx.get_indexer(ref) if len(idx) == len(ref) else None + # Non-unique index → mismatch (see docstring). + positions = ( + idx.get_indexer(ref) if len(idx) == len(ref) and idx.is_unique else None + ) if positions is not None and (positions >= 0).all(): if reorder is None: reorder = (str(d), ref.values, idx.values) diff --git a/test/test_legacy_violations.py b/test/test_legacy_violations.py index fe1af6ea4..05c0c0fca 100644 --- a/test/test_legacy_violations.py +++ b/test/test_legacy_violations.py @@ -434,6 +434,19 @@ def test_nan_scalar_raises(self, x: Variable, op: str) -> None: with pytest.raises(ValueError, match="NaN"): _OPS[op](x, float("nan")) + @pytest.mark.v1 + @pytest.mark.parametrize("op", ["add", "sub", "mul", "div"]) + @pytest.mark.parametrize("dtype", [np.float64, np.float32, np.float16]) + def test_nan_numpy_scalar_raises(self, x: Variable, op: str, dtype: type) -> None: + """ + A numpy-scalar NaN must raise regardless of dtype. ``np.float32`` / + ``np.float16`` do not subclass Python ``float``, so the scalar + fast-path §5 check (``isinstance(other, float)``) used to miss them + and silently add/multiply NaN into the expression. + """ + with pytest.raises(ValueError, match="NaN"): + _OPS[op](1 * x, dtype("nan")) + @pytest.mark.v1 def test_pypsa_1683_inf_times_zero_raises( self, x: Variable, time: pd.RangeIndex @@ -828,6 +841,35 @@ def test_var_plus_var_reordered_labels_raises(self, m: Model) -> None: with pytest.raises(ValueError, match="Coordinate mismatch"): (1 * a) + (1 * b) + @pytest.mark.v1 + def test_var_plus_var_duplicate_differing_labels_raises_cleanly( + self, m: Model + ) -> None: + """ + A shared dim with non-unique labels can't be resolved to a + permutation, so ``conform_merge_dims`` must report a §8 mismatch + (clean ``Coordinate mismatch`` ValueError) rather than letting + ``Index.get_indexer`` surface an opaque ``InvalidIndexError``. + """ + a = m.add_variables(coords=[pd.Index(["a", "a", "b"], name="d")], name="a") + b = m.add_variables(coords=[pd.Index(["a", "b", "b"], name="d")], name="b") + with pytest.raises(ValueError, match="Coordinate mismatch"): + a + b + + @pytest.mark.legacy + def test_var_plus_var_duplicate_differing_labels_legacy_positional( + self, m: Model + ) -> None: + """ + Legacy aligns duplicate-labelled shared dims positionally (as it did + before the merge check existed). The reorder-detection must not crash + it with an ``InvalidIndexError`` on the non-unique index. + """ + a = m.add_variables(coords=[pd.Index(["a", "a", "b"], name="d")], name="a") + b = m.add_variables(coords=[pd.Index(["a", "b", "b"], name="d")], name="b") + result = a + b + assert result.sizes["d"] == 3 + @pytest.mark.v1 def test_reordered_constants_raise(self, m: Model) -> None: ea = pd.Index(["costs", "penalty"], name="e")