diff --git a/doc/release_notes.rst b/doc/release_notes.rst index dac57907..e694a5c4 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -6,6 +6,8 @@ Upcoming Version **Bug Fixes** +* Fix the parsing of solutions returned by the CBC solver when setting a MIP duality + gap tolerance. * Improve the mapping of termination conditions for the SCIP solver Version 0.5.3 diff --git a/linopy/solvers.py b/linopy/solvers.py index 5ea3ffb4..2fb7cb8e 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -14,6 +14,7 @@ import subprocess as sub import sys from abc import ABC, abstractmethod +from collections import namedtuple from collections.abc import Generator from pathlib import Path from typing import TYPE_CHECKING, Any, Callable @@ -438,20 +439,20 @@ def solve_problem_from_file( p.stdout.close() p.wait() else: - log_f = open(log_fn, "w") - p = sub.Popen(command.split(" "), stdout=log_f, stderr=log_f) - p.wait() + with open(log_fn, "w") as log_f: + p = sub.Popen(command.split(" "), stdout=log_f, stderr=log_f) + p.wait() with open(solution_fn) as f: - data = f.readline() + first_line = f.readline() - if data.startswith("Optimal - objective value"): + if first_line.startswith("Optimal "): status = Status.from_termination_condition("optimal") - elif "Infeasible" in data: + elif "Infeasible" in first_line: status = Status.from_termination_condition("infeasible") else: status = Status(SolverStatus.warning, TerminationCondition.unknown) - status.legacy_status = data + status.legacy_status = first_line # Use HiGHS to parse the problem file and find the set of variable names, needed to parse solution h = highspy.Highs() @@ -459,7 +460,11 @@ def solve_problem_from_file( variables = {v.name for v in h.getVariables()} def get_solver_solution() -> Solution: - objective = float(data[len("Optimal - objective value ") :]) + m = re.match(r"Optimal.* - objective value (\d+\.?\d*)$", first_line) + if m and len(m.groups()) == 1: + objective = float(m.group(1)) + else: + objective = np.nan with open(solution_fn, "rb") as f: trimmed_sol_fn = re.sub(rb"\*\*\s+", b"", f.read()) @@ -482,7 +487,20 @@ def get_solver_solution() -> Solution: solution = self.safe_get_solution(status=status, func=get_solver_solution) solution = maybe_adjust_objective_sign(solution, io_api, sense) - return Result(status, solution) + # Parse the output and get duality gap and solver runtime + mip_gap, runtime = None, None + if log_fn is not None: + with open(log_fn) as log_f: + output = "".join(log_f.readlines()) + m = re.search(r"\nGap: +(\d+\.?\d*)\n", output) + if m and len(m.groups()) == 1: + mip_gap = float(m.group(1)) + m = re.search(r"\nTime \(Wallclock seconds\): +(\d+\.?\d*)\n", output) + if m and len(m.groups()) == 1: + runtime = float(m.group(1)) + CbcModel = namedtuple("CbcModel", ["mip_gap", "runtime"]) + + return Result(status, solution, CbcModel(mip_gap, runtime)) class GLPK(Solver):