Skip to content
Merged
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 @@ -6,6 +6,10 @@ Upcoming Version

* ``Model.to_netcdf`` now records the writing linopy version in the ``_linopy_version`` dataset attribute. Files written by older versions (without the attribute) continue to read unchanged.

**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 @@ -34,6 +34,7 @@
from highspy.highs import Highs

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


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -238,6 +239,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 @@ -253,8 +265,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
57 changes: 57 additions & 0 deletions test/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import importlib.util
import json
import pickle
from collections.abc import Callable
from pathlib import Path

import numpy as np
Expand Down Expand Up @@ -529,6 +530,62 @@ def test_to_file_lp_mixed_sign_constraints(tmp_path: Path) -> None:
assert "=" in content


class TestLPBinaryBounds:
"""LP export honors binary bounds tightened below [0, 1] (#776)."""

@pytest.fixture
def make_tightened_model(self) -> Callable[[], Model]:
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())

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: Callable[[], 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)

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

@pytest.mark.skipif(not available_solvers, reason="No solver installed")
def test_lp_and_direct_agree(
self, make_tightened_model: Callable[[], Model]
) -> None:
"""LP and direct paths see the same feasible set for tightened binaries."""
solver = available_solvers[0]

m_direct = make_tightened_model()
m_direct.solve(solver_name=solver, io_api="direct")

m_lp = make_tightened_model()
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