Skip to content

Commit 86baf59

Browse files
FBumannclaudeFabianHofmannpre-commit-ci[bot]
authored
feat(model): allow {0,1} bounds for binaries in add_variables (#778)
* 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) <noreply@anthropic.com> * docs: tone down binary-bounds release note Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * test: group LP binary-bounds tests into a class with a shared factory fixture * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * test: add type annotations to binary bounds test params --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Fabian <fab.hof@gmx.de> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 081cd8b commit 86baf59

3 files changed

Lines changed: 75 additions & 8 deletions

File tree

doc/release_notes.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ Upcoming Version
66

77
* ``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.
88

9+
**Features**
10+
11+
* ``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)
12+
913
**Bug fixes**
1014

1115
* 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)

linopy/model.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,11 @@ def add_variables(
620620
Parameters
621621
----------
622622
lower : float/array_like, optional
623-
Lower bound of the variable(s). Ignored if `binary` is True.
624-
The default is -inf.
623+
Lower bound of the variable(s). For binary variables it
624+
defaults to 0 and, if given, must be 0 or 1. The default is -inf.
625625
upper : TYPE, optional
626-
Upper bound of the variable(s). Ignored if `binary` is True.
627-
The default is inf.
626+
Upper bound of the variable(s). For binary variables it
627+
defaults to 1 and, if given, must be 0 or 1. The default is inf.
628628
coords : list/dict/xarray.Coordinates, optional
629629
The coords of the variable array. When provided with **named
630630
dimensions** (a ``Mapping``, ``xarray.Coordinates``, a
@@ -773,10 +773,14 @@ def add_variables(
773773
)
774774

775775
if binary:
776-
if (lower != -inf) or (upper != inf):
777-
raise ValueError("Binary variables cannot have lower or upper bounds.")
778-
else:
779-
lower, upper = 0, 1
776+
if np.isscalar(lower) and lower == -inf:
777+
lower = 0
778+
elif not (np.isin(lower, (0, 1)) | pd.isna(lower)).all():
779+
raise ValueError("Binary variable lower bounds must be 0 or 1.")
780+
if np.isscalar(upper) and upper == inf:
781+
upper = 1
782+
elif not (np.isin(upper, (0, 1)) | pd.isna(upper)).all():
783+
raise ValueError("Binary variable upper bounds must be 0 or 1.")
780784

781785
if semi_continuous:
782786
if not np.isscalar(lower) or float(lower) <= 0: # type: ignore[arg-type]

test/test_variable_assignment.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
This module aims at testing the correct assignment of variable to the model.
44
"""
55

6+
from typing import Any
7+
68
import dask
79
import numpy as np
810
import pandas as pd
@@ -248,6 +250,63 @@ def test_variable_assignment_binary_with_error() -> None:
248250
m.add_variables(lower=-2, coords=coords, binary=True)
249251

250252

253+
def test_variable_assignment_binary_force_on() -> None:
254+
"""A scalar bound defaults the other end: lower=1 forces the binary on."""
255+
forced_on = Model().add_variables(
256+
binary=True, lower=1, coords=[pd.RangeIndex(4, name="t")]
257+
)
258+
assert (forced_on.lower.values == 1).all()
259+
assert (forced_on.upper.values == 1).all()
260+
261+
262+
@pytest.mark.parametrize(
263+
"upper",
264+
[
265+
pytest.param([1, 1, 0, 0], id="list"),
266+
pytest.param(np.array([1.0, 1.0, 0.0, 0.0]), id="ndarray"),
267+
pytest.param(pd.Series([1, 1, 0, 0]), id="series"),
268+
pytest.param(
269+
xr.DataArray([1, np.nan, 0, 1], dims="t", coords={"t": range(4)}),
270+
id="dataarray-nan",
271+
),
272+
],
273+
)
274+
def test_variable_assignment_binary_array_bounds_ok(upper: Any) -> None:
275+
"""0/1 bounds accepted, NaN tolerated (for masking), across containers."""
276+
Model().add_variables(binary=True, upper=upper, coords=[pd.RangeIndex(4, name="t")])
277+
278+
279+
@pytest.mark.parametrize(
280+
"upper",
281+
[
282+
pytest.param([1, 1, 2, 0], id="list"),
283+
pytest.param(np.array([0.5, 1.0, 0.0, 1.0]), id="fractional"),
284+
pytest.param(pd.Series([2, 1, 0, 1]), id="series"),
285+
pytest.param(
286+
xr.DataArray([1, np.nan, 2, 0], dims="t", coords={"t": range(4)}),
287+
id="dataarray-nan",
288+
),
289+
],
290+
)
291+
def test_variable_assignment_binary_array_bounds_error(upper: Any) -> None:
292+
"""A non-0/1 value is rejected, even when NaN is also present."""
293+
with pytest.raises(ValueError, match="must be 0 or 1"):
294+
Model().add_variables(
295+
binary=True, upper=upper, coords=[pd.RangeIndex(4, name="t")]
296+
)
297+
298+
299+
@pytest.mark.parametrize("bound", [0, 1, 0.0, 1.0])
300+
def test_variable_assignment_binary_scalar_bound_ok(bound: float) -> None:
301+
Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)])
302+
303+
304+
@pytest.mark.parametrize("bound", [0.5, 2, -1])
305+
def test_variable_assignment_binary_scalar_bound_error(bound: float) -> None:
306+
with pytest.raises(ValueError, match="must be 0 or 1"):
307+
Model().add_variables(binary=True, upper=bound, coords=[pd.RangeIndex(2)])
308+
309+
251310
def test_variable_assignment_integer() -> None:
252311
m = Model()
253312

0 commit comments

Comments
 (0)