From 1b0db51749f63d3420863c01d779ea934ef022e2 Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Mon, 6 Jul 2026 17:31:46 +0200 Subject: [PATCH 1/6] Fix GLPK objective parsing --- linopy/solvers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 987c7b70..b4d3ceec 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1472,7 +1472,13 @@ 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)) + match = re.search( + r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?", + info.Objective, + ) + if match is None: + raise ValueError(f"Could not parse objective value: {info.Objective!r}") + objective = float(match.group(0)) termination_condition = CONDITION_MAP.get(condition, condition) status = Status.from_termination_condition(termination_condition) From a56b366cfc8f79284d8730577660ce9fd8d92137 Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Mon, 6 Jul 2026 17:40:09 +0200 Subject: [PATCH 2/6] Add release note --- doc/release_notes.rst | 1 + 1 file changed, 1 insertion(+) 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``. From 002acddde2396b4c0c1a27747dfa0684832c86a1 Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Tue, 7 Jul 2026 10:56:06 +0200 Subject: [PATCH 3/6] Refactor GLPK objective parsing into helper method --- linopy/solvers.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index b4d3ceec..cfe59c94 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1367,6 +1367,27 @@ def get_solver_solution() -> Solution: class GLPK(Solver[None]): """ Solver subclass for the GLPK solver. + """ + + _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)) Attributes ---------- @@ -1472,13 +1493,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() - match = re.search( - r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?", - info.Objective, - ) - if match is None: - raise ValueError(f"Could not parse objective value: {info.Objective!r}") - objective = float(match.group(0)) + objective = self._parse_objective(info.Objective) termination_condition = CONDITION_MAP.get(condition, condition) status = Status.from_termination_condition(termination_condition) From f619938ba94cda6cc2982433a27e48abe60b29be Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Tue, 7 Jul 2026 11:23:09 +0200 Subject: [PATCH 4/6] Add GLPK objective parser tests --- test/test_solvers.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/test_solvers.py b/test/test_solvers.py index 3c927245..4adcf3ba 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -570,6 +570,42 @@ 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") From 05db4a932eac997410a690e26036adb97b07c418 Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Tue, 7 Jul 2026 12:55:27 +0200 Subject: [PATCH 5/6] Keep GLPK class docstring intact --- linopy/solvers.py | 27 +++++++++++++-------------- test/test_solvers.py | 1 - 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index cfe59c94..4b2af680 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1367,12 +1367,25 @@ def get_solver_solution() -> Solution: class GLPK(Solver[None]): """ Solver subclass for the GLPK solver. + + Attributes + ---------- + **solver_options + options for the given solver """ _OBJECTIVE_TOKEN: ClassVar[re.Pattern[str]] = re.compile( r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?" ) + display_name: ClassVar[str] = "GLPK" + features: ClassVar[frozenset[SolverFeature]] = frozenset( + { + SolverFeature.INTEGER_VARIABLES, + SolverFeature.READ_MODEL_FROM_FILE, + } + ) + @classmethod def _parse_objective(cls, text: str) -> float: """ @@ -1389,20 +1402,6 @@ def _parse_objective(cls, text: str) -> float: raise ValueError(f"Could not parse objective value: {text!r}") return float(match.group(0)) - Attributes - ---------- - **solver_options - options for the given solver - """ - - display_name: ClassVar[str] = "GLPK" - features: ClassVar[frozenset[SolverFeature]] = frozenset( - { - SolverFeature.INTEGER_VARIABLES, - SolverFeature.READ_MODEL_FROM_FILE, - } - ) - @classmethod @functools.cache def is_available(cls) -> bool: diff --git a/test/test_solvers.py b/test/test_solvers.py index 4adcf3ba..5af65a46 100644 --- a/test/test_solvers.py +++ b/test/test_solvers.py @@ -570,7 +570,6 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None: assert m.solver is None - @pytest.mark.parametrize( "objective_text, expected", [ From 8d7f0d44a683de742414b65ca352838be14235db Mon Sep 17 00:00:00 2001 From: Daniele Lerede Date: Tue, 7 Jul 2026 13:11:00 +0200 Subject: [PATCH 6/6] Move GLPK objective token below public attributes --- linopy/solvers.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/linopy/solvers.py b/linopy/solvers.py index 4b2af680..f7769dff 100644 --- a/linopy/solvers.py +++ b/linopy/solvers.py @@ -1374,10 +1374,6 @@ class GLPK(Solver[None]): options for the given solver """ - _OBJECTIVE_TOKEN: ClassVar[re.Pattern[str]] = re.compile( - r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?" - ) - display_name: ClassVar[str] = "GLPK" features: ClassVar[frozenset[SolverFeature]] = frozenset( { @@ -1386,6 +1382,10 @@ 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: """