Skip to content
Merged
8 changes: 8 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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)

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
20 changes: 12 additions & 8 deletions linopy/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
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
57 changes: 57 additions & 0 deletions test/test_variable_assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down