diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 80f9076e..2727ebfc 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -29,6 +29,7 @@ Upcoming Version **Bug fixes** +* Fix GLPK objective parsing. (https://github.com/PyPSA/linopy/pull/818) * LP file export now honors bounds tightened below ``[0, 1]`` on a binary variable via the ``.lower``/``.upper`` setters after creation (e.g. ``upper = 0``). Previously such bounds were written only by ``io_api="direct"`` and dropped by ``io_api="lp"``. (https://github.com/PyPSA/linopy/issues/776) * 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``. diff --git a/linopy/solvers.py b/linopy/solvers.py index 987c7b70..f7769dff 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1382,6 +1382,26 @@ class GLPK(Solver[None]): } ) + _OBJECTIVE_TOKEN: ClassVar[re.Pattern[str]] = re.compile( + r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?" + ) + + @classmethod + def _parse_objective(cls, text: str) -> float: + """ + Extract the objective value from a GLPK ``Objective:`` line. + + GLPK reports the objective as `` = (MINimum)``. Anchoring + on the ``=`` drops the objective name before matching the first + float/scientific-notation token, so surrounding text can no longer + corrupt the parsed value. + """ + _, _, tail = text.rpartition("=") + match = cls._OBJECTIVE_TOKEN.search(tail) + if match is None: + raise ValueError(f"Could not parse objective value: {text!r}") + return float(match.group(0)) + @classmethod @functools.cache def is_available(cls) -> bool: @@ -1472,7 +1492,7 @@ def read_until_break(f: io.TextIOWrapper) -> Generator[str, None, None]: info_io = io.StringIO("".join(read_until_break(f))[:-2]) info = pd.read_csv(info_io, sep=":", index_col=0, header=None)[1] condition = info.Status.lower().strip() - objective = float(re.sub(r"[^0-9\.\+\-e]+", "", info.Objective)) + objective = self._parse_objective(info.Objective) termination_condition = CONDITION_MAP.get(condition, condition) status = Status.from_termination_condition(termination_condition) diff --git a/test/test_solvers.py b/test/test_solvers.py index 3c927245..5af65a46 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -570,6 +570,41 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: assert m.solver is None +@pytest.mark.parametrize( + "objective_text, expected", + [ + (" obj = 1234.56 (MINimum)", 1234.56), + (" obj = 0 (MINimum)", 0.0), + (" obj = -3.5 (MAXimum)", -3.5), + (" obj = +42 (MINimum)", 42.0), + (" obj = .5 (MINimum)", 0.5), + (" obj = 1.5e+06 (MAXimum)", 1.5e6), + (" obj = -2E-3 (MINimum)", -2e-3), + (" net_present_value = 1234.5 (MINimum)", 1234.5), + (" obj1 = 1234.56 (MINimum)", 1234.56), + (" c2e = -7.5e2 (MAXimum)", -750.0), + (" 3.5 (MINimum)", 3.5), + (" -1.5e3 (MAXimum)", -1500.0), + (" 0 (MINimum)", 0.0), + ], +) +def test_parse_glpk_objective(objective_text: str, expected: float) -> None: + assert solvers.GLPK._parse_objective(objective_text) == pytest.approx(expected) + + +@pytest.mark.parametrize( + "objective_text", + [ + " obj = (MINimum)", + " unbounded", + "", + ], +) +def test_parse_glpk_objective_no_value_raises(objective_text: str) -> None: + with pytest.raises(ValueError, match="Could not parse objective value"): + solvers.GLPK._parse_objective(objective_text) + + mosek_installed = pytest.importorskip("mosek", reason="Mosek is not installed")