From 9cc45e75d7aa5144fcc6384c1a1b7a5524cd7cc8 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Thu, 8 Oct 2026 12:07:22 +0200 Subject: [PATCH 1/3] fix: keep dash-named parameters, attributes and MultiIndex dims in read_netcdf read_netcdf assigned each data variable and attribute to its container by splitting the key at its last dash. Parameters are stored as parameters-, so a parameter named my-param was assigned to parameters-my and dropped; the same happened to parameter attributes and _multiindex attributes of dimensions with a dash in their name. Keys now belong to the longest known container prefix. Co-Authored-By: Claude Opus 5.5 --- doc/release_notes.rst | 1 + linopy/io.py | 26 ++++++++++++++++++++++---- test/test_io.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 049ea850..4491e633 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -106,6 +106,7 @@ Upcoming Version * A frozen constraint caches the label-to-position mapping of its matrix columns by weak reference and only rebuilds it when the constraint or the set of variables changes. While a persistent snapshot holds the arrays, repeated matrix assembly on an unchanged model returns the same objects, so the snapshot diff can again skip the comparison of untouched frozen constraints by object identity; one-off exports such as ``to_file`` retain no extra memory. (`#933 `__) * ``Model.copy`` (and the ``copy.copy``/``copy.deepcopy`` protocols) keeps frozen constraints as ``CSRConstraint`` instead of rebuilding them as dense ``Constraint``; ``deep=True`` copies their arrays, and duals are copied only with ``include_solution=True``. A CSR-backed objective is now deep-copied with ``deep=True`` as well. The auxiliary coordinates of a CSR grid are stored read-only, so a copy can share them safely. (`#981 `__) * ``Solver.close()`` no longer leaves dangling native handles behind. The solver model is now dropped before the environment that owns it, instead of after. And the COPT and MindOpt file interfaces no longer hand back a model they already disposed: after a file-based COPT or MindOpt solve, ``model.solver_model`` is ``None`` rather than a handle into freed memory. (`#899 `__) +* ``linopy.read_netcdf`` no longer drops a parameter whose name contains a dash (``m.parameters["my-param"]``), parameter attributes with a dash in their name, or the ``MultiIndex`` of a dimension with a dash in its name. Keys were split at their last dash to find the container they belong to, which only works for names without one. **Breaking Changes** diff --git a/linopy/io.py b/linopy/io.py index 80568f18..35a79b5c 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -15,6 +15,7 @@ from dataclasses import replace from importlib.metadata import version from io import BufferedWriter +from itertools import chain from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING, Any, TypeVar @@ -1219,7 +1220,7 @@ def read_netcdf(path: Path | str, **kwargs: Any) -> Model: m = Model(sparse=bool(ds.attrs.get("sparse", False))) def has_prefix(k: str, prefix: str) -> bool: - return k.rsplit("-", 1)[0] == prefix + return owner(k) == prefix def remove_prefix(k: str, prefix: str) -> str: return k[len(prefix) + 1 :] @@ -1269,15 +1270,32 @@ def container_names(kind: str) -> list[str]: ) return ordered + containers = { + kind: container_names(kind) + for kind in ("variables", "expressions", "constraints") + } + # Data variables and attributes are stored as ``-``, with + # prefix ``-``, ``objective`` or ``parameters``. Names and keys + # (parameter names, attribute names, dimensions) may contain dashes, so a + # key belongs to the longest known prefix. + prefixes = {"objective", "parameters", *chain.from_iterable(containers.values())} + + def owner(k: str) -> str | None: + parts = k.split("-") + for i in range(len(parts) - 1, 0, -1): + if (prefix := "-".join(parts[:i])) in prefixes: + return prefix + return None + variables = {} - for k in container_names("variables"): + for k in containers["variables"]: name = remove_prefix(k, "variables") variables[name] = Variable(get_prefix(ds, k), m, name) m._variables = Variables(variables, m) expressions: dict[str, LinearExpression | QuadraticExpression] = {} - for k in container_names("expressions"): + for k in containers["expressions"]: name = remove_prefix(k, "expressions") expr_ds = get_prefix(ds, k) expr_type = expr_ds.attrs.pop(EXPR_TYPE_ATTR, None) @@ -1295,7 +1313,7 @@ def container_names(kind: str) -> list[str]: m._expressions = Expressions(expressions, m) constraints: dict[str, ConstraintBase] = {} - for k in container_names("constraints"): + for k in containers["constraints"]: name = remove_prefix(k, "constraints") con_ds = get_prefix(ds, k) if con_ds.attrs.get("_linopy_format") == "csr": diff --git a/test/test_io.py b/test/test_io.py index dabee6f5..fbbbeb0c 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -360,6 +360,44 @@ def test_model_to_netcdf_with_dash_names( assert_model_equal(m, p) +def test_model_to_netcdf_with_dash_named_parameters( + model: Model, tmp_path: Path +) -> None: + m = model + m.parameters["my-param"] = xr.DataArray( + [5, 6], dims=["my-dim"], coords={"my-dim": ["a", "b"]} + ) + m.parameters.attrs["my-attr"] = "kept" + fn = tmp_path / "test.nc" + m.to_netcdf(fn) + p = read_netcdf(fn) + + assert_model_equal(m, p) + assert p.parameters.attrs == m.parameters.attrs + + +def test_model_to_netcdf_with_dash_named_multiindex_dim(tmp_path: Path) -> None: + from linopy.semantics import is_v1 + + if is_v1(): + pytest.skip("v1 rejects MultiIndex; this model only builds under legacy") + index = pd.MultiIndex.from_tuples( + [(1, "a"), (1, "b"), (2, "a")], names=["first", "second"] + ) + coords = xr.Coordinates.from_pandas_multiindex(index, "multi-dim") + m = Model() + x = m.add_variables(coords=coords, name="x") + m.add_objective(x.sum()) + m.parameters["param"] = xr.DataArray([1, 2, 3], coords=coords) + fn = tmp_path / "test.nc" + m.to_netcdf(fn) + p = read_netcdf(fn) + + assert_model_equal(m, p) + for index in (p.variables["x"].indexes, p.parameters.indexes): + assert isinstance(index["multi-dim"], pd.MultiIndex) + + def test_model_to_netcdf_with_status_and_condition( model_with_dash_names: Model, tmp_path: Path ) -> None: From 7dc40b1587fa76362550df4aaab529dbb72fdac9 Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Mon, 12 Oct 2026 14:44:11 +0200 Subject: [PATCH 2/3] 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 796ea93225568e625d420d2560ad1bad10dfeefb Mon Sep 17 00:00:00 2001 From: Mayk Thewessen Date: Wed, 14 Oct 2026 19:47:50 +0200 Subject: [PATCH 3/3] docs: move release note to upcoming version Co-Authored-By: Claude Opus 5.5 --- doc/release_notes.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 49f1dc3c..1b7407d6 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,8 @@ Release Notes Upcoming Version ---------------- +* ``read_netcdf`` no longer drops parameters, parameter attributes or ``MultiIndex`` dimensions whose name contains ``-``. (`#1025 `__) + Version 0.10.0 -------------- @@ -103,7 +105,6 @@ Enable it with ``linopy.options["semantics"] = "v1"``. Legacy stays the default * ``to_netcdf``/``read_netcdf`` keep a quadratic objective quadratic. (`#903 `__) * ``read_netcdf`` restores containers in insertion order, so a round trip no longer permutes ``matrices.A``. ``assert_model_equal`` now compares the order. (`#934 `__) * ``read_netcdf`` no longer fails when a frozen constraint has a dimension or level name with ``-``. -* ``read_netcdf`` no longer drops parameters, parameter attributes or ``MultiIndex`` dimensions whose name contains ``-``. * ``Model.copy`` keeps frozen constraints frozen; ``deep=True`` copies their arrays and a CSR-backed objective. (`#981 `__) Files from earlier versions load as before.