diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 9b1ecbd8..465cd3fa 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 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 ------------- 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()