From c22c94b856e85bdd10ea2656cb770f72dd9edde5 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 17 Mar 2026 13:37:46 +0100 Subject: [PATCH 1/9] Added m.copy() method. --- linopy/model.py | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/linopy/model.py b/linopy/model.py index 06e814c6..bfa3ddd2 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1877,6 +1877,78 @@ def reset_solution(self) -> None: self.variables.reset_solution() self.constraints.reset_dual() + def copy(self, include_solution: bool = False) -> Model: + """ + Return a deep copy of this model. + + Copies variables, constraints, objective, parameters, blocks, and all + scalar attributes (counters, flags). The copy is fully independent: + modifying one does not affect the other. + + Parameters + ---------- + include_solution : bool, optional + Whether to include the current solution and dual values in the copy. + If False (default), the copy is returned in an initialized state: + solution and dual data are excluded, objective value is set to None, + and status is set to 'initialized'. If True, solution, dual values, + solve status, and objective value are also copied. + + Returns + ------- + Model + A deep copy of the model. + """ + SOLVE_STATE_ATTRS = {"status", "termination_condition"} + + m = Model( + chunk=self._chunk, + force_dim_names=self._force_dim_names, + auto_mask=self._auto_mask, + solver_dir=self._solver_dir, + ) + + m._variables = Variables( + { + name: Variable( + var.data.copy() + if include_solution + else var.data[self.variables.dataset_attrs].copy(), + m, + name, + ) + for name, var in self.variables.items() + }, + m, + ) + + m._constraints = Constraints( + { + name: Constraint( + con.data.copy() + if include_solution + else con.data[self.constraints.dataset_attrs].copy(), + m, + name, + ) + for name, con in self.constraints.items() + }, + m, + ) + + obj_expr = LinearExpression(self.objective.expression.data.copy(), m) + m._objective = Objective(obj_expr, m, self.objective.sense) + m._objective._value = self.objective.value if include_solution else None + + m._parameters = self._parameters.copy(deep=True) + m._blocks = self._blocks.copy() if self._blocks is not None else None + + for attr in self.scalar_attrs: + if include_solution or attr not in SOLVE_STATE_ATTRS: + setattr(m, attr, getattr(self, attr)) + + return m + to_netcdf = to_netcdf to_file = to_file From 821850372acb4c436a1b6b5f5f51036efdb80629 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 17 Mar 2026 13:38:24 +0100 Subject: [PATCH 2/9] Added testing suite for m.copy(). --- test/test_model.py | 73 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/test/test_model.py b/test/test_model.py index c363fe4c..51241f87 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -12,8 +12,13 @@ import pytest import xarray as xr -from linopy import EQUAL, Model -from linopy.testing import assert_model_equal +from linopy import EQUAL, Model, available_solvers +from linopy.testing import ( + assert_conequal, + assert_equal, + assert_linequal, + assert_model_equal, +) target_shape: tuple[int, int] = (10, 10) @@ -163,3 +168,67 @@ def test_assert_model_equal() -> None: m.add_objective(obj) assert_model_equal(m, m) + + +def _build_model() -> Model: + """Small representative model used across copy tests.""" + m: Model = Model() + + lower: xr.DataArray = xr.DataArray( + np.zeros((10, 10)), coords=[range(10), range(10)] + ) + upper: xr.DataArray = xr.DataArray(np.ones((10, 10)), coords=[range(10), range(10)]) + x = m.add_variables(lower, upper, name="x") + y = m.add_variables(name="y") + + m.add_constraints(1 * x + 10 * y, EQUAL, 0) + m.add_objective((10 * x + 5 * y).sum()) + + return m + + +def test_model_copy_unsolved() -> None: + """Copy of unsolved model is structurally equal and independent.""" + m = _build_model() + c = m.copy(include_solution=False) + + assert_model_equal(m, c) + + # independence: mutating copy does not affect source + c.add_variables(name="z") + assert "z" not in m.variables + + +@pytest.mark.skipif(len(available_solvers) == 0, reason="No solver installed") +def test_model_copy_solved_with_solution() -> None: + """Copy with include_solution=True preserves solve state.""" + m = _build_model() + m.solve() + + c = m.copy(include_solution=True) + assert_model_equal(m, c) + + +@pytest.mark.skipif(len(available_solvers) == 0, reason="No solver installed") +def test_model_copy_solved_without_solution() -> None: + """Copy with include_solution=False (default) drops solve state but preserves problem structure.""" + m = _build_model() + m.solve() + + c = m.copy(include_solution=False) + + # solve state is dropped + assert c.status == "initialized" + assert c.termination_condition == "" + assert c.objective.value is None + + # problem structure is preserved — compare only dataset_attrs to exclude solution/dual + for v in m.variables: + assert_equal( + c.variables[v].data[c.variables.dataset_attrs], + m.variables[v].data[m.variables.dataset_attrs], + ) + for con in m.constraints: + assert_conequal(c.constraints[con], m.constraints[con], strict=False) + assert_linequal(c.objective.expression, m.objective.expression) + assert c.objective.sense == m.objective.sense From 2da2d3521ac655a33d0749dd1554530eeabe2523 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Tue, 17 Mar 2026 13:50:57 +0100 Subject: [PATCH 3/9] Fix solver_dir type annotation. --- linopy/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linopy/model.py b/linopy/model.py index bfa3ddd2..2db7d7df 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1905,7 +1905,7 @@ def copy(self, include_solution: bool = False) -> Model: chunk=self._chunk, force_dim_names=self._force_dim_names, auto_mask=self._auto_mask, - solver_dir=self._solver_dir, + solver_dir=str(self._solver_dir), ) m._variables = Variables( From bb6b8a4216f5d8a9a2467263c87360314cb3f173 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Wed, 18 Mar 2026 14:58:00 +0100 Subject: [PATCH 4/9] Bug fix: xarray copyies need to be . --- linopy/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linopy/model.py b/linopy/model.py index 2db7d7df..55b9d5a5 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1913,7 +1913,7 @@ def copy(self, include_solution: bool = False) -> Model: name: Variable( var.data.copy() if include_solution - else var.data[self.variables.dataset_attrs].copy(), + else var.data[self.variables.dataset_attrs].copy(deep=True), m, name, ) @@ -1927,7 +1927,7 @@ def copy(self, include_solution: bool = False) -> Model: name: Constraint( con.data.copy() if include_solution - else con.data[self.constraints.dataset_attrs].copy(), + else con.data[self.constraints.dataset_attrs].copy(deep=True), m, name, ) From fe8d952aea94fb80e73d227719f10b61cbf7b36d Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Wed, 18 Mar 2026 19:39:58 +0100 Subject: [PATCH 5/9] Moved copy to io.py, added deep-copy to all xarray operations. --- linopy/io.py | 85 +++++++++++++++++++++++++++++++++++++++++++++++++ linopy/model.py | 73 ++---------------------------------------- 2 files changed, 87 insertions(+), 71 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index 2213cbb5..c380c931 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1239,3 +1239,88 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: setattr(m, k, ds.attrs.get(k)) return m + + +def copy(src: Model, include_solution: bool = False) -> Model: + """ + Return a deep copy of this model. + + Copies variables, constraints, objective, parameters, blocks, and all + scalar attributes (counters, flags). The copy is fully independent: + modifying one does not affect the other. + + Parameters + ---------- + src : Model + The model to copy. + include_solution : bool, optional + Whether to include the current solution and dual values in the copy. + If False (default), the copy is returned in an initialized state: + solution and dual data are excluded, objective value is set to None, + and status is set to 'initialized'. If True, solution, dual values, + solve status, and objective value are also copied. + + Returns + ------- + Model + A deep copy of the model. + """ + from linopy.model import ( + Constraint, + Constraints, + LinearExpression, + Model, + Objective, + Variable, + Variables, + ) + + SOLVE_STATE_ATTRS = {"status", "termination_condition"} + + m = Model( + chunk=src._chunk, + force_dim_names=src._force_dim_names, + auto_mask=src._auto_mask, + solver_dir=str(src._solver_dir), + ) + + m._variables = Variables( + { + name: Variable( + var.data.copy(deep=True) + if include_solution + else var.data[src.variables.dataset_attrs].copy(deep=True), + m, + name, + ) + for name, var in src.variables.items() + }, + m, + ) + + m._constraints = Constraints( + { + name: Constraint( + con.data.copy(deep=True) + if include_solution + else con.data[src.constraints.dataset_attrs].copy(deep=True), + m, + name, + ) + for name, con in src.constraints.items() + }, + m, + ) + + obj_expr = LinearExpression(src.objective.expression.data.copy(deep=True), m) + m._objective = Objective(obj_expr, m, src.objective.sense) + m._objective._value = src.objective.value if include_solution else None + + m._parameters = src._parameters.copy(deep=True) + m._blocks = src._blocks.copy(deep=True) if src._blocks is not None else None + + for attr in src.scalar_attrs: + if include_solution or attr not in SOLVE_STATE_ATTRS: + setattr(m, attr, getattr(src, attr)) + + return m diff --git a/linopy/model.py b/linopy/model.py index 55b9d5a5..48ba8b02 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -53,6 +53,7 @@ ScalarLinearExpression, ) from linopy.io import ( + copy, to_block_files, to_cupdlpx, to_file, @@ -1877,77 +1878,7 @@ def reset_solution(self) -> None: self.variables.reset_solution() self.constraints.reset_dual() - def copy(self, include_solution: bool = False) -> Model: - """ - Return a deep copy of this model. - - Copies variables, constraints, objective, parameters, blocks, and all - scalar attributes (counters, flags). The copy is fully independent: - modifying one does not affect the other. - - Parameters - ---------- - include_solution : bool, optional - Whether to include the current solution and dual values in the copy. - If False (default), the copy is returned in an initialized state: - solution and dual data are excluded, objective value is set to None, - and status is set to 'initialized'. If True, solution, dual values, - solve status, and objective value are also copied. - - Returns - ------- - Model - A deep copy of the model. - """ - SOLVE_STATE_ATTRS = {"status", "termination_condition"} - - m = Model( - chunk=self._chunk, - force_dim_names=self._force_dim_names, - auto_mask=self._auto_mask, - solver_dir=str(self._solver_dir), - ) - - m._variables = Variables( - { - name: Variable( - var.data.copy() - if include_solution - else var.data[self.variables.dataset_attrs].copy(deep=True), - m, - name, - ) - for name, var in self.variables.items() - }, - m, - ) - - m._constraints = Constraints( - { - name: Constraint( - con.data.copy() - if include_solution - else con.data[self.constraints.dataset_attrs].copy(deep=True), - m, - name, - ) - for name, con in self.constraints.items() - }, - m, - ) - - obj_expr = LinearExpression(self.objective.expression.data.copy(), m) - m._objective = Objective(obj_expr, m, self.objective.sense) - m._objective._value = self.objective.value if include_solution else None - - m._parameters = self._parameters.copy(deep=True) - m._blocks = self._blocks.copy() if self._blocks is not None else None - - for attr in self.scalar_attrs: - if include_solution or attr not in SOLVE_STATE_ATTRS: - setattr(m, attr, getattr(self, attr)) - - return m + copy = copy to_netcdf = to_netcdf From f4016a8811e196092f7983e7543d776c74f8c928 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 20 Mar 2026 15:09:44 +0100 Subject: [PATCH 6/9] Improved copy method: Strengtheninc copy protocol compatibility, check for deep copy independence. --- linopy/io.py | 99 ++++++++++++++++++--------- linopy/model.py | 6 ++ test/test_model.py | 163 ++++++++++++++++++++++++++++++++++++--------- 3 files changed, 204 insertions(+), 64 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index c380c931..fd3c536a 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1241,29 +1241,38 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset: return m -def copy(src: Model, include_solution: bool = False) -> Model: +def copy(m: Model, include_solution: bool = False, deep: bool = True) -> Model: """ - Return a deep copy of this model. + Return a copy of this model. - Copies variables, constraints, objective, parameters, blocks, and all - scalar attributes (counters, flags). The copy is fully independent: - modifying one does not affect the other. + With ``deep=True`` (default), variables, constraints, objective, + parameters, blocks, and scalar attributes are copied to a fully + independent model. With ``deep=False``, returns a shallow copy. + + Solver runtime metadata (for example, ``solver_name`` and + ``solver_model``) is intentionally not copied. Solver backend state + is recreated on ``solve()``. Parameters ---------- - src : Model + m : Model The model to copy. include_solution : bool, optional Whether to include the current solution and dual values in the copy. If False (default), the copy is returned in an initialized state: solution and dual data are excluded, objective value is set to None, and status is set to 'initialized'. If True, solution, dual values, - solve status, and objective value are also copied. + solve status, and objective value are also copied. If the model is + unsolved, this has no additional effect. + deep : bool, optional + Whether to return a deep copy (default) or shallow copy. If False, + the returned model uses independent wrapper objects that share + underlying data buffers with the source model. Returns ------- Model - A deep copy of the model. + A deep or shallow copy of the model. """ from linopy.model import ( Constraint, @@ -1277,50 +1286,74 @@ def copy(src: Model, include_solution: bool = False) -> Model: SOLVE_STATE_ATTRS = {"status", "termination_condition"} - m = Model( - chunk=src._chunk, - force_dim_names=src._force_dim_names, - auto_mask=src._auto_mask, - solver_dir=str(src._solver_dir), + new_model = Model( + chunk=m._chunk, + force_dim_names=m._force_dim_names, + auto_mask=m._auto_mask, + solver_dir=str(m._solver_dir), ) - m._variables = Variables( + new_model._variables = Variables( { name: Variable( - var.data.copy(deep=True) + var.data.copy(deep=deep) if include_solution - else var.data[src.variables.dataset_attrs].copy(deep=True), - m, + else var.data[m.variables.dataset_attrs].copy(deep=deep), + new_model, name, ) - for name, var in src.variables.items() + for name, var in m.variables.items() }, - m, + new_model, ) - m._constraints = Constraints( + new_model._constraints = Constraints( { name: Constraint( - con.data.copy(deep=True) + con.data.copy(deep=deep) if include_solution - else con.data[src.constraints.dataset_attrs].copy(deep=True), - m, + else con.data[m.constraints.dataset_attrs].copy(deep=deep), + new_model, name, ) - for name, con in src.constraints.items() + for name, con in m.constraints.items() }, - m, + new_model, ) - obj_expr = LinearExpression(src.objective.expression.data.copy(deep=True), m) - m._objective = Objective(obj_expr, m, src.objective.sense) - m._objective._value = src.objective.value if include_solution else None + obj_expr = LinearExpression(m.objective.expression.data.copy(deep=deep), new_model) + new_model._objective = Objective(obj_expr, new_model, m.objective.sense) + new_model._objective._value = m.objective.value if include_solution else None - m._parameters = src._parameters.copy(deep=True) - m._blocks = src._blocks.copy(deep=True) if src._blocks is not None else None + new_model._parameters = m._parameters.copy(deep=deep) + new_model._blocks = m._blocks.copy(deep=deep) if m._blocks is not None else None - for attr in src.scalar_attrs: + for attr in m.scalar_attrs: if include_solution or attr not in SOLVE_STATE_ATTRS: - setattr(m, attr, getattr(src, attr)) + setattr(new_model, attr, getattr(m, attr)) - return m + return new_model + + +def shallowcopy(m: Model) -> Model: + """ + Support Python's ``copy.copy`` protocol for ``Model``. + + Returns a shallow copy with independent wrapper objects that share + underlying array buffers with ``m``. Solve artifacts are excluded, + matching :meth:`Model.copy` defaults. + """ + return copy(m, include_solution=False, deep=False) + + +def deepcopy(m: Model, memo: dict[int, Any]) -> Model: + """ + Support Python's ``copy.deepcopy`` protocol for ``Model``. + + Returns a deep, structurally independent copy and records it in ``memo`` + as required by Python's copy protocol. Solve artifacts are excluded, + matching :meth:`Model.copy` defaults. + """ + new_model = copy(m, include_solution=False, deep=True) + memo[id(m)] = new_model + return new_model diff --git a/linopy/model.py b/linopy/model.py index 48ba8b02..84275c8b 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -54,6 +54,8 @@ ) from linopy.io import ( copy, + deepcopy, + shallowcopy, to_block_files, to_cupdlpx, to_file, @@ -1880,6 +1882,10 @@ def reset_solution(self) -> None: copy = copy + __copy__ = shallowcopy + + __deepcopy__ = deepcopy + to_netcdf = to_netcdf to_file = to_file diff --git a/test/test_model.py b/test/test_model.py index 51241f87..c0988c26 100644 --- a/test/test_model.py +++ b/test/test_model.py @@ -5,6 +5,7 @@ from __future__ import annotations +import copy as pycopy from pathlib import Path from tempfile import gettempdir @@ -170,7 +171,8 @@ def test_assert_model_equal() -> None: assert_model_equal(m, m) -def _build_model() -> Model: +@pytest.fixture(scope="module") +def copy_test_model() -> Model: """Small representative model used across copy tests.""" m: Model = Model() @@ -187,9 +189,17 @@ def _build_model() -> Model: return m -def test_model_copy_unsolved() -> None: +@pytest.fixture(scope="module") +def solved_copy_test_model(copy_test_model: Model) -> Model: + """Solved representative model used across solved-copy tests.""" + m = copy_test_model.copy(deep=True) + m.solve() + return m + + +def test_model_copy_unsolved(copy_test_model: Model) -> None: """Copy of unsolved model is structurally equal and independent.""" - m = _build_model() + m = copy_test_model.copy(deep=True) c = m.copy(include_solution=False) assert_model_equal(m, c) @@ -199,36 +209,127 @@ def test_model_copy_unsolved() -> None: assert "z" not in m.variables -@pytest.mark.skipif(len(available_solvers) == 0, reason="No solver installed") -def test_model_copy_solved_with_solution() -> None: - """Copy with include_solution=True preserves solve state.""" - m = _build_model() - m.solve() +def test_model_copy_unsolved_with_solution_flag(copy_test_model: Model) -> None: + """Unsolved model with include_solution=True has no extra solve artifacts.""" + m = copy_test_model.copy(deep=True) - c = m.copy(include_solution=True) - assert_model_equal(m, c) + c_include_solution = m.copy(include_solution=True) + c_exclude_solution = m.copy(include_solution=False) + assert_model_equal(c_include_solution, c_exclude_solution) + assert c_include_solution.status == "initialized" + assert c_include_solution.termination_condition == "" + assert c_include_solution.objective.value is None -@pytest.mark.skipif(len(available_solvers) == 0, reason="No solver installed") -def test_model_copy_solved_without_solution() -> None: - """Copy with include_solution=False (default) drops solve state but preserves problem structure.""" - m = _build_model() - m.solve() - c = m.copy(include_solution=False) +def test_model_copy_shallow(copy_test_model: Model) -> None: + """Shallow copy has independent wrappers sharing underlying data buffers.""" + m = copy_test_model.copy(deep=True) + c = m.copy(deep=False) + + assert c is not m + assert c.variables is not m.variables + assert c.constraints is not m.constraints + assert c.objective is not m.objective + + # wrappers are distinct, but shallow copy shares payload buffers + c.variables["x"].lower.values[0, 0] = 123.0 + assert m.variables["x"].lower.values[0, 0] == 123.0 + + +def test_model_deepcopy_protocol(copy_test_model: Model) -> None: + """copy.deepcopy(model) dispatches to Model.__deepcopy__ and stays independent.""" + m = copy_test_model.copy(deep=True) + c = pycopy.deepcopy(m) + + assert_model_equal(m, c) + + # Test independence: mutations to copy do not affect source + # 1. Variable mutation: add new variable + c.add_variables(name="z") + assert "z" not in m.variables - # solve state is dropped - assert c.status == "initialized" - assert c.termination_condition == "" - assert c.objective.value is None - - # problem structure is preserved — compare only dataset_attrs to exclude solution/dual - for v in m.variables: - assert_equal( - c.variables[v].data[c.variables.dataset_attrs], - m.variables[v].data[m.variables.dataset_attrs], - ) - for con in m.constraints: - assert_conequal(c.constraints[con], m.constraints[con], strict=False) - assert_linequal(c.objective.expression, m.objective.expression) - assert c.objective.sense == m.objective.sense + # 2. Variable data mutation (bounds): verify buffers are independent + original_lower = m.variables["x"].lower.values[0, 0].item() + new_lower = 999 + c.variables["x"].lower.values[0, 0] = new_lower + assert c.variables["x"].lower.values[0, 0] == new_lower + assert m.variables["x"].lower.values[0, 0] == original_lower + + # 3. Constraint coefficient mutation: deep copy must not leak back + original_con_coeff = m.constraints["con0"].coeffs.values.flat[0].item() + new_con_coeff = original_con_coeff + 42 + c.constraints["con0"].coeffs.values.flat[0] = new_con_coeff + assert c.constraints["con0"].coeffs.values.flat[0] == new_con_coeff + assert m.constraints["con0"].coeffs.values.flat[0] == original_con_coeff + + # 4. Objective expression coefficient mutation: deep copy must not leak back + original_obj_coeff = m.objective.expression.coeffs.values.flat[0].item() + new_obj_coeff = original_obj_coeff + 20 + c.objective.expression.coeffs.values.flat[0] = new_obj_coeff + assert c.objective.expression.coeffs.values.flat[0] == new_obj_coeff + assert m.objective.expression.coeffs.values.flat[0] == original_obj_coeff + + # 5. Objective sense mutation + original_sense = m.objective.sense + c.objective.sense = "max" + assert c.objective.sense == "max" + assert m.objective.sense == original_sense + + +@pytest.mark.skipif(not available_solvers, reason="No solver installed") +class TestModelCopySolved: + def test_model_deepcopy_protocol_excludes_solution( + self, solved_copy_test_model: Model + ) -> None: + """copy.deepcopy on solved model drops solve state by default.""" + m = solved_copy_test_model + + c = pycopy.deepcopy(m) + + assert c.status == "initialized" + assert c.termination_condition == "" + assert c.objective.value is None + + for v in m.variables: + assert_equal( + c.variables[v].data[c.variables.dataset_attrs], + m.variables[v].data[m.variables.dataset_attrs], + ) + for con in m.constraints: + assert_conequal(c.constraints[con], m.constraints[con], strict=False) + assert_linequal(c.objective.expression, m.objective.expression) + assert c.objective.sense == m.objective.sense + + def test_model_copy_solved_with_solution( + self, solved_copy_test_model: Model + ) -> None: + """Copy with include_solution=True preserves solve state.""" + m = solved_copy_test_model + + c = m.copy(include_solution=True) + assert_model_equal(m, c) + + def test_model_copy_solved_without_solution( + self, solved_copy_test_model: Model + ) -> None: + """Copy with include_solution=False (default) drops solve state but preserves problem structure.""" + m = solved_copy_test_model + + c = m.copy(include_solution=False) + + # solve state is dropped + assert c.status == "initialized" + assert c.termination_condition == "" + assert c.objective.value is None + + # problem structure is preserved — compare only dataset_attrs to exclude solution/dual + for v in m.variables: + assert_equal( + c.variables[v].data[c.variables.dataset_attrs], + m.variables[v].data[m.variables.dataset_attrs], + ) + for con in m.constraints: + assert_conequal(c.constraints[con], m.constraints[con], strict=False) + assert_linequal(c.objective.expression, m.objective.expression) + assert c.objective.sense == m.objective.sense From 49e9246371dba823a6ab28b29054226ee419441b Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 20 Mar 2026 15:36:01 +0100 Subject: [PATCH 7/9] Added release notes. --- doc/release_notes.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 35b21c67..a138fbbb 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,7 @@ Release Notes Upcoming Version ---------------- +* Add ``Model.copy()`` method with ``deep`` and ``include_solution`` options; support Python ``copy.copy`` and ``copy.deepcopy`` protocols via ``__copy__`` and ``__deepcopy__``. * Harmonize coordinate alignment for operations with subset/superset objects: - Multiplication and division fill missing coords with 0 (variable doesn't participate) - Addition and subtraction of constants fill missing coords with 0 (identity element) and pin result to LHS coords From 4089a85226638caaee8974388cfcf7a351104ee8 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 20 Mar 2026 15:50:59 +0100 Subject: [PATCH 8/9] Made Model.copy defaulting to deep copy more explicit. --- linopy/io.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linopy/io.py b/linopy/io.py index fd3c536a..f083b685 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1249,6 +1249,9 @@ def copy(m: Model, include_solution: bool = False, deep: bool = True) -> Model: parameters, blocks, and scalar attributes are copied to a fully independent model. With ``deep=False``, returns a shallow copy. + :meth:`Model.copy` defaults to deep copy for workflow safety, while + Python's ``copy.copy(model)`` performs a shallow copy via ``__copy__``. + Solver runtime metadata (for example, ``solver_name`` and ``solver_model``) is intentionally not copied. Solver backend state is recreated on ``solve()``. From 0199d67406b6bb911eaf7d13c9e313e73b317a31 Mon Sep 17 00:00:00 2001 From: Bobby Xiong Date: Fri, 20 Mar 2026 16:00:15 +0100 Subject: [PATCH 9/9] Fine-tuned docs and added to read the docs api.rst. --- doc/api.rst | 1 + doc/release_notes.rst | 2 +- linopy/io.py | 16 ++++++++-------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 20958857..1554ce60 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -24,6 +24,7 @@ Creating a model piecewise.segments model.Model.linexpr model.Model.remove_constraints + model.Model.copy Classes under the hook diff --git a/doc/release_notes.rst b/doc/release_notes.rst index a138fbbb..26007b5c 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,7 +4,7 @@ Release Notes Upcoming Version ---------------- -* Add ``Model.copy()`` method with ``deep`` and ``include_solution`` options; support Python ``copy.copy`` and ``copy.deepcopy`` protocols via ``__copy__`` and ``__deepcopy__``. +* Add ``Model.copy()`` (default deep copy) with ``deep`` and ``include_solution`` options; support Python ``copy.copy`` and ``copy.deepcopy`` protocols via ``__copy__`` and ``__deepcopy__``. * Harmonize coordinate alignment for operations with subset/superset objects: - Multiplication and division fill missing coords with 0 (variable doesn't participate) - Addition and subtraction of constants fill missing coords with 0 (identity element) and pin result to LHS coords diff --git a/linopy/io.py b/linopy/io.py index f083b685..e7353b60 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -1249,8 +1249,9 @@ def copy(m: Model, include_solution: bool = False, deep: bool = True) -> Model: parameters, blocks, and scalar attributes are copied to a fully independent model. With ``deep=False``, returns a shallow copy. - :meth:`Model.copy` defaults to deep copy for workflow safety, while - Python's ``copy.copy(model)`` performs a shallow copy via ``__copy__``. + :meth:`Model.copy` defaults to deep copy for workflow safety. + In contrast, ``copy.copy(model)`` is shallow via ``__copy__``, and + ``copy.deepcopy(model)`` is deep via ``__deepcopy__``. Solver runtime metadata (for example, ``solver_name`` and ``solver_model``) is intentionally not copied. Solver backend state @@ -1261,12 +1262,11 @@ def copy(m: Model, include_solution: bool = False, deep: bool = True) -> Model: m : Model The model to copy. include_solution : bool, optional - Whether to include the current solution and dual values in the copy. - If False (default), the copy is returned in an initialized state: - solution and dual data are excluded, objective value is set to None, - and status is set to 'initialized'. If True, solution, dual values, - solve status, and objective value are also copied. If the model is - unsolved, this has no additional effect. + Whether to include solution and dual values in the copy. + If False (default), solve artifacts are excluded: solution/dual data, + objective value, and solve status are reset to initialized state. + If True, these values are copied when present. For unsolved models, + this has no additional effect. deep : bool, optional Whether to return a deep copy (default) or shallow copy. If False, the returned model uses independent wrapper objects that share