Skip to content

Commit e9e7870

Browse files
coroaclaude
andcommitted
Merge upstream/master and fix copy() for new Constraint types
- Merge feat: add m.copy() method (PyPSA#623) from upstream - Fix copy() to use MutableConstraint and .mutable() since the new Constraint class is CSR-backed and doesn't accept a Dataset constructor - Fix objective value type: float() cast to satisfy float | None annotation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 parents 83dd58a + 393da2f commit e9e7870

5 files changed

Lines changed: 303 additions & 2 deletions

File tree

doc/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Creating a model
2424
piecewise.segments
2525
model.Model.linexpr
2626
model.Model.remove_constraints
27+
model.Model.copy
2728

2829

2930
Classes under the hook

doc/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release Notes
44
Upcoming Version
55
----------------
66

7+
* Add ``Model.copy()`` (default deep copy) with ``deep`` and ``include_solution`` options; support Python ``copy.copy`` and ``copy.deepcopy`` protocols via ``__copy__`` and ``__deepcopy__``.
78
* Harmonize coordinate alignment for operations with subset/superset objects:
89
- Multiplication and division fill missing coords with 0 (variable doesn't participate)
910
- Addition and subtraction of constants fill missing coords with 0 (identity element) and pin result to LHS coords

linopy/io.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1244,3 +1244,123 @@ def get_prefix(ds: xr.Dataset, prefix: str) -> xr.Dataset:
12441244
setattr(m, k, ds.attrs.get(k))
12451245

12461246
return m
1247+
1248+
1249+
def copy(m: Model, include_solution: bool = False, deep: bool = True) -> Model:
1250+
"""
1251+
Return a copy of this model.
1252+
1253+
With ``deep=True`` (default), variables, constraints, objective,
1254+
parameters, blocks, and scalar attributes are copied to a fully
1255+
independent model. With ``deep=False``, returns a shallow copy.
1256+
1257+
:meth:`Model.copy` defaults to deep copy for workflow safety.
1258+
In contrast, ``copy.copy(model)`` is shallow via ``__copy__``, and
1259+
``copy.deepcopy(model)`` is deep via ``__deepcopy__``.
1260+
1261+
Solver runtime metadata (for example, ``solver_name`` and
1262+
``solver_model``) is intentionally not copied. Solver backend state
1263+
is recreated on ``solve()``.
1264+
1265+
Parameters
1266+
----------
1267+
m : Model
1268+
The model to copy.
1269+
include_solution : bool, optional
1270+
Whether to include solution and dual values in the copy.
1271+
If False (default), solve artifacts are excluded: solution/dual data,
1272+
objective value, and solve status are reset to initialized state.
1273+
If True, these values are copied when present. For unsolved models,
1274+
this has no additional effect.
1275+
deep : bool, optional
1276+
Whether to return a deep copy (default) or shallow copy. If False,
1277+
the returned model uses independent wrapper objects that share
1278+
underlying data buffers with the source model.
1279+
1280+
Returns
1281+
-------
1282+
Model
1283+
A deep or shallow copy of the model.
1284+
"""
1285+
from linopy.constraints import Constraints, MutableConstraint
1286+
from linopy.expressions import LinearExpression
1287+
from linopy.model import Model, Objective
1288+
from linopy.variables import Variable, Variables
1289+
1290+
SOLVE_STATE_ATTRS = {"status", "termination_condition"}
1291+
1292+
new_model = Model(
1293+
chunk=m._chunk,
1294+
force_dim_names=m._force_dim_names,
1295+
auto_mask=m._auto_mask,
1296+
solver_dir=str(m._solver_dir),
1297+
)
1298+
1299+
new_model._variables = Variables(
1300+
{
1301+
name: Variable(
1302+
var.data.copy(deep=deep)
1303+
if include_solution
1304+
else var.data[m.variables.dataset_attrs].copy(deep=deep),
1305+
new_model,
1306+
name,
1307+
)
1308+
for name, var in m.variables.items()
1309+
},
1310+
new_model,
1311+
)
1312+
1313+
new_model._constraints = Constraints(
1314+
{
1315+
name: MutableConstraint(
1316+
con.mutable().data.copy(deep=deep)
1317+
if include_solution
1318+
else con.mutable().data[m.constraints.dataset_attrs].copy(deep=deep),
1319+
new_model,
1320+
name,
1321+
)
1322+
for name, con in m.constraints.items()
1323+
},
1324+
new_model,
1325+
)
1326+
1327+
obj_expr = LinearExpression(m.objective.expression.data.copy(deep=deep), new_model)
1328+
new_model._objective = Objective(obj_expr, new_model, m.objective.sense)
1329+
new_model._objective._value = (
1330+
float(m.objective.value)
1331+
if (include_solution and m.objective.value is not None)
1332+
else None
1333+
)
1334+
1335+
new_model._parameters = m._parameters.copy(deep=deep)
1336+
new_model._blocks = m._blocks.copy(deep=deep) if m._blocks is not None else None
1337+
1338+
for attr in m.scalar_attrs:
1339+
if include_solution or attr not in SOLVE_STATE_ATTRS:
1340+
setattr(new_model, attr, getattr(m, attr))
1341+
1342+
return new_model
1343+
1344+
1345+
def shallowcopy(m: Model) -> Model:
1346+
"""
1347+
Support Python's ``copy.copy`` protocol for ``Model``.
1348+
1349+
Returns a shallow copy with independent wrapper objects that share
1350+
underlying array buffers with ``m``. Solve artifacts are excluded,
1351+
matching :meth:`Model.copy` defaults.
1352+
"""
1353+
return copy(m, include_solution=False, deep=False)
1354+
1355+
1356+
def deepcopy(m: Model, memo: dict[int, Any]) -> Model:
1357+
"""
1358+
Support Python's ``copy.deepcopy`` protocol for ``Model``.
1359+
1360+
Returns a deep, structurally independent copy and records it in ``memo``
1361+
as required by Python's copy protocol. Solve artifacts are excluded,
1362+
matching :meth:`Model.copy` defaults.
1363+
"""
1364+
new_model = copy(m, include_solution=False, deep=True)
1365+
memo[id(m)] = new_model
1366+
return new_model

linopy/model.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@
6060
ScalarLinearExpression,
6161
)
6262
from linopy.io import (
63+
copy,
64+
deepcopy,
65+
shallowcopy,
6366
to_block_files,
6467
to_cupdlpx,
6568
to_file,
@@ -1920,6 +1923,12 @@ def reset_solution(self) -> None:
19201923
self.variables.reset_solution()
19211924
self.constraints.reset_dual()
19221925

1926+
copy = copy
1927+
1928+
__copy__ = shallowcopy
1929+
1930+
__deepcopy__ = deepcopy
1931+
19231932
to_netcdf = to_netcdf
19241933

19251934
to_file = to_file

test/test_model.py

Lines changed: 172 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,21 @@
55

66
from __future__ import annotations
77

8+
import copy as pycopy
89
from pathlib import Path
910
from tempfile import gettempdir
1011

1112
import numpy as np
1213
import pytest
1314
import xarray as xr
1415

15-
from linopy import EQUAL, Model
16-
from linopy.testing import assert_model_equal
16+
from linopy import EQUAL, Model, available_solvers
17+
from linopy.testing import (
18+
assert_conequal,
19+
assert_equal,
20+
assert_linequal,
21+
assert_model_equal,
22+
)
1723

1824
target_shape: tuple[int, int] = (10, 10)
1925

@@ -164,3 +170,167 @@ def test_assert_model_equal() -> None:
164170
m.add_objective(obj)
165171

166172
assert_model_equal(m, m)
173+
174+
175+
@pytest.fixture(scope="module")
176+
def copy_test_model() -> Model:
177+
"""Small representative model used across copy tests."""
178+
m: Model = Model()
179+
180+
lower: xr.DataArray = xr.DataArray(
181+
np.zeros((10, 10)), coords=[range(10), range(10)]
182+
)
183+
upper: xr.DataArray = xr.DataArray(np.ones((10, 10)), coords=[range(10), range(10)])
184+
x = m.add_variables(lower, upper, name="x")
185+
y = m.add_variables(name="y")
186+
187+
m.add_constraints(1 * x + 10 * y, EQUAL, 0)
188+
m.add_objective((10 * x + 5 * y).sum())
189+
190+
return m
191+
192+
193+
@pytest.fixture(scope="module")
194+
def solved_copy_test_model(copy_test_model: Model) -> Model:
195+
"""Solved representative model used across solved-copy tests."""
196+
m = copy_test_model.copy(deep=True)
197+
m.solve()
198+
return m
199+
200+
201+
def test_model_copy_unsolved(copy_test_model: Model) -> None:
202+
"""Copy of unsolved model is structurally equal and independent."""
203+
m = copy_test_model.copy(deep=True)
204+
c = m.copy(include_solution=False)
205+
206+
assert_model_equal(m, c)
207+
208+
# independence: mutating copy does not affect source
209+
c.add_variables(name="z")
210+
assert "z" not in m.variables
211+
212+
213+
def test_model_copy_unsolved_with_solution_flag(copy_test_model: Model) -> None:
214+
"""Unsolved model with include_solution=True has no extra solve artifacts."""
215+
m = copy_test_model.copy(deep=True)
216+
217+
c_include_solution = m.copy(include_solution=True)
218+
c_exclude_solution = m.copy(include_solution=False)
219+
220+
assert_model_equal(c_include_solution, c_exclude_solution)
221+
assert c_include_solution.status == "initialized"
222+
assert c_include_solution.termination_condition == ""
223+
assert c_include_solution.objective.value is None
224+
225+
226+
def test_model_copy_shallow(copy_test_model: Model) -> None:
227+
"""Shallow copy has independent wrappers sharing underlying data buffers."""
228+
m = copy_test_model.copy(deep=True)
229+
c = m.copy(deep=False)
230+
231+
assert c is not m
232+
assert c.variables is not m.variables
233+
assert c.constraints is not m.constraints
234+
assert c.objective is not m.objective
235+
236+
# wrappers are distinct, but shallow copy shares payload buffers
237+
c.variables["x"].lower.values[0, 0] = 123.0
238+
assert m.variables["x"].lower.values[0, 0] == 123.0
239+
240+
241+
def test_model_deepcopy_protocol(copy_test_model: Model) -> None:
242+
"""copy.deepcopy(model) dispatches to Model.__deepcopy__ and stays independent."""
243+
m = copy_test_model.copy(deep=True)
244+
c = pycopy.deepcopy(m)
245+
246+
assert_model_equal(m, c)
247+
248+
# Test independence: mutations to copy do not affect source
249+
# 1. Variable mutation: add new variable
250+
c.add_variables(name="z")
251+
assert "z" not in m.variables
252+
253+
# 2. Variable data mutation (bounds): verify buffers are independent
254+
original_lower = m.variables["x"].lower.values[0, 0].item()
255+
new_lower = 999
256+
c.variables["x"].lower.values[0, 0] = new_lower
257+
assert c.variables["x"].lower.values[0, 0] == new_lower
258+
assert m.variables["x"].lower.values[0, 0] == original_lower
259+
260+
# 3. Constraint coefficient mutation: deep copy must not leak back
261+
original_con_coeff = m.constraints["con0"].coeffs.values.flat[0].item()
262+
new_con_coeff = original_con_coeff + 42
263+
c.constraints["con0"].coeffs.values.flat[0] = new_con_coeff
264+
assert c.constraints["con0"].coeffs.values.flat[0] == new_con_coeff
265+
assert m.constraints["con0"].coeffs.values.flat[0] == original_con_coeff
266+
267+
# 4. Objective expression coefficient mutation: deep copy must not leak back
268+
original_obj_coeff = m.objective.expression.coeffs.values.flat[0].item()
269+
new_obj_coeff = original_obj_coeff + 20
270+
c.objective.expression.coeffs.values.flat[0] = new_obj_coeff
271+
assert c.objective.expression.coeffs.values.flat[0] == new_obj_coeff
272+
assert m.objective.expression.coeffs.values.flat[0] == original_obj_coeff
273+
274+
# 5. Objective sense mutation
275+
original_sense = m.objective.sense
276+
c.objective.sense = "max"
277+
assert c.objective.sense == "max"
278+
assert m.objective.sense == original_sense
279+
280+
281+
@pytest.mark.skipif(not available_solvers, reason="No solver installed")
282+
class TestModelCopySolved:
283+
def test_model_deepcopy_protocol_excludes_solution(
284+
self, solved_copy_test_model: Model
285+
) -> None:
286+
"""copy.deepcopy on solved model drops solve state by default."""
287+
m = solved_copy_test_model
288+
289+
c = pycopy.deepcopy(m)
290+
291+
assert c.status == "initialized"
292+
assert c.termination_condition == ""
293+
assert c.objective.value is None
294+
295+
for v in m.variables:
296+
assert_equal(
297+
c.variables[v].data[c.variables.dataset_attrs],
298+
m.variables[v].data[m.variables.dataset_attrs],
299+
)
300+
for con in m.constraints:
301+
assert_conequal(c.constraints[con], m.constraints[con], strict=False)
302+
assert_linequal(c.objective.expression, m.objective.expression)
303+
assert c.objective.sense == m.objective.sense
304+
305+
def test_model_copy_solved_with_solution(
306+
self, solved_copy_test_model: Model
307+
) -> None:
308+
"""Copy with include_solution=True preserves solve state."""
309+
m = solved_copy_test_model
310+
311+
c = m.copy(include_solution=True)
312+
assert_model_equal(m, c)
313+
314+
def test_model_copy_solved_without_solution(
315+
self, solved_copy_test_model: Model
316+
) -> None:
317+
"""Copy with include_solution=False (default) drops solve state but preserves problem structure."""
318+
m = solved_copy_test_model
319+
320+
c = m.copy(include_solution=False)
321+
322+
# solve state is dropped
323+
assert c.status == "initialized"
324+
assert c.termination_condition == ""
325+
assert c.objective.value is None
326+
327+
# problem structure is preserved — compare only dataset_attrs to exclude solution/dual
328+
for v in m.variables:
329+
assert_equal(
330+
c.variables[v].data[c.variables.dataset_attrs],
331+
m.variables[v].data[m.variables.dataset_attrs],
332+
)
333+
for con in m.constraints:
334+
assert_conequal(c.constraints[con], m.constraints[con], strict=False)
335+
assert_linequal(c.objective.expression, m.objective.expression)
336+
assert c.objective.sense == m.objective.sense

0 commit comments

Comments
 (0)