Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
2 changes: 1 addition & 1 deletion linopy/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
41 changes: 37 additions & 4 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions test/test_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading