Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------

Expand Down
18 changes: 16 additions & 2 deletions linopy/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from highspy.highs import Highs

from linopy.model import Model
from linopy.variables import Variable


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions test/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading