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()