Skip to content

Commit a13e32a

Browse files
fix: GLPK objective parsing (#818)
* Fix GLPK objective parsing * Add release note * Refactor GLPK objective parsing into helper method * Add GLPK objective parser tests * Keep GLPK class docstring intact * Move GLPK objective token below public attributes
1 parent e861678 commit a13e32a

3 files changed

Lines changed: 57 additions & 1 deletion

File tree

doc/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Upcoming Version
2929

3030
**Bug fixes**
3131

32+
* Fix GLPK objective parsing. (https://github.com/PyPSA/linopy/pull/818)
3233
* 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)
3334
* 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.
3435
* ``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``.

linopy/solvers.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,26 @@ class GLPK(Solver[None]):
13821382
}
13831383
)
13841384

1385+
_OBJECTIVE_TOKEN: ClassVar[re.Pattern[str]] = re.compile(
1386+
r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
1387+
)
1388+
1389+
@classmethod
1390+
def _parse_objective(cls, text: str) -> float:
1391+
"""
1392+
Extract the objective value from a GLPK ``Objective:`` line.
1393+
1394+
GLPK reports the objective as ``<name> = <value> (MINimum)``. Anchoring
1395+
on the ``=`` drops the objective name before matching the first
1396+
float/scientific-notation token, so surrounding text can no longer
1397+
corrupt the parsed value.
1398+
"""
1399+
_, _, tail = text.rpartition("=")
1400+
match = cls._OBJECTIVE_TOKEN.search(tail)
1401+
if match is None:
1402+
raise ValueError(f"Could not parse objective value: {text!r}")
1403+
return float(match.group(0))
1404+
13851405
@classmethod
13861406
@functools.cache
13871407
def is_available(cls) -> bool:
@@ -1472,7 +1492,7 @@ def read_until_break(f: io.TextIOWrapper) -> Generator[str, None, None]:
14721492
info_io = io.StringIO("".join(read_until_break(f))[:-2])
14731493
info = pd.read_csv(info_io, sep=":", index_col=0, header=None)[1]
14741494
condition = info.Status.lower().strip()
1475-
objective = float(re.sub(r"[^0-9\.\+\-e]+", "", info.Objective))
1495+
objective = self._parse_objective(info.Objective)
14761496

14771497
termination_condition = CONDITION_MAP.get(condition, condition)
14781498
status = Status.from_termination_condition(termination_condition)

test/test_solvers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,41 @@ def test_assign_result_without_solver_kwarg_leaves_solver_unset(self) -> None:
570570
assert m.solver is None
571571

572572

573+
@pytest.mark.parametrize(
574+
"objective_text, expected",
575+
[
576+
(" obj = 1234.56 (MINimum)", 1234.56),
577+
(" obj = 0 (MINimum)", 0.0),
578+
(" obj = -3.5 (MAXimum)", -3.5),
579+
(" obj = +42 (MINimum)", 42.0),
580+
(" obj = .5 (MINimum)", 0.5),
581+
(" obj = 1.5e+06 (MAXimum)", 1.5e6),
582+
(" obj = -2E-3 (MINimum)", -2e-3),
583+
(" net_present_value = 1234.5 (MINimum)", 1234.5),
584+
(" obj1 = 1234.56 (MINimum)", 1234.56),
585+
(" c2e = -7.5e2 (MAXimum)", -750.0),
586+
(" 3.5 (MINimum)", 3.5),
587+
(" -1.5e3 (MAXimum)", -1500.0),
588+
(" 0 (MINimum)", 0.0),
589+
],
590+
)
591+
def test_parse_glpk_objective(objective_text: str, expected: float) -> None:
592+
assert solvers.GLPK._parse_objective(objective_text) == pytest.approx(expected)
593+
594+
595+
@pytest.mark.parametrize(
596+
"objective_text",
597+
[
598+
" obj = (MINimum)",
599+
" unbounded",
600+
"",
601+
],
602+
)
603+
def test_parse_glpk_objective_no_value_raises(objective_text: str) -> None:
604+
with pytest.raises(ValueError, match="Could not parse objective value"):
605+
solvers.GLPK._parse_objective(objective_text)
606+
607+
573608
mosek_installed = pytest.importorskip("mosek", reason="Mosek is not installed")
574609

575610

0 commit comments

Comments
 (0)