Skip to content
Open
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
3 changes: 1 addition & 2 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ Release Notes
Upcoming Version
----------------


*Strict "v1" arithmetic semantics (opt-in)*

* A new, stricter convention for how linopy arithmetic aligns coordinates and treats missing data is available behind ``linopy.options["semantics"] = "v1"``. Legacy behaviour remains the **default** in this release; v1 is opt-in. In short, under v1:
Expand Down Expand Up @@ -54,7 +53,7 @@ Upcoming Version
**Bug fixes**

* A multi-key ``groupby`` now returns its groups sorted by key tuple, like the single-key path. The key combinations were numbered by iterating a ``set``, so the group order was arbitrary and changed between processes with ``PYTHONHASHSEED``.

* ``Solver.close()`` now drops the native solver model before closing the environment that owns it. The reverse order left the model pointing at freed memory, so collecting it later crashed the interpreter, typically during an unrelated garbage collection pass. This affected HiGHS, SCIP, COPT and MindOpt; solvers registering their model on the environment's ``ExitStack`` (Gurobi, Xpress, Mosek) were already safe. COPT additionally kept its environment alive, so ``model.solver_model`` stays usable after ``model.solve("copt")``. (`#899 <https://github.com/PyPSA/linopy/pull/899>`__)

Version 0.9.1
-------------
Expand Down
105 changes: 52 additions & 53 deletions linopy/solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1124,10 +1124,10 @@ def close(self) -> None:
(``solver_model``, ``compute_infeasibilities()``) and persistent
re-solves are no longer available.
"""
self.solver_model = None
if self._env_stack is not None:
self._env_stack.close()
self.env = None
self.solver_model = None
self._env_stack = None

def __del__(self) -> None:
Expand Down Expand Up @@ -3933,74 +3933,73 @@ def _run_file(
io_api = read_io_api_from_problem_file(problem_fn)
sense = read_sense_from_problem_file(problem_fn)

if env is None:
env_ = coptpy.Envr()
self.close()
self._env_stack = contextlib.ExitStack()
env_ = coptpy.Envr()
self._env_stack.callback(env_.close)

try:
m = env_.createModel()
m = env_.createModel()

m.read(path_to_string(problem_fn))
m.read(path_to_string(problem_fn))

if log_fn is not None:
m.setLogFile(path_to_string(log_fn))
if log_fn is not None:
m.setLogFile(path_to_string(log_fn))

for k, v in self.solver_options.items():
m.setParam(k, v)
for k, v in self.solver_options.items():
m.setParam(k, v)

if warmstart_fn is not None:
m.readBasis(path_to_string(warmstart_fn))
if warmstart_fn is not None:
m.readBasis(path_to_string(warmstart_fn))

m.solve()
m.solve()

if basis_fn and m.HasBasis:
try:
m.write(path_to_string(basis_fn))
except coptpy.CoptError as err:
logger.warning("No model basis stored. Raised error: %s", err)
if basis_fn and m.HasBasis:
try:
m.write(path_to_string(basis_fn))
except coptpy.CoptError as err:
logger.warning("No model basis stored. Raised error: %s", err)

if solution_fn:
try:
m.write(path_to_string(solution_fn))
except coptpy.CoptError as err:
logger.warning("No model solution stored. Raised error: %s", err)
if solution_fn:
try:
m.write(path_to_string(solution_fn))
except coptpy.CoptError as err:
logger.warning("No model solution stored. Raised error: %s", err)

# TODO: check if this suffices
condition = m.MipStatus if m.ismip else m.LpStatus
termination_condition = CONDITION_MAP.get(condition, str(condition))
status = Status.from_termination_condition(termination_condition)
status.legacy_status = str(condition)

def get_solver_solution() -> Solution:
# TODO: check if this suffices
condition = m.MipStatus if m.ismip else m.LpStatus
termination_condition = CONDITION_MAP.get(condition, str(condition))
status = Status.from_termination_condition(termination_condition)
status.legacy_status = str(condition)
objective = m.BestObj if m.ismip else m.LpObjVal

def get_solver_solution() -> Solution:
# TODO: check if this suffices
objective = m.BestObj if m.ismip else m.LpObjVal
vars_ = m.getVars()
sol = _solution_from_names(
np.array([v.x for v in vars_], dtype=float),
[v.name for v in vars_],
self._n_vars,
)

vars_ = m.getVars()
sol = _solution_from_names(
np.array([v.x for v in vars_], dtype=float),
[v.name for v in vars_],
self._n_vars,
try:
cons = m.getConstrs()
dual = _solution_from_names(
np.array([c.pi for c in cons], dtype=float),
[c.name for c in cons],
self._n_cons,
)
except (coptpy.CoptError, AttributeError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = np.array([], dtype=float)

try:
cons = m.getConstrs()
dual = _solution_from_names(
np.array([c.pi for c in cons], dtype=float),
[c.name for c in cons],
self._n_cons,
)
except (coptpy.CoptError, AttributeError):
logger.warning("Dual values of MILP couldn't be parsed")
dual = np.array([], dtype=float)

return Solution(sol, dual, objective)
return Solution(sol, dual, objective)

solution = self.safe_get_solution(status=status, func=get_solver_solution)
solution = maybe_adjust_objective_sign(solution, io_api, sense)
solution = self.safe_get_solution(status=status, func=get_solver_solution)
solution = maybe_adjust_objective_sign(solution, io_api, sense)

self.io_api = io_api
return self._make_result(status, solution, solver_model=m)
finally:
env_.close()
self.io_api = io_api
return self._make_result(status, solution, solver_model=m)


class MindOpt(Solver[None]):
Expand Down
9 changes: 9 additions & 0 deletions test/test_solvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ def test_gurobi_env_persists_after_solve(simple_model: Model) -> None:
assert isinstance(simple_model.solver_model.NumVars, int)


@pytest.mark.skipif(
"copt" not in set(solvers.licensed_solvers), reason="COPT is not installed"
)
def test_copt_env_persists_after_solve(simple_model: Model) -> None:
simple_model.solve("copt")
assert simple_model.solver is not None
assert isinstance(simple_model.solver_model.getVars(), list)


@pytest.mark.parametrize("solver", sorted(set(solvers.licensed_solvers)))
def test_solver_close_releases_state(simple_model: Model, solver: str) -> None:
simple_model.solve(solver)
Expand Down
Loading