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..0efc11ad 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -757,7 +757,7 @@ def to_gurobipy( set_names=set_names, env=env, ) - return solver.solver_model + return solver._detach_solver_model() def to_highspy( diff --git a/linopy/model.py b/linopy/model.py index b1477b27..f0269ad7 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -1819,6 +1819,17 @@ 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()``) 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 f7769dff..867dc06f 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, @@ -1112,6 +1112,18 @@ def update_solver_model(self, model: Model, **kwargs: Any) -> None: raise NotImplementedError def close(self) -> None: + """ + Dispose the native solver model and env, releasing any held license. + + 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``), + 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 @@ -1948,6 +1960,27 @@ 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 _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, @@ -1964,7 +1997,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 +2192,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..4cb19723 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -214,6 +214,30 @@ 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: + """ + 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) + held = simple_model.solver_model + assert isinstance(held.NumVars, int) + + simple_model.solver = None + + with pytest.raises(gurobipy.GurobiError, match="freed"): + _ = held.NumVars + + free_mps_problem = """NAME sample_mip ROWS N obj