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/6] 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/6] 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 ------------- From b0642f23a9f0545db5c8e8941ee3590303681bf6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:09:47 +0200 Subject: [PATCH 3/6] feat(model): allow {0,1} bounds for binaries in add_variables Binary bounds could previously only be set via the .lower/.upper setters after creation; add_variables(binary=True, ...) raised on any lower/upper. Now it accepts bounds as long as every value is 0 or 1 (unset bounds still default to 0/1), and validates the rest. Refs #776 Co-Authored-By: Claude Opus 4.8 (1M context) --- doc/release_notes.rst | 4 +++ linopy/model.py | 20 ++++++----- test/test_variable_assignment.py | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 465cd3fa..f92fd872 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -4,6 +4,10 @@ Release Notes Upcoming Version ---------------- +**Features** + +* ``add_variables(binary=True, ...)`` now accepts ``lower``/``upper`` bounds, as long as they are 0 or 1. Previously binary bounds could only be set via the ``.lower``/``.upper`` setters after creation. (https://github.com/PyPSA/linopy/issues/776) + **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) diff --git a/linopy/model.py b/linopy/model.py index 884d59db..b3529965 100644 --- a/linopy/model.py +++ b/linopy/model.py @@ -620,11 +620,11 @@ def add_variables( Parameters ---------- lower : float/array_like, optional - Lower bound of the variable(s). Ignored if `binary` is True. - The default is -inf. + Lower bound of the variable(s). For binary variables it + defaults to 0 and, if given, must be 0 or 1. The default is -inf. upper : TYPE, optional - Upper bound of the variable(s). Ignored if `binary` is True. - The default is inf. + Upper bound of the variable(s). For binary variables it + defaults to 1 and, if given, must be 0 or 1. The default is inf. coords : list/dict/xarray.Coordinates, optional The coords of the variable array. When provided with **named dimensions** (a ``Mapping``, ``xarray.Coordinates``, a @@ -773,10 +773,14 @@ def add_variables( ) if binary: - if (lower != -inf) or (upper != inf): - raise ValueError("Binary variables cannot have lower or upper bounds.") - else: - lower, upper = 0, 1 + if np.isscalar(lower) and lower == -inf: + lower = 0 + elif not (np.isin(lower, (0, 1)) | pd.isna(lower)).all(): + raise ValueError("Binary variable lower bounds must be 0 or 1.") + if np.isscalar(upper) and upper == inf: + upper = 1 + elif not (np.isin(upper, (0, 1)) | pd.isna(upper)).all(): + raise ValueError("Binary variable upper bounds must be 0 or 1.") if semi_continuous: if not np.isscalar(lower) or float(lower) <= 0: # type: ignore[arg-type] diff --git a/test/test_variable_assignment.py b/test/test_variable_assignment.py index 02da32df..b453b4d3 100644 --- a/test/test_variable_assignment.py +++ b/test/test_variable_assignment.py @@ -248,6 +248,63 @@ def test_variable_assignment_binary_with_error() -> None: m.add_variables(lower=-2, coords=coords, binary=True) +def test_variable_assignment_binary_force_on() -> None: + """A scalar bound defaults the other end: lower=1 forces the binary on.""" + forced_on = Model().add_variables( + binary=True, lower=1, coords=[pd.RangeIndex(4, name="t")] + ) + assert (forced_on.lower.values == 1).all() + assert (forced_on.upper.values == 1).all() + + +@pytest.mark.parametrize( + "upper", + [ + pytest.param([1, 1, 0, 0], id="list"), + pytest.param(np.array([1.0, 1.0, 0.0, 0.0]), id="ndarray"), + pytest.param(pd.Series([1, 1, 0, 0]), id="series"), + pytest.param( + xr.DataArray([1, np.nan, 0, 1], dims="t", coords={"t": range(4)}), + id="dataarray-nan", + ), + ], +) +def test_variable_assignment_binary_array_bounds_ok(upper) -> None: + """0/1 bounds accepted, NaN tolerated (for masking), across containers.""" + Model().add_variables(binary=True, upper=upper, coords=[pd.RangeIndex(4, name="t")]) + + +@pytest.mark.parametrize( + "upper", + [ + pytest.param([1, 1, 2, 0], id="list"), + pytest.param(np.array([0.5, 1.0, 0.0, 1.0]), id="fractional"), + pytest.param(pd.Series([2, 1, 0, 1]), id="series"), + pytest.param( + xr.DataArray([1, np.nan, 2, 0], dims="t", coords={"t": range(4)}), + id="dataarray-nan", + ), + ], +) +def test_variable_assignment_binary_array_bounds_error(upper) -> None: + """A non-0/1 value is rejected, even when NaN is also present.""" + with pytest.raises(ValueError, match="must be 0 or 1"): + Model().add_variables( + binary=True, upper=upper, coords=[pd.RangeIndex(4, name="t")] + ) + + +@pytest.mark.parametrize("bound", [0, 1, 0.0, 1.0]) +def test_variable_assignment_binary_scalar_bound_ok(bound) -> None: + Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)]) + + +@pytest.mark.parametrize("bound", [0.5, 2, -1]) +def test_variable_assignment_binary_scalar_bound_error(bound) -> None: + with pytest.raises(ValueError, match="must be 0 or 1"): + Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)]) + + def test_variable_assignment_integer() -> None: m = Model() From b7eb44d6f2bb89215deab3b6b153cd1526775d35 Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 19 Jun 2026 08:57:47 +0200 Subject: [PATCH 4/6] test: group LP binary-bounds tests into a class with a shared factory fixture --- test/test_io.py | 90 ++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/test/test_io.py b/test/test_io.py index 2e3eef3c..cf6a5aca 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -515,62 +515,54 @@ 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() +class TestLPBinaryBounds: + """LP export honors binary bounds tightened below [0, 1] (#776).""" + + @pytest.fixture + def make_tightened_model(self): + 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 + + return build + + def test_default_bounds_omitted(self, 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()) - 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 + fn = tmp_path / "binary_default.lp" + m.to_file(fn) + assert "bounds" not in fn.read_text() + def test_tightened_bounds_written(self, make_tightened_model, tmp_path: Path) -> None: + """Per-element bounds tighter than [0, 1] reach the LP `bounds` section.""" + m = make_tightened_model() + fn = tmp_path / "binary_tightened.lp" + m.to_file(fn) -@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] + bounds_section = fn.read_text().split("bounds")[1].split("binary")[0] + for label in m.variables["x"].labels.values[2:]: + assert f"x{label} <= +0.0" in bounds_section - 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 + @pytest.mark.skipif(not available_solvers, reason="No solver installed") + def test_lp_and_direct_agree(self, make_tightened_model) -> None: + """LP and direct paths see the same feasible set for tightened binaries.""" + solver = available_solvers[0] - m_direct = build() - m_direct.solve(solver_name=solver, io_api="direct") + m_direct = make_tightened_model() + m_direct.solve(solver_name=solver, io_api="direct") - m_lp = build() - m_lp.solve(solver_name=solver, io_api="lp") + m_lp = make_tightened_model() + m_lp.solve(solver_name=solver, io_api="lp") - assert m_direct.objective.value == m_lp.objective.value == -2 + assert m_direct.objective.value == m_lp.objective.value == -2 def test_to_file_lp_frozen_vs_mutable(tmp_path: Path) -> None: From 303cc0fcc9147d4a9fc12e5ad699086a1392825e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:00:01 +0000 Subject: [PATCH 5/6] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- test/test_io.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/test_io.py b/test/test_io.py index 369b580f..72acf500 100644 --- a/test/test_io.py +++ b/test/test_io.py @@ -536,7 +536,9 @@ class TestLPBinaryBounds: def make_tightened_model(self): def build() -> Model: m = Model() - x = m.add_variables(binary=True, coords=[pd.RangeIndex(4, name="t")], name="x") + 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()) @@ -555,7 +557,9 @@ def test_default_bounds_omitted(self, tmp_path: Path) -> None: m.to_file(fn) assert "bounds" not in fn.read_text() - def test_tightened_bounds_written(self, make_tightened_model, tmp_path: Path) -> None: + def test_tightened_bounds_written( + self, make_tightened_model, tmp_path: Path + ) -> None: """Per-element bounds tighter than [0, 1] reach the LP `bounds` section.""" m = make_tightened_model() fn = tmp_path / "binary_tightened.lp" From 871198e5cf5334f6beabae3940f9ced3b771ffbc Mon Sep 17 00:00:00 2001 From: Fabian Date: Fri, 19 Jun 2026 12:34:23 +0200 Subject: [PATCH 6/6] test: add type annotations to binary bounds test params --- test/test_variable_assignment.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/test/test_variable_assignment.py b/test/test_variable_assignment.py index b453b4d3..a64492ba 100644 --- a/test/test_variable_assignment.py +++ b/test/test_variable_assignment.py @@ -3,6 +3,8 @@ This module aims at testing the correct assignment of variable to the model. """ +from typing import Any + import dask import numpy as np import pandas as pd @@ -269,7 +271,7 @@ def test_variable_assignment_binary_force_on() -> None: ), ], ) -def test_variable_assignment_binary_array_bounds_ok(upper) -> None: +def test_variable_assignment_binary_array_bounds_ok(upper: Any) -> None: """0/1 bounds accepted, NaN tolerated (for masking), across containers.""" Model().add_variables(binary=True, upper=upper, coords=[pd.RangeIndex(4, name="t")]) @@ -286,7 +288,7 @@ def test_variable_assignment_binary_array_bounds_ok(upper) -> None: ), ], ) -def test_variable_assignment_binary_array_bounds_error(upper) -> None: +def test_variable_assignment_binary_array_bounds_error(upper: Any) -> None: """A non-0/1 value is rejected, even when NaN is also present.""" with pytest.raises(ValueError, match="must be 0 or 1"): Model().add_variables( @@ -295,12 +297,12 @@ def test_variable_assignment_binary_array_bounds_error(upper) -> None: @pytest.mark.parametrize("bound", [0, 1, 0.0, 1.0]) -def test_variable_assignment_binary_scalar_bound_ok(bound) -> None: +def test_variable_assignment_binary_scalar_bound_ok(bound: float) -> None: Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)]) @pytest.mark.parametrize("bound", [0.5, 2, -1]) -def test_variable_assignment_binary_scalar_bound_error(bound) -> None: +def test_variable_assignment_binary_scalar_bound_error(bound: float) -> None: with pytest.raises(ValueError, match="must be 0 or 1"): Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)])