Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions arithmetics-design/legacy-removal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`

Expand Down
7 changes: 1 addition & 6 deletions examples/piecewise-inequality-bounds.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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\""
]
},
{
Expand Down
5 changes: 0 additions & 5 deletions examples/piecewise-linear-constraints.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 0 additions & 8 deletions linopy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
9 changes: 5 additions & 4 deletions linopy/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
conform_merge_dims,
enforce_aux_conflict,
first_mismatched_dim,
is_nan_scalar,
is_v1,
warn_legacy,
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion linopy/semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions test/test_legacy_violations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down