Skip to content
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +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 <https://github.com/PyPSA/linopy/issues/926>`__)
* A ``CSRConstraint`` from a CSR-backed expression keeps its auxiliary coordinates through ``to_dense()``, ``freeze()`` and netcdf. (`#941 <https://github.com/PyPSA/linopy/issues/941>`__)
* Unsupported ``CSRConstraint`` operations (``loc``, ``update``, ``from_rule``, setting ``coeffs``/``vars``/``sign``) raise an ``AttributeError`` that points to ``.mutable()``. (`#963 <https://github.com/PyPSA/linopy/issues/963>`__)
* Freezing a constraint with a ``MultiIndex`` dimension no longer raises ``TypeError: Must pass list-like as `names```. (`#1022 <https://github.com/PyPSA/linopy/pull/1022>`__)
* Repeated matrix builds on an unchanged model reuse the cached column mapping again, so persistent snapshot diffs skip untouched frozen constraints. (`#933 <https://github.com/PyPSA/linopy/issues/933>`__)

*Expressions*
Expand Down
15 changes: 12 additions & 3 deletions linopy/csr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 = {}
Expand Down
18 changes: 18 additions & 0 deletions test/test_csr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down
24 changes: 17 additions & 7 deletions test/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -399,6 +393,22 @@ 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:
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
Expand Down
Loading