From 65bb7d797d58648350c3f5642c75877c80e6d609 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:57:54 +0200 Subject: [PATCH 1/2] fix(io): write LP bounds for tightened binary variables Per-element bounds set below the implied [0, 1] on a binary variable (e.g. masking out entries with upper = 0) were silently dropped by the LP file export: binaries appeared only in the `binary` section. The direct API honored them, so the same model relaxed when solved through io_api="lp". bounds_to_file now emits a bounds row for any binary whose bounds differ from (0, 1) anywhere, matching the direct path. Closes #776 Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/release_notes.rst | 4 +++ linopy/io.py | 18 ++++++++++++-- test/test_io.py | 58 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9b1ecbd8..8caaa500 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,10 @@ Release Notes Upcoming Version ---------------- +**Bug fixes** + +* LP file export now writes a ``bounds`` section for binary variables whose bounds were tightened below the implied ``[0, 1]`` (e.g. masking out entries with ``upper = 0``). Previously such bounds were silently dropped, so the same model could relax when solved through ``io_api="lp"`` while ``io_api="direct"`` honored them. (https://github.com/PyPSA/linopy/issues/776) + Version 0.8.0 ------------- diff --git a/linopy/io.py b/linopy/io.py index 1c4714c4..22c27ffb 100644 --- a/linopy/io.py +++ b/linopy/io.py @@ -33,6 +33,7 @@ from highspy.highs import Highs from linopy.model import Model + from linopy.variables import Variable logger = logging.getLogger(__name__) @@ -235,6 +236,17 @@ def objective_to_file( objective_write_quadratic_terms(f, quads, print_variable) +def _binary_has_nondefault_bounds(var: Variable) -> bool: + """ + Whether a binary variable carries bounds other than the implied (0, 1). + + Scans the raw bound values (a single vectorised pass each), so masked + slots are tolerated: a false positive only routes the variable through + the bounds loop, where masked labels are dropped before writing. + """ + return bool((var.lower.values != 0).any() or (var.upper.values != 1).any()) + + def bounds_to_file( m: Model, f: BufferedWriter, @@ -250,8 +262,10 @@ def bounds_to_file( + list(m.variables.integers) + list(m.variables.semi_continuous) + [ - n for n in m.variables.binaries if m.variables[n].fixed - ] # fixed binaries need bounds + n + for n in m.variables.binaries + if _binary_has_nondefault_bounds(m.variables[n]) + ] ) if not len(list(names)): return diff --git a/test/test_io.py b/test/test_io.py index fba65aab..2e3eef3c 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -515,6 +515,64 @@ def test_to_file_lp_mixed_sign_constraints(tmp_path: Path) -> None: assert "=" in content +def test_to_file_lp_binary_default_bounds_omitted(tmp_path: Path) -> None: + """A binary with the implied [0, 1] bounds gets no bounds section.""" + m = Model() + b = m.add_variables(binary=True, coords=[pd.RangeIndex(3, name="t")], name="b") + m.add_constraints(b.sum() >= 1, name="c") + m.add_objective(b.sum()) + + fn = tmp_path / "binary_default.lp" + m.to_file(fn) + assert "bounds" not in fn.read_text() + + +def test_to_file_lp_binary_tightened_bounds(tmp_path: Path) -> None: + """ + Per-element bounds tighter than [0, 1] on a binary reach the LP file. + + Regression test for https://github.com/PyPSA/linopy/issues/776: the LP + export used to emit binaries only in the `binary` section (implied + [0, 1]), diverging from the direct API which honored the bounds. + """ + m = Model() + x = m.add_variables(binary=True, coords=[pd.RangeIndex(4, name="t")], name="x") + x.upper = pd.Series([1, 1, 0, 0], index=pd.RangeIndex(4, name="t")) + m.add_constraints(x.sum() >= 2, name="atleast2") + m.add_objective(-1 * x.sum()) + + fn = tmp_path / "binary_tightened.lp" + m.to_file(fn) + content = fn.read_text() + + bounds_section = content.split("bounds")[1].split("binary")[0] + labels = m.variables["x"].labels.values + for label in labels[2:]: + assert f"x{label} <= +0.0" in bounds_section + + +@pytest.mark.skipif(not available_solvers, reason="No solver installed") +def test_lp_and_direct_agree_on_binary_bounds(tmp_path: Path) -> None: + """The LP and direct paths see the same feasible set for tightened binaries.""" + solver = available_solvers[0] + + def build() -> Model: + m = Model() + x = m.add_variables(binary=True, coords=[pd.RangeIndex(4, name="t")], name="x") + x.upper = pd.Series([1, 1, 0, 0], index=pd.RangeIndex(4, name="t")) + m.add_constraints(x.sum() >= 2, name="atleast2") + m.add_objective(-1 * x.sum()) + return m + + m_direct = build() + m_direct.solve(solver_name=solver, io_api="direct") + + m_lp = build() + m_lp.solve(solver_name=solver, io_api="lp") + + assert m_direct.objective.value == m_lp.objective.value == -2 + + def test_to_file_lp_frozen_vs_mutable(tmp_path: Path) -> None: """Test that frozen and mutable constraints produce identical LP output.""" m_frozen = Model() From 4e7ccb563ef1c9659fe6158f7615231e83fb9477 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:01:15 +0200 Subject: [PATCH 2/2] docs: tone down binary-bounds release note Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/release_notes.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 8caaa500..465cd3fa 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -6,7 +6,7 @@ Upcoming Version **Bug fixes** -* LP file export now writes a ``bounds`` section for binary variables whose bounds were tightened below the implied ``[0, 1]`` (e.g. masking out entries with ``upper = 0``). Previously such bounds were silently dropped, so the same model could relax when solved through ``io_api="lp"`` while ``io_api="direct"`` honored them. (https://github.com/PyPSA/linopy/issues/776) +* 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) Version 0.8.0 -------------