Skip to content

Commit 0b4605a

Browse files
FBumannclaude
andauthored
Add legacy/v1 arithmetic convention with deprecation transition (#607)
* Add legacy arithmetic join mode with deprecation warning for transition - Add `options["arithmetic_join"]` setting (default: "legacy") to control coordinate alignment in arithmetic operations, merge, and constraints - Legacy mode reproduces old behavior: override when shapes match, outer otherwise for merge; reindex_like for constants; inner for align() - All legacy codepaths emit FutureWarning guiding users to opt in to "exact" - Move shared test fixtures (m, x, y, z, v, u) to conftest.py - Exact-behavior tests use autouse fixture to set arithmetic_join="exact" - Legacy test files (test_*_legacy.py) validate old behavior is preserved - All 2736 tests pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify global setting to 'legacy'/'v1', add LinopyDeprecationWarning - Restrict options["arithmetic_join"] to {"legacy", "v1"} instead of exposing all xarray join values (explicit join= parameter still accepts any) - "v1" maps to "exact" join internally - Add LinopyDeprecationWarning class (subclass of FutureWarning) with centralized message including how to silence - Export LinopyDeprecationWarning from linopy.__init__ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rename arithmetic_join to arithmetic_convention, mention v1 removal - Rename setting from 'arithmetic_join' to 'arithmetic_convention' - Update deprecation message: "will be removed in linopy v1" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rename test fixtures from exact_join to v1_convention Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update legacy tests * Merge harmonize-linopy-operations-mixed, restore NaN filling and align function - Resolve merge conflicts keeping transition layer logic - Restore NaN fillna(0) in _add_constant and _apply_constant_op - Restore simple finisher-based align() function (fixes MultiIndex) - Use check_common_keys_values in merge legacy path - Update legacy test files to match origin/harmonize-linopy-operations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Clean up obsolete code and fix convention-awareness in arithmetic - Remove dead check_common_keys_values function from common.py - Remove redundant default_join parameter from _align_constant, use options["arithmetic_convention"] directly - Gate fillna(0) calls in _add_constant and _apply_constant_op behind legacy convention check so NaN values propagate correctly under v1 - Fix legacy to_constraint path to compute constraint RHS directly instead of routing through sub() which re-applies fillna - Restore Variable.__mul__ scalar fast path via to_linexpr(other) - Restore Variable.__div__ explicit TypeError for non-linear division - Update v1 tests to expect ValueError on mismatched coords and test explicit join= escape hatches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix mypy errors and pytest importmode for CI - Add return type annotations (Generator) to all v1_convention fixtures - Add importmode = "importlib" to pytest config to fix import mismatch when linopy is installed from wheel and source dir is also present - Use tuple literal in loop to fix arg-type error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix CI: move import linopy to lazy in conftest.py Top-level `import linopy` in conftest.py caused pytest to import the package from site-packages before collecting doctests from the source directory, triggering import file mismatch errors on all platforms. Move the import inside fixture functions where it's actually needed. Also revert the unnecessary test.yml and importmode changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2bdb49b commit 0b4605a

16 files changed

Lines changed: 3968 additions & 297 deletions

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,3 @@ benchmark/scripts/leftovers/
5050
# direnv
5151
.envrc
5252
AGENTS.md
53-
coverage.xml

linopy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# we need to extend their __mul__ functions with a quick special case
1414
import linopy.monkey_patch_xarray # noqa: F401
1515
from linopy.common import align
16-
from linopy.config import options
16+
from linopy.config import LinopyDeprecationWarning, options
1717
from linopy.constants import EQUAL, GREATER_EQUAL, LESS_EQUAL
1818
from linopy.constraints import Constraint, Constraints
1919
from linopy.expressions import LinearExpression, QuadraticExpression, merge
@@ -34,6 +34,7 @@
3434
"EQUAL",
3535
"GREATER_EQUAL",
3636
"LESS_EQUAL",
37+
"LinopyDeprecationWarning",
3738
"LinearExpression",
3839
"Model",
3940
"Objective",

linopy/common.py

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import operator
1111
import os
1212
from collections.abc import Callable, Generator, Hashable, Iterable, Sequence
13-
from functools import reduce, wraps
13+
from functools import partial, reduce, wraps
1414
from pathlib import Path
1515
from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload
1616
from warnings import warn
@@ -1205,7 +1205,7 @@ def check_common_keys_values(list_of_dicts: list[dict[str, Any]]) -> bool:
12051205

12061206
def align(
12071207
*objects: LinearExpression | QuadraticExpression | Variable | T_Alignable,
1208-
join: JoinOptions = "exact",
1208+
join: JoinOptions | None = None,
12091209
copy: bool = True,
12101210
indexes: Any = None,
12111211
exclude: str | Iterable[Hashable] = frozenset(),
@@ -1265,41 +1265,56 @@ def align(
12651265
12661266
12671267
"""
1268+
from linopy.config import options
12681269
from linopy.expressions import LinearExpression, QuadraticExpression
12691270
from linopy.variables import Variable
12701271

1271-
# Extract underlying Datasets for index computation.
1272+
if join is None:
1273+
join = options["arithmetic_convention"]
1274+
1275+
if join == "legacy":
1276+
from linopy.config import LEGACY_DEPRECATION_MESSAGE, LinopyDeprecationWarning
1277+
1278+
warn(
1279+
LEGACY_DEPRECATION_MESSAGE,
1280+
LinopyDeprecationWarning,
1281+
stacklevel=2,
1282+
)
1283+
join = "inner"
1284+
1285+
elif join == "v1":
1286+
join = "exact"
1287+
1288+
finisher: list[partial[Any] | Callable[[Any], Any]] = []
12721289
das: list[Any] = []
12731290
for obj in objects:
1274-
if isinstance(obj, LinearExpression | QuadraticExpression | Variable):
1291+
if isinstance(obj, LinearExpression | QuadraticExpression):
1292+
finisher.append(partial(obj.__class__, model=obj.model))
1293+
das.append(obj.data)
1294+
elif isinstance(obj, Variable):
1295+
finisher.append(
1296+
partial(
1297+
obj.__class__,
1298+
model=obj.model,
1299+
name=obj.data.attrs["name"],
1300+
skip_broadcast=True,
1301+
)
1302+
)
12751303
das.append(obj.data)
12761304
else:
1305+
finisher.append(lambda x: x)
12771306
das.append(obj)
12781307

12791308
exclude = frozenset(exclude).union(HELPER_DIMS)
1280-
1281-
# Compute target indexes.
1282-
target_aligned = xr_align(
1283-
*das, join=join, copy=False, indexes=indexes, exclude=exclude
1309+
aligned = xr_align(
1310+
*das,
1311+
join=join,
1312+
copy=copy,
1313+
indexes=indexes,
1314+
exclude=exclude,
1315+
fill_value=fill_value,
12841316
)
1285-
1286-
# Reindex each object to target indexes.
1287-
reindex_kwargs: dict[str, Any] = {}
1288-
if fill_value is not dtypes.NA:
1289-
reindex_kwargs["fill_value"] = fill_value
1290-
results: list[Any] = []
1291-
for obj, target in zip(objects, target_aligned):
1292-
indexers = {
1293-
dim: target.indexes[dim]
1294-
for dim in target.dims
1295-
if dim not in exclude and dim in target.indexes
1296-
}
1297-
# Variable.reindex has no fill_value — it always uses sentinels
1298-
if isinstance(obj, Variable):
1299-
results.append(obj.reindex(indexers))
1300-
else:
1301-
results.append(obj.reindex(indexers, **reindex_kwargs)) # type: ignore[union-attr]
1302-
return tuple(results)
1317+
return tuple([f(da) for f, da in zip(finisher, aligned)])
13031318

13041319

13051320
LocT = TypeVar(

linopy/config.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,46 @@
99

1010
from typing import Any
1111

12+
VALID_ARITHMETIC_JOINS = {"legacy", "v1"}
13+
14+
LEGACY_DEPRECATION_MESSAGE = (
15+
"The 'legacy' arithmetic convention is deprecated and will be removed in "
16+
"linopy v1. Set linopy.options['arithmetic_convention'] = 'v1' to opt in "
17+
"to the new behavior, or filter this warning with:\n"
18+
" import warnings; warnings.filterwarnings('ignore', category=LinopyDeprecationWarning)"
19+
)
20+
21+
22+
class LinopyDeprecationWarning(FutureWarning):
23+
"""Warning for deprecated linopy features scheduled for removal."""
24+
1225

1326
class OptionSettings:
14-
def __init__(self, **kwargs: int) -> None:
27+
def __init__(self, **kwargs: Any) -> None:
1528
self._defaults = kwargs
1629
self._current_values = kwargs.copy()
1730

18-
def __call__(self, **kwargs: int) -> None:
31+
def __call__(self, **kwargs: Any) -> None:
1932
self.set_value(**kwargs)
2033

21-
def __getitem__(self, key: str) -> int:
34+
def __getitem__(self, key: str) -> Any:
2235
return self.get_value(key)
2336

24-
def __setitem__(self, key: str, value: int) -> None:
37+
def __setitem__(self, key: str, value: Any) -> None:
2538
return self.set_value(**{key: value})
2639

27-
def set_value(self, **kwargs: int) -> None:
40+
def set_value(self, **kwargs: Any) -> None:
2841
for k, v in kwargs.items():
2942
if k not in self._defaults:
3043
raise KeyError(f"{k} is not a valid setting.")
44+
if k == "arithmetic_convention" and v not in VALID_ARITHMETIC_JOINS:
45+
raise ValueError(
46+
f"Invalid arithmetic_convention: {v!r}. "
47+
f"Must be one of {VALID_ARITHMETIC_JOINS}."
48+
)
3149
self._current_values[k] = v
3250

33-
def get_value(self, name: str) -> int:
51+
def get_value(self, name: str) -> Any:
3452
if name in self._defaults:
3553
return self._current_values[name]
3654
else:
@@ -57,4 +75,8 @@ def __repr__(self) -> str:
5775
return f"OptionSettings:\n {settings}"
5876

5977

60-
options = OptionSettings(display_max_rows=14, display_max_terms=6)
78+
options = OptionSettings(
79+
display_max_rows=14,
80+
display_max_terms=6,
81+
arithmetic_convention="legacy",
82+
)

0 commit comments

Comments
 (0)