From 492d2ecc98e38b334b890c5711917af4d71562da Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Wed, 7 Oct 2026 15:56:48 +0200 Subject: [PATCH 1/4] fix: freeze constraints with a MultiIndex dimension Grid.from_dataset named each index after its dimension with Index.rename, which on a MultiIndex renames the levels and raises "Must pass list-like as `names`". Set the name of a MultiIndex instead and keep its level names. Co-Authored-By: Claude Opus 5.5 --- doc/release_notes.rst | 1 + linopy/csr.py | 11 ++++++++++- test/test_io.py | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 4c8bb82f..d866d86d 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -94,6 +94,7 @@ Upcoming Version * ``sum()`` over a dimension no longer raises when another dimension of the expression has size 0; it returns an expression without terms over the kept coordinates, as summing over the empty dimension itself already did. (https://github.com/PyPSA/linopy/issues/906) * A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``. * The ``linopy.options`` context manager now restores the values that were active on entry instead of resetting all options to their defaults. +* Freezing a constraint (``freeze=True``) with a ``MultiIndex`` dimension no longer raises ``TypeError: Must pass list-like as `names```. Building the CSR grid renamed the index to its dimension, which renames the levels of a ``MultiIndex``. * ``Model.copy`` (and the ``copy.copy``/``copy.deepcopy`` protocols) no longer downgrades a quadratic objective to a linear one. The objective was rebuilt as a ``LinearExpression`` regardless of its type, so the copy silently solved a different problem. ``linopy.testing.assert_model_equal`` now compares the objective by expression type as well, which it could not do before. (`#903 `__) * ``Model.to_netcdf``/``linopy.read_netcdf`` downgraded a quadratic objective the same way; the expression type is now stored alongside the objective and restored on read. Files written by earlier versions are read as before. (`#903 `__) * ``linopy.read_netcdf`` now restores variables, expressions and constraints in their original insertion order instead of alphabetically, so ``matrices.A`` of a round-tripped model is no longer a row/column permutation of the original. The order is stored in the file; files written by earlier versions still load in sorted order. ``linopy.testing.assert_model_equal`` now also compares container order. (`#934 `__) diff --git a/linopy/csr.py b/linopy/csr.py index 7c27463a..db4351e0 100644 --- a/linopy/csr.py +++ b/linopy/csr.py @@ -92,7 +92,7 @@ def from_coords(cls, coords: Iterable[pd.Index]) -> Grid: def from_dataset(cls, ds: Dataset | DataArray, dims: Iterable[str]) -> Grid: """Build from the indexes and auxiliary coordinates ``ds`` carries on ``dims``.""" dims = tuple(dims) - indexes = {d: ds.get_index(d).rename(d) for d in dims} + indexes = {d: _named(ds.get_index(d), d) for d in dims} return cls(indexes, _aux_coords(ds, set(dims))) @classmethod @@ -837,6 +837,15 @@ def _readonly(values: np.ndarray) -> np.ndarray: return values +def _named(index: pd.Index, dim: str) -> pd.Index: + """``index`` named after ``dim``, keeping the level names of a MultiIndex.""" + if isinstance(index, pd.MultiIndex): + index = index.copy() + index.name = dim + return index + return index.rename(dim) + + def _aux_coords(ds: Dataset | DataArray, dims: set[str]) -> AuxCoords: """Scalar and one-dimensional auxiliary coordinates of ``ds`` lying on ``dims``.""" aux: AuxCoords = {} diff --git a/test/test_io.py b/test/test_io.py index dabee6f5..b3566434 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -399,6 +399,24 @@ def test_model_to_netcdf_with_multiindex( assert_model_equal(m, p) +def test_model_to_netcdf_with_multiindex_frozen_constraint( + model_with_multiindex: Model, tmp_path: Path +) -> None: + from linopy.constraints import CSRConstraint + + m = model_with_multiindex + x, y = m.variables["x-var"], m.variables["y-var"] + con = m.add_constraints(x + y, LESS_EQUAL, 10, name="frozen", freeze=True) + assert isinstance(con, CSRConstraint) + + fn = tmp_path / "test.nc" + m.to_netcdf(fn) + p = read_netcdf(fn) + + assert isinstance(p.constraints["frozen"], CSRConstraint) + assert_model_equal(m, p) + + # Regression for https://github.com/PyPSA/linopy/issues/525. def test_model_to_netcdf_with_multiindex_scipy_engine( model_with_multiindex: Model, tmp_path: Path From d130c9f88794b10852477201b8db49c6db6120ca Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Wed, 7 Oct 2026 23:05:40 +0200 Subject: [PATCH 2/4] fix: name the index in from_grouper the same MultiIndex-safe way Co-Authored-By: Claude Opus 5.5 --- linopy/csr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/csr.py b/linopy/csr.py index db4351e0..3a5f3f16 100644 --- a/linopy/csr.py +++ b/linopy/csr.py @@ -373,7 +373,7 @@ def from_grouper( ) for d in coord_dims: if d != member_dim: - indexes[d] = ds.get_index(d).rename(d) + indexes[d] = _named(ds.get_index(d), d) aux |= _aux_coords(ds, set(coord_dims) - {member_dim}) grid = Grid({d: indexes[d] for d in grid_dims}, aux) if isinstance(source, Dataset): From 7dc40b1587fa76362550df4aaab529dbb72fdac9 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Mon, 12 Oct 2026 14:44:11 +0200 Subject: [PATCH 3/4] fix: type check with numpy 2.5.4 isscalar stubs numpy 2.5.4 widened the TypeIs of np.isscalar to include numbers.Number, which xarray's DataArray.__add__ does not accept, so mypy fails on the legacy scalar branch of _add_constant. Cast to the scalar members of ConstantLike; no runtime change. Co-Authored-By: Claude Opus 5.5 --- linopy/expressions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/linopy/expressions.py b/linopy/expressions.py index 197482ad..e0414483 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -1275,7 +1275,9 @@ def _add_constant_legacy( if is_nan_scalar(other): check_user_nan() warn_legacy_absence(self.const) - return self.assign(const=self.const.fillna(0) + other) + return self.assign( + const=self.const.fillna(0) + cast("float | np.number", other) + ) self_const, da, needs_reindex = self._broadcast_and_align( other, fill_value, join ) From 8346bb3a3686d37f296bd1fabf13f3d5e45a2fc6 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 16 Oct 2026 11:39:19 +0200 Subject: [PATCH 4/4] fix: keep MultiIndex level names in Grid.renamed, drop cast hunk, tidy imports and release note --- doc/release_notes.rst | 2 +- linopy/csr.py | 2 +- linopy/expressions.py | 4 +--- test/test_csr.py | 18 ++++++++++++++++++ test/test_io.py | 10 +--------- 5 files changed, 22 insertions(+), 14 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 787b9901..ce339899 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -112,7 +112,7 @@ Files from earlier versions load as before. * Adding or removing variables after ``freeze=True`` no longer breaks ``model.matrices`` with ``inconsistent shapes``. (`#926 `__) * A ``CSRConstraint`` from a CSR-backed expression keeps its auxiliary coordinates through ``to_dense()``, ``freeze()`` and netcdf. (`#941 `__) * Unsupported ``CSRConstraint`` operations (``loc``, ``update``, ``from_rule``, setting ``coeffs``/``vars``/``sign``) raise an ``AttributeError`` that points to ``.mutable()``. (`#963 `__) -* Freezing a constraint with a ``MultiIndex`` dimension no longer raises ``TypeError: Must pass list-like as `names```. +* Freezing a constraint with a ``MultiIndex`` dimension no longer raises ``TypeError: Must pass list-like as `names```. (`#1022 `__) * Repeated matrix builds on an unchanged model reuse the cached column mapping again, so persistent snapshot diffs skip untouched frozen constraints. (`#933 `__) *Expressions* diff --git a/linopy/csr.py b/linopy/csr.py index 3a5f3f16..9ad88912 100644 --- a/linopy/csr.py +++ b/linopy/csr.py @@ -172,7 +172,7 @@ def indexer(self, other: Grid) -> tuple[np.ndarray, np.ndarray]: def renamed(self, names: Mapping[str, str]) -> Grid: """Relabel dimensions; the cell layout is unchanged.""" indexes = { - names.get(d, d): i.rename(names.get(d, d)) for d, i in self.indexes.items() + names.get(d, d): _named(i, names.get(d, d)) for d, i in self.indexes.items() } aux = { n: (names.get(d, d) if isinstance(d, str) else d, v) diff --git a/linopy/expressions.py b/linopy/expressions.py index e0414483..197482ad 100644 --- a/linopy/expressions.py +++ b/linopy/expressions.py @@ -1275,9 +1275,7 @@ def _add_constant_legacy( if is_nan_scalar(other): check_user_nan() warn_legacy_absence(self.const) - return self.assign( - const=self.const.fillna(0) + cast("float | np.number", other) - ) + return self.assign(const=self.const.fillna(0) + other) self_const, da, needs_reindex = self._broadcast_and_align( other, fill_value, join ) diff --git a/test/test_csr.py b/test/test_csr.py index d51bba86..bd4164e9 100644 --- a/test/test_csr.py +++ b/test/test_csr.py @@ -957,6 +957,24 @@ def test_rename_stays_csr_and_matches_dense() -> None: assert_linequal(sparse, dense) +def test_rename_multiindex_dim_stays_csr_and_matches_dense() -> None: + require_v1() + + def build(sparse: bool) -> LinearExpression: + m = Model(sparse=sparse) + gen = pd.RangeIndex(4, name="gen") + coords = [gen, pd.Index([1, 2], name="a"), pd.Index(["u", "v"], name="b")] + x = m.add_variables(coords=coords, name="x") + bus = pd.Series([0, 0, 1, 1], index=gen, name="bus") + expr = (1.0 * x).stack(multi=["a", "b"]).groupby(bus).sum() + return expr.rename(multi="ab") + + sparse = build(sparse=True) + assert sparse._csr is not None + assert sparse.indexes["ab"].names == ["a", "b"] + assert_linequal(sparse, build(sparse=False)) + + @pytest.mark.parametrize( "op", [lambda e: e.reindex(group=[3, 0, 9]), lambda e: e.rename(group="g")], diff --git a/test/test_io.py b/test/test_io.py index b3566434..8eb75f93 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -20,7 +20,7 @@ from linopy import LESS_EQUAL, Model, available_solvers, read_netcdf from linopy.constants import FACTOR_DIM -from linopy.constraints import Constraint +from linopy.constraints import Constraint, CSRConstraint from linopy.expressions import LinearExpression, QuadraticExpression from linopy.io import CONTAINER_ORDER_ATTR, signed_number from linopy.testing import assert_exprequal, assert_model_equal @@ -181,8 +181,6 @@ def test_read_netcdf_inconsistent_order_attr_raises( def test_model_to_netcdf_frozen_constraint(tmp_path: Path) -> None: - from linopy.constraints import CSRConstraint - m = Model() x = m.add_variables(coords=[pd.RangeIndex(5, name="i")], name="x") m.add_constraints(x >= 1, name="c", freeze=True) @@ -283,8 +281,6 @@ def test_model_to_netcdf_compression_scipy( def test_model_from_netcdf_frozen_constraint_legacy_positions(tmp_path: Path) -> None: """Files written before #926 stored dense positions as CSR columns.""" - from linopy.constraints import CSRConstraint - m = Model() i = pd.RangeIndex(3, name="i") mask = xr.DataArray([True, False, False], dims=["i"]) @@ -315,8 +311,6 @@ def test_model_from_netcdf_frozen_constraint_legacy_positions(tmp_path: Path) -> def test_model_to_netcdf_mixed_sign_constraint(tmp_path: Path) -> None: - from linopy.constraints import CSRConstraint - m = Model() x = m.add_variables(coords=[pd.RangeIndex(4, name="i")], name="x") @@ -402,8 +396,6 @@ def test_model_to_netcdf_with_multiindex( def test_model_to_netcdf_with_multiindex_frozen_constraint( model_with_multiindex: Model, tmp_path: Path ) -> None: - from linopy.constraints import CSRConstraint - m = model_with_multiindex x, y = m.variables["x-var"], m.variables["y-var"] con = m.add_constraints(x + y, LESS_EQUAL, 10, name="frozen", freeze=True)