Skip to content

feat: add m.copy() method to create deep copy of model#623

Open
bobbyxng wants to merge 9 commits intomasterfrom
copy
Open

feat: add m.copy() method to create deep copy of model#623
bobbyxng wants to merge 9 commits intomasterfrom
copy

Conversation

@bobbyxng
Copy link

@bobbyxng bobbyxng commented Mar 17, 2026

Changes proposed in this Pull Request

Adds Model.copy(), a method to create a deep copy of a linopy model.

  • Independent copy: Modifying one does not affect the other
  • By default (include_solution=False) the copy contains only the problem structure (variables, constraints, objective expression, parameters, blocks, and counters), and is returned in an initialized state.
  • Setting include_solution=True additionally copies solution and dual values, solve status, and objective value.
  • The respective tests unsolved vs unsolved, solved vs solved, unsolved vs solved where added to testing/test_model.py

The motivation behind this was an automated LP dualiser (following in a future PR) which requires building on the primal model to build the dual (without modifying the original primal).

Checklist

  • Code changes are sufficiently documented; i.e. new functions contain docstrings and further explanations may be given in doc.
  • Unit tests for new features were added (if applicable).
  • A note for the release notes doc/release_notes.rst of the upcoming release is included.
  • I consent to the release of this PR's code under the MIT license.

@bobbyxng bobbyxng requested a review from FabianHofmann March 17, 2026 12:47
@bobbyxng bobbyxng self-assigned this Mar 17, 2026
@bobbyxng bobbyxng added the enhancement New feature or request label Mar 17, 2026
@bobbyxng
Copy link
Author

Added a bug fix, xarray copies need to be done with deep=True (see https://docs.xarray.dev/en/latest/generated/xarray.DataArray.copy.html)

If deep=True, a deep copy is made of the data array. Otherwise, a shallow copy is made, and the returned data array’s values are a new view of this data array’s values.

Copy link

@brynpickering brynpickering left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bobbyxng couple of test suite simplifications suggested + an open question on naming / harmonising with Python convention for copy methods.

I would also add tests for the "deep" part of your copy, i.e. you should update some data in c in the variables, constraints, and expressions, then assert that they haven't changed in m.

You're also missing solver_name and solver_model from the copied Model object. solver_model isn't an easy one to copy consistently (e.g. the gurobi model has a copy method, but the highs model doesn't) and I guess there's no benefit to copying it across as the next solve would delete and recreate it anyway?

linopy/io.py Outdated
return m


def copy(src: Model, include_solution: bool = False) -> Model:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be called deep_copy / deepcopy to align with Python convention that copy refers to a shallow copy?

Or, you provide a deep arg, returning copy.copy(self) if they set deep=False.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should also map your deep copy function to __deepcopy__ so that copy.deepcopy(m) will return the same result. It allows other classes which have a linopy model as an attribute to get a deepcopy of that attribute back on request.

See the xarray dataset copy methods for an example.

Copy link
Author

@bobbyxng bobbyxng Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I prefer going copy() with the deep arg, which is what I have now implemented. Currently Model.copy(deep: bool=True) is the main API, deep copy by default, shallow copy when deep=False

  • __copy__ is mapped to conventional shallow copy behaviour
  • __deepcopy__ is mapped to conventional deep copy behaviour. So python's copy.deepcopy(m) returns the same kind of result as m.copy(deep=True) (default).
  • A potential confusion might be that m.copy() by default has deep=True, but personally I believe this addresses more common workflows, where you'd want a copy of a linopy model to be independent. Open for discussion thouh @brynpickering @FabianHofmann

@bobbyxng
Copy link
Author

Thanks @brynpickering, this was very helpful feedback, I learned a lot in this process. I have implemented your suggestions and aligned copy behaviour with python's copy/deepcopy conventions.

  • with Model.copy(deep=True) as the proposed primary linopy API and deep=False for shallow behaviour
  • include_solution is an additional arg to allow the user to decide whether the solution is to be copied, independent of shallow/deep copy choice
  • mapped to python copy's copy.copy(m) (shallow copy, deep=False) and copy.deepcopy(m) (deep copy, deep=True)

I also extended the tests, including more detiled deep copy coverage to mutate data in the copied models across all objects (vars, constraints, objective), also grouped solved copy testing under a dedicated class with a single skip marker.

Regarding the solver meta data:

  • I agree that solver_model should not be copied for exactly the reasons you mentioned, i.e. depending on the chosen solver/semantics and as it is reinstantiated anyway on m.solve()
  • solver_name also becomes meaningless on a copy, as it needs to be explicitly passed to m.solve() anyway

@bobbyxng bobbyxng requested a review from brynpickering March 20, 2026 14:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants