diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 1ee7166f..a74ec6eb 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -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: @@ -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 `__) Version 0.9.1 ------------- diff --git a/linopy/solvers.py b/linopy/solvers.py index 3b8f2fed..59b82270 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -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: @@ -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]): diff --git a/test/test_solvers.py b/test/test_solvers.py index 3522d8be..eb8c5ffe 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -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)