From 0f1b7acc71cdab33097d460a25b1154a25c105ec Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:59:22 +0200 Subject: [PATCH 1/4] test(io): pin that Model.copy keeps a quadratic objective quadratic Strict-xfail regression test for #903: Model.copy() and the copy.copy / copy.deepcopy protocols rebuild the objective as a LinearExpression, silently turning a QP into a different LP. Co-Authored-By: Claude Opus 5 (1M context) --- test/test_model.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/test_model.py b/test/test_model.py index f55f280c..b8f5d8fe 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -7,6 +7,7 @@ import copy as pycopy import weakref +from collections.abc import Callable from pathlib import Path from tempfile import gettempdir @@ -394,6 +395,29 @@ def test_copy_model_with_expressions( assert m.expressions["lin"].coeffs.values.flat[0] == original_coeff +@pytest.mark.parametrize( + "copy_fn", + [lambda m: m.copy(), pycopy.copy, pycopy.deepcopy], + ids=["copy", "shallowcopy", "deepcopy"], +) +@pytest.mark.xfail( + strict=True, reason="issue #903: copy downgrades a quadratic objective to linear" +) +def test_model_copy_preserves_quadratic_objective( + copy_fn: Callable[[Model], Model], +) -> None: + """Copying a model keeps its objective quadratic instead of linearizing it.""" + m: Model = Model() + x = m.add_variables(lower=-5, upper=5, name="x") + m.add_objective(x * x + 2 * x) + + c = copy_fn(m) + + assert type(c.objective.expression) is type(m.objective.expression) + assert c.type == m.type == "QP" + assert_model_equal(m, c) + + @pytest.mark.skipif(not available_solvers, reason="No solver installed") class TestModelCopySolved: def test_model_deepcopy_protocol_excludes_solution( From e4d6b5cc633183dc9923ed7a04f181a59329fb78 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:59:49 +0200 Subject: [PATCH 2/4] fix(io): keep the objective's expression type when copying a model Model.copy() wrapped the objective data in LinearExpression regardless of its actual type, so a QuadraticExpression objective was silently downgraded: Model.type flipped from QP to LP, the quadratic term's second variable column was reinterpreted as a linear row, and the copy solved a different problem without warning. Dispatch on the source expression's type, as _copy_expr already does for named expressions. assert_model_equal compared objectives with assert_linequal, which rejects a QuadraticExpression outright, so it could not be used on a QP model at all. It now dispatches via assert_exprequal, which also makes a difference in expression type fail the comparison. Closes #903 Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 1 + linopy/io.py | 4 +++- linopy/testing.py | 2 +- test/test_model.py | 3 --- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 693293eb..5587e512 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -54,6 +54,7 @@ Upcoming Version **Bug fixes** * 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``. +* ``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 `__) Version 0.9.1 diff --git a/linopy/io.py b/linopy/io.py index e6196d47..29c687e2 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1267,7 +1267,9 @@ def _copy_con_data(con: ConstraintBase) -> xr.Dataset: new_model, ) - obj_expr = LinearExpression(m.objective.expression.data.copy(deep=deep), new_model) + obj_expr = type(m.objective.expression)( + m.objective.expression.data.copy(deep=deep), new_model + ) new_model._objective = Objective(obj_expr, new_model, m.objective.sense) new_model._objective._value = ( float(m.objective.value) diff --git a/linopy/testing.py b/linopy/testing.py index d9c67f7b..5fd16778 100644 --- a/linopy/testing.py +++ b/linopy/testing.py @@ -130,7 +130,7 @@ def assert_model_equal(a: Model, b: Model) -> None: for e in a.expressions: assert_exprequal(a.expressions[e], b.expressions[e]) - assert_linequal(a.objective.expression, b.objective.expression) + assert_exprequal(a.objective.expression, b.objective.expression, check_name=False) assert a.objective.sense == b.objective.sense assert a.objective.value == b.objective.value diff --git a/test/test_model.py b/test/test_model.py index b8f5d8fe..de0b3cb6 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -400,9 +400,6 @@ def test_copy_model_with_expressions( [lambda m: m.copy(), pycopy.copy, pycopy.deepcopy], ids=["copy", "shallowcopy", "deepcopy"], ) -@pytest.mark.xfail( - strict=True, reason="issue #903: copy downgrades a quadratic objective to linear" -) def test_model_copy_preserves_quadratic_objective( copy_fn: Callable[[Model], Model], ) -> None: From 5399609157a032dc1ca8e353727d170c8feb85f9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:00:18 +0200 Subject: [PATCH 3/4] fix(io): keep the objective's expression type across a netcdf round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_netcdf/read_netcdf rebuilt the objective as a LinearExpression, so a saved QP was read back as a different LP — the same silent downgrade as Model.copy in the previous commit. Store the expression type next to the objective and restore it on read, falling back to the presence of the factor dimension for files written by earlier versions. Co-Authored-By: Claude Opus 5 (1M context) --- doc/release_notes.rst | 1 + linopy/io.py | 13 ++++++++++--- test/test_io.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 5587e512..26ec0b56 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -55,6 +55,7 @@ Upcoming Version * 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``. * ``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 `__) Version 0.9.1 diff --git a/linopy/io.py b/linopy/io.py index 29c687e2..f981d19b 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -992,7 +992,9 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: for name, expr in m.expressions.items() ] objective = m.objective.data - objective = objective.assign_attrs(sense=m.objective.sense) + objective = objective.assign_attrs( + sense=m.objective.sense, _linopy_expr_type=m.objective.expression.type + ) if m.objective.value is not None: objective = objective.assign_attrs(value=m.objective.value) obj = [with_prefix(objective, "objective")] @@ -1141,9 +1143,14 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: m._constraints = Constraints(constraints, m) objective = get_prefix(ds, "objective") - m.objective = Objective( - LinearExpression(objective, m), m, objective.attrs.pop("sense") + obj_type = objective.attrs.pop("_linopy_expr_type", None) + obj_cls: type[LinearExpression] | type[QuadraticExpression] = ( + QuadraticExpression + if obj_type == "QuadraticExpression" + or (obj_type is None and FACTOR_DIM in objective.dims) + else LinearExpression ) + m.objective = Objective(obj_cls(objective, m), m, objective.attrs.pop("sense")) m.objective._value = objective.attrs.pop("value", None) m.parameters = get_prefix(ds, "parameters") diff --git a/test/test_io.py b/test/test_io.py index 7c37a7dd..50f53b76 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -277,6 +277,20 @@ def test_model_to_netcdf_quadratic_expression( assert_exprequal(m.expressions["quad"], p.expressions["quad"]) +def test_model_to_netcdf_quadratic_objective(tmp_path: Path) -> None: + """A quadratic objective survives the round-trip as a QP, not an LP.""" + m = Model() + x = m.add_variables(4, pd.Series([8, 10]), name="x") + m.add_objective((x * x + 2 * x).sum()) + + fn = tmp_path / "test.nc" + m.to_netcdf(fn) + p = read_netcdf(fn) + + assert isinstance(p.objective.expression, QuadraticExpression) + assert_model_equal(m, p) + + def test_model_to_netcdf_expression_dash_name( model_with_expressions: Model, tmp_path: Path ) -> None: From 586dd5b752aa83eb22df7ea8fb39c2cc15174a3a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:07:03 +0200 Subject: [PATCH 4/4] refactor(io): name the expression-type netcdf attr The attribute carrying an expression's class through to_netcdf has been spelled out at each of its four use sites since it was introduced for named expressions. Give it a constant, next to NETCDF_VERSION_ATTR. Co-Authored-By: Claude Opus 5 (1M context) --- linopy/io.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index f981d19b..b5ea8bc1 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -40,6 +40,7 @@ logger = logging.getLogger(__name__) NETCDF_VERSION_ATTR = "_linopy_version" +EXPR_TYPE_ATTR = "_linopy_expr_type" ufunc_kwargs = dict(vectorize=True) @@ -986,14 +987,14 @@ def with_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: ] exprs = [ with_prefix( - expr.data.assign_attrs(name=name, _linopy_expr_type=expr.type), + expr.data.assign_attrs(name=name, **{EXPR_TYPE_ATTR: expr.type}), f"expressions-{name}", ) for name, expr in m.expressions.items() ] objective = m.objective.data objective = objective.assign_attrs( - sense=m.objective.sense, _linopy_expr_type=m.objective.expression.type + sense=m.objective.sense, **{EXPR_TYPE_ATTR: m.objective.expression.type} ) if m.objective.value is not None: objective = objective.assign_attrs(value=m.objective.value) @@ -1116,7 +1117,7 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: for k in sorted(expr_names): name = remove_prefix(k, "expressions") expr_ds = get_prefix(ds, k) - expr_type = expr_ds.attrs.pop("_linopy_expr_type", None) + expr_type = expr_ds.attrs.pop(EXPR_TYPE_ATTR, None) expr_ds.attrs.pop("name", None) # re-attached below, after construction expr: LinearExpression | QuadraticExpression if expr_type == "QuadraticExpression" or ( @@ -1143,7 +1144,7 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: m._constraints = Constraints(constraints, m) objective = get_prefix(ds, "objective") - obj_type = objective.attrs.pop("_linopy_expr_type", None) + obj_type = objective.attrs.pop(EXPR_TYPE_ATTR, None) obj_cls: type[LinearExpression] | type[QuadraticExpression] = ( QuadraticExpression if obj_type == "QuadraticExpression"