From 6a4a97b8fe25835fb316ae482cae61e66651f3de Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:31:19 +0200 Subject: [PATCH 1/5] fix(gurobi): dispose the solver model before the env on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the gurobipy model on the env ExitStack (as Xpress and Mosek already do), so Solver.close() disposes it before the env — the ordering Gurobi requires for the license to be released. Previously the model was only dereferenced, so any user-held model.solver_model reference silently kept the license acquired after close(). to_gurobipy() now detaches the built model from the throwaway solver so the caller keeps ownership. Document the release points in Model.solve(). Closes #459 follow-up. Co-Authored-By: Claude Fable 5 --- doc/release_notes.rst | 1 + linopy/io.py | 9 ++++++++- linopy/model.py | 10 ++++++++++ linopy/solvers.py | 13 +++++++++++-- test/test_solvers.py | 22 ++++++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index bffb2daa..cddbb836 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -39,6 +39,7 @@ Upcoming Version * Freezing an empty constraint group (e.g. an empty ``isel`` slice) no longer raises ``ValueError: cannot reshape array of size 0``. ``Model(freeze_constraints=True)`` and ``Constraint.freeze()`` now round-trip zero-row constraints losslessly. * ``Variable.where`` no longer raises ``ValueError: exact match required for all data variable names`` once a solution is attached (after ``Model.solve``) or the variable is fixed. The fill value now covers auxiliary data variables (``solution``, stashed bounds) instead of only ``labels``/``lower``/``upper``. * ``linopy.testing.assert_linequal`` now aligns dimension order before comparing, so mathematically identical expressions built in different orders (e.g. ``x + y`` versus ``y + x``, which inherit different dimension orders from xarray broadcasting) are correctly treated as equal. Genuinely different expressions still fail. +* ``Solver.close()`` (also triggered by ``model.solver = None`` and the next ``solve()`` call) now explicitly disposes the ``gurobipy`` model before the environment. Previously the model was only dereferenced, so a user-held ``model.solver_model`` reference silently kept the Gurobi license acquired after ``close()``. (https://github.com/PyPSA/linopy/issues/459) Version 0.8.0 ------------- diff --git a/linopy/io.py b/linopy/io.py index bccbdecf..fa91f912 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -757,7 +757,14 @@ def to_gurobipy( set_names=set_names, env=env, ) - return solver.solver_model + gm = solver.solver_model + # the caller owns the returned model: detach it (and its env) from the + # solver so teardown does not dispose it; gurobipy frees the underlying + # env once the caller drops the model + if solver._env_stack is not None: + solver._env_stack.pop_all() + solver.close() + return gm def to_highspy( diff --git a/linopy/model.py b/linopy/model.py index b1477b27..a4ae3221 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1819,6 +1819,16 @@ def solve( status : tuple Tuple containing the status and termination condition of the optimization process. + + Notes + ----- + After solving, the solver stays attached as ``model.solver`` for + post-solve introspection (``model.solver_model``, + ``compute_infeasibilities()``). For solvers with limited licenses + (e.g. Gurobi) this means the license remains acquired until the + solver is released: call ``model.solver.close()`` (or assign + ``model.solver = None``) to free it explicitly. It is also released + on the next ``solve()`` call and when the model is garbage-collected. """ if mock_solve: return self._mock_solve( diff --git a/linopy/solvers.py b/linopy/solvers.py index f7769dff..5c91860d 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1948,6 +1948,15 @@ def _resolve_env(self, env: gurobipy.Env | dict[str, Any] | None) -> gurobipy.En self.env = resolved return resolved + def _register_solver_model(self, m: gurobipy.Model) -> gurobipy.Model: + """ + Register the gurobipy model on the env stack so ``close()`` disposes + it before the env — required for the license to be released even while + user code still holds a ``solver_model`` reference. + """ + assert self._env_stack is not None + return self._env_stack.enter_context(m) + def _build_direct( self, explicit_coordinate_names: bool = False, @@ -1964,7 +1973,7 @@ def _build_direct( explicit_coordinate_names=explicit_coordinate_names, set_names=set_names, ) - self.solver_model = m + self.solver_model = self._register_solver_model(m) self.io_api = "direct" self.sense = model.sense self._cache_model_labels(model) @@ -2159,7 +2168,7 @@ def _run_file( env_ = self._resolve_env(env) m = gurobipy.read(problem_fn_, env=env_) - self.solver_model = m + self.solver_model = self._register_solver_model(m) self.io_api = io_api return self._solve( diff --git a/test/test_solvers.py b/test/test_solvers.py index 5af65a46..d899f7ae 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -214,6 +214,28 @@ def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: assert solver_instance.env is None +@pytest.mark.skipif( + "gurobi" not in set(solvers.licensed_solvers), reason="Gurobi is not installed" +) +@pytest.mark.parametrize("io_api", ["direct", "lp"]) +def test_gurobi_close_disposes_held_solver_model( + simple_model: Model, io_api: str +) -> None: + import gurobipy + + simple_model.solve("gurobi", io_api=io_api) + held = simple_model.solver_model + assert isinstance(held.NumVars, int) + + simple_model.solver = None + + # the gurobipy model must be disposed even while `held` keeps it alive: + # a merely dereferenced model keeps the underlying env — and with it the + # license — acquired until garbage collection + with pytest.raises(gurobipy.GurobiError, match="freed"): + _ = held.NumVars + + free_mps_problem = """NAME sample_mip ROWS N obj From 06d1c6078ad243eb1dbc59776be578a29d449c43 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:53:02 +0200 Subject: [PATCH 2/5] refactor: extract _detach_solver_model, trim inline comments Also note in Model.solve() that the attached solver enables persistent re-solves. Co-Authored-By: Claude Fable 5 --- linopy/io.py | 9 +-------- linopy/model.py | 11 ++++++----- linopy/solvers.py | 12 ++++++++++++ test/test_solvers.py | 8 +++++--- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/linopy/io.py b/linopy/io.py index fa91f912..0efc11ad 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -757,14 +757,7 @@ def to_gurobipy( set_names=set_names, env=env, ) - gm = solver.solver_model - # the caller owns the returned model: detach it (and its env) from the - # solver so teardown does not dispose it; gurobipy frees the underlying - # env once the caller drops the model - if solver._env_stack is not None: - solver._env_stack.pop_all() - solver.close() - return gm + return solver._detach_solver_model() def to_highspy( diff --git a/linopy/model.py b/linopy/model.py index a4ae3221..f0269ad7 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1824,11 +1824,12 @@ def solve( ----- After solving, the solver stays attached as ``model.solver`` for post-solve introspection (``model.solver_model``, - ``compute_infeasibilities()``). For solvers with limited licenses - (e.g. Gurobi) this means the license remains acquired until the - solver is released: call ``model.solver.close()`` (or assign - ``model.solver = None``) to free it explicitly. It is also released - on the next ``solve()`` call and when the model is garbage-collected. + ``compute_infeasibilities()``) and reuse, e.g. persistent in-place + re-solves. For solvers with limited licenses (e.g. Gurobi) this + means the license remains acquired until the solver is released: + call ``model.solver.close()`` (or assign ``model.solver = None``) + to free it explicitly. It is also released on the next ``solve()`` + call and when the model is garbage-collected. """ if mock_solve: return self._mock_solve( diff --git a/linopy/solvers.py b/linopy/solvers.py index 5c91860d..5be53008 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1957,6 +1957,18 @@ def _register_solver_model(self, m: gurobipy.Model) -> gurobipy.Model: assert self._env_stack is not None return self._env_stack.enter_context(m) + def _detach_solver_model(self) -> gurobipy.Model: + """ + Hand ownership of the gurobipy model to the caller: unregister it from + the solver's teardown so ``close()`` does not dispose it. gurobipy + frees the underlying env once the caller drops the model. + """ + m = self.solver_model + if self._env_stack is not None: + self._env_stack.pop_all() + self.close() + return m + def _build_direct( self, explicit_coordinate_names: bool = False, diff --git a/test/test_solvers.py b/test/test_solvers.py index d899f7ae..4cb19723 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -221,6 +221,11 @@ def test_solver_close_releases_state(simple_model: Model, solver: str) -> None: def test_gurobi_close_disposes_held_solver_model( simple_model: Model, io_api: str ) -> None: + """ + close() must dispose the gurobipy model even while a reference to it is + held: a merely dereferenced model keeps the underlying env — and with it + the license — acquired until garbage collection. + """ import gurobipy simple_model.solve("gurobi", io_api=io_api) @@ -229,9 +234,6 @@ def test_gurobi_close_disposes_held_solver_model( simple_model.solver = None - # the gurobipy model must be disposed even while `held` keeps it alive: - # a merely dereferenced model keeps the underlying env — and with it the - # license — acquired until garbage collection with pytest.raises(gurobipy.GurobiError, match="freed"): _ = held.NumVars From a75c46b8fabef6c1fa87291d561d4818494ec4ef Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:04:33 +0200 Subject: [PATCH 3/5] docs: docstring Solver.close(), list Solver base in API reference Co-Authored-By: Claude Fable 5 --- doc/api.rst | 1 + linopy/solvers.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/doc/api.rst b/doc/api.rst index 6fb3434f..76275fc0 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -522,6 +522,7 @@ Solvers :toctree: generated/ solvers.available_solvers + solvers.Solver solvers.CBC solvers.COPT solvers.Cplex diff --git a/linopy/solvers.py b/linopy/solvers.py index 5be53008..8e0a9db6 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1112,6 +1112,20 @@ def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError def close(self) -> None: + """ + Release the native solver state. + + Disposes the native solver model and any solver environment created + by this instance, releasing the resources tied to them — including + the solver license, where the solver holds one. A user-supplied + environment is left untouched. + + Idempotent, and called automatically when a new ``solve()`` replaces + this solver, when ``model.solver`` is reassigned (e.g. to ``None``), + and on garbage collection. After closing, post-solve introspection + (``solver_model``, ``compute_infeasibilities()``) and persistent + re-solves are no longer available. + """ if self._env_stack is not None: self._env_stack.close() self.env = None From 74ef85f8a607225187b4a7b4fb1aac19af9d9f4c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:18:27 +0200 Subject: [PATCH 4/5] fix(typing): Solver.from_model returns Self mypy rejected Gurobi._detach_solver_model on the Solver-typed return. Co-Authored-By: Claude Fable 5 --- linopy/solvers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 8e0a9db6..7bc9d922 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -25,7 +25,7 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as package_version from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Generic, NamedTuple, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, NamedTuple, Self, TypeVar import numpy as np import pandas as pd @@ -683,7 +683,7 @@ def from_model( options: dict[str, Any] | None = None, track_updates: bool = False, **build_kwargs: Any, - ) -> Solver: + ) -> Self: """Instantiate and build the solver against ``model``.""" instance = cls( model=model, From 9796537078449957459f79cce20251168c182907 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:25:52 +0200 Subject: [PATCH 5/5] docs: license release in close() summary line; drop Solver from api list The autosummary method tables on the subclass pages show only the first docstring line, so it must carry the license-release fact itself. Co-Authored-By: Claude Fable 5 --- doc/api.rst | 1 - linopy/solvers.py | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 76275fc0..6fb3434f 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -522,7 +522,6 @@ Solvers :toctree: generated/ solvers.available_solvers - solvers.Solver solvers.CBC solvers.COPT solvers.Cplex diff --git a/linopy/solvers.py b/linopy/solvers.py index 7bc9d922..867dc06f 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1113,12 +1113,10 @@ def update_solver_model(self, model: Model, **kwargs: Any) -> None: def close(self) -> None: """ - Release the native solver state. + Dispose the native solver model and env, releasing any held license. - Disposes the native solver model and any solver environment created - by this instance, releasing the resources tied to them — including - the solver license, where the solver holds one. A user-supplied - environment is left untouched. + Only resources created by this instance are disposed; a + user-supplied environment is left untouched. Idempotent, and called automatically when a new ``solve()`` replaces this solver, when ``model.solver`` is reassigned (e.g. to ``None``),