|
| 1 | +""" |
| 2 | +Tests for the ``active_fill`` parameter of ``add_piecewise_formulation`` (#796). |
| 3 | +
|
| 4 | +``active_fill`` is a transitional convenience: it pads a partial ``active`` |
| 5 | +gate (a subset of the indexed dimension, or a masked gate) to full coverage. |
| 6 | +It is slated for removal once the v1 arithmetic semantics (#717) make |
| 7 | +``active.reindex(coords).fillna(value)`` correct on its own, so these tests |
| 8 | +live in a dedicated module that can be dropped with the parameter. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from collections.abc import Callable |
| 14 | +from typing import Any, Literal, TypeAlias |
| 15 | + |
| 16 | +import numpy as np |
| 17 | +import pandas as pd |
| 18 | +import pytest |
| 19 | +import xarray as xr |
| 20 | + |
| 21 | +from linopy import Model, available_solvers, segments |
| 22 | +from linopy.piecewise import _resolve_active |
| 23 | +from linopy.solver_capabilities import ( |
| 24 | + SolverFeature, |
| 25 | + get_available_solvers_with_feature, |
| 26 | +) |
| 27 | + |
| 28 | +Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] |
| 29 | +GateBuilder: TypeAlias = Callable[[Model], Any] |
| 30 | + |
| 31 | +_any_solvers = [ |
| 32 | + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers |
| 33 | +] |
| 34 | +_sos2_solvers = get_available_solvers_with_feature( |
| 35 | + SolverFeature.SOS_CONSTRAINTS, available_solvers |
| 36 | +) |
| 37 | + |
| 38 | + |
| 39 | +# ``active`` is meaningful only for the committable subset {a, c}; "b" stays |
| 40 | +# ungated. The partial-gate shapes below all leave "b" as the gap. |
| 41 | +_PWL_GENS = pd.Index(["a", "b", "c"], name="gen") |
| 42 | +_COMMITTABLE = pd.Index(["a", "c"], name="gen") |
| 43 | + |
| 44 | + |
| 45 | +def _subset_gate(m: Model) -> Any: |
| 46 | + """``active`` indexed over a strict subset of the formulation's dim.""" |
| 47 | + return m.add_variables(binary=True, coords=[_COMMITTABLE], name="u") |
| 48 | + |
| 49 | + |
| 50 | +def _masked_gate(m: Model) -> Any: |
| 51 | + """``active`` over the full dim but masked where it does not apply.""" |
| 52 | + mask = pd.Series([True, False, True], index=_PWL_GENS) |
| 53 | + return m.add_variables(binary=True, coords=[_PWL_GENS], name="u", mask=mask) |
| 54 | + |
| 55 | + |
| 56 | +def _full_gate(m: Model) -> Any: |
| 57 | + return m.add_variables(binary=True, coords=[_PWL_GENS], name="u") |
| 58 | + |
| 59 | + |
| 60 | +def _scalar_gate(m: Model) -> Any: |
| 61 | + return m.add_variables(binary=True, name="u") |
| 62 | + |
| 63 | + |
| 64 | +_PARTIAL_GATES = [ |
| 65 | + pytest.param(_subset_gate, id="strict-subset"), |
| 66 | + pytest.param(_masked_gate, id="masked"), |
| 67 | +] |
| 68 | + |
| 69 | +# (builder, active_fill, should_raise): partial gates raise unless active_fill |
| 70 | +# is set; full/scalar gates are always fine. |
| 71 | +_COVERAGE_CASES = [ |
| 72 | + pytest.param(_subset_gate, None, True, id="subset-None-raises"), |
| 73 | + pytest.param(_masked_gate, None, True, id="masked-None-raises"), |
| 74 | + pytest.param(_subset_gate, 1, False, id="subset-fill1-ok"), |
| 75 | + pytest.param(_masked_gate, 1, False, id="masked-fill1-ok"), |
| 76 | + pytest.param(_subset_gate, 0, False, id="subset-fill0-ok"), |
| 77 | + pytest.param(_full_gate, None, False, id="full-ok"), |
| 78 | + pytest.param(_scalar_gate, None, False, id="scalar-ok"), |
| 79 | +] |
| 80 | + |
| 81 | + |
| 82 | +def _solve_partial_gate( |
| 83 | + solver_name: str, |
| 84 | + make_active: GateBuilder, |
| 85 | + *, |
| 86 | + method: Method, |
| 87 | + disjunctive: bool = False, |
| 88 | +) -> None: |
| 89 | + """Fill a partial gate, force the committable units off, demand "b" runs.""" |
| 90 | + m = Model() |
| 91 | + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") |
| 92 | + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") |
| 93 | + u = make_active(m) |
| 94 | + if disjunctive: |
| 95 | + m.add_piecewise_formulation( |
| 96 | + (x, segments([[0.0, 50.0], [50.0, 100.0]])), |
| 97 | + (y, segments([[0.0, 10.0], [10.0, 50.0]])), |
| 98 | + active=u, |
| 99 | + active_fill=1, |
| 100 | + ) |
| 101 | + else: |
| 102 | + m.add_piecewise_formulation( |
| 103 | + (x, [0, 50, 100]), |
| 104 | + (y, [0, 10, 50]), |
| 105 | + active=u, |
| 106 | + active_fill=1, |
| 107 | + method=method, |
| 108 | + ) |
| 109 | + m.add_constraints(u <= 0, name="force_off") |
| 110 | + m.add_constraints(x.sel(gen="b") >= 50, name="demand") |
| 111 | + m.add_objective(y.sum(), sense="min") |
| 112 | + status, _ = m.solve(solver_name=solver_name) |
| 113 | + assert status == "ok" |
| 114 | + np.testing.assert_allclose(float(x.solution.sel(gen="a")), 0, atol=1e-4) |
| 115 | + np.testing.assert_allclose(float(x.solution.sel(gen="c")), 0, atol=1e-4) |
| 116 | + np.testing.assert_allclose(float(x.solution.sel(gen="b")), 50, atol=1e-4) |
| 117 | + np.testing.assert_allclose(float(y.solution.sel(gen="b")), 10, atol=1e-4) |
| 118 | + |
| 119 | + |
| 120 | +class TestResolveActiveFill: |
| 121 | + """The private ``_resolve_active`` fills gaps with ``active_fill``.""" |
| 122 | + |
| 123 | + @pytest.mark.parametrize("fill_value", [1, 0]) |
| 124 | + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) |
| 125 | + def test_fills_gap(self, make_active: GateBuilder, fill_value: int) -> None: |
| 126 | + reference = xr.DataArray(np.zeros(len(_PWL_GENS)), coords=[_PWL_GENS]) |
| 127 | + gate = _resolve_active(1 * make_active(Model()), reference, fill_value) |
| 128 | + assert gate.const.sel(gen="b").item() == fill_value |
| 129 | + assert bool((gate.vars.sel(gen="b") < 0).all()) # no variable at "b" |
| 130 | + assert bool((gate.vars.sel(gen="a") >= 0).any()) # variable kept at "a" |
| 131 | + |
| 132 | + |
| 133 | +class TestActiveFillValidation: |
| 134 | + """``add_piecewise_formulation`` gates a partial ``active`` via ``active_fill``.""" |
| 135 | + |
| 136 | + @pytest.mark.parametrize("make_active, active_fill, should_raise", _COVERAGE_CASES) |
| 137 | + def test_coverage( |
| 138 | + self, |
| 139 | + make_active: GateBuilder, |
| 140 | + active_fill: int | None, |
| 141 | + should_raise: bool, |
| 142 | + ) -> None: |
| 143 | + m = Model() |
| 144 | + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") |
| 145 | + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") |
| 146 | + |
| 147 | + def build() -> None: |
| 148 | + m.add_piecewise_formulation( |
| 149 | + (x, [0, 50, 100]), |
| 150 | + (y, [0, 10, 50]), |
| 151 | + active=make_active(m), |
| 152 | + active_fill=active_fill, |
| 153 | + method="incremental", |
| 154 | + ) |
| 155 | + |
| 156 | + if should_raise: |
| 157 | + with pytest.raises(ValueError, match="active_fill"): |
| 158 | + build() |
| 159 | + else: |
| 160 | + build() |
| 161 | + |
| 162 | + def test_active_fill_without_active_raises(self) -> None: |
| 163 | + m = Model() |
| 164 | + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") |
| 165 | + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") |
| 166 | + with pytest.raises(ValueError, match="without `active`"): |
| 167 | + m.add_piecewise_formulation( |
| 168 | + (x, [0, 50, 100]), |
| 169 | + (y, [0, 10, 50]), |
| 170 | + active_fill=1, |
| 171 | + method="incremental", |
| 172 | + ) |
| 173 | + |
| 174 | + def test_lower_dimensional_active_broadcasts(self) -> None: |
| 175 | + """A gate missing an entire dim broadcasts and must not be rejected.""" |
| 176 | + ts = pd.Index([0, 1], name="t") |
| 177 | + m = Model() |
| 178 | + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS, ts], name="x") |
| 179 | + y = m.add_variables(lower=0, coords=[_PWL_GENS, ts], name="y") |
| 180 | + u = m.add_variables(binary=True, coords=[_PWL_GENS], name="u") |
| 181 | + m.add_piecewise_formulation( |
| 182 | + (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, method="incremental" |
| 183 | + ) |
| 184 | + |
| 185 | + |
| 186 | +@pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available") |
| 187 | +class TestSolverActiveFill: |
| 188 | + """End-to-end: ``active_fill`` leaves ungated units free (#796).""" |
| 189 | + |
| 190 | + @pytest.fixture(params=_any_solvers) |
| 191 | + def solver_name(self, request: pytest.FixtureRequest) -> str: |
| 192 | + return request.param |
| 193 | + |
| 194 | + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) |
| 195 | + def test_incremental(self, solver_name: str, make_active: GateBuilder) -> None: |
| 196 | + _solve_partial_gate(solver_name, make_active, method="incremental") |
| 197 | + |
| 198 | + |
| 199 | +@pytest.mark.skipif(len(_sos2_solvers) == 0, reason="No SOS2-capable solver") |
| 200 | +class TestSolverActiveFillSOS2: |
| 201 | + @pytest.fixture(params=_sos2_solvers) |
| 202 | + def solver_name(self, request: pytest.FixtureRequest) -> str: |
| 203 | + return request.param |
| 204 | + |
| 205 | + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) |
| 206 | + @pytest.mark.parametrize( |
| 207 | + "method, disjunctive", |
| 208 | + [ |
| 209 | + pytest.param("sos2", False, id="sos2"), |
| 210 | + pytest.param("auto", True, id="disjunctive"), |
| 211 | + ], |
| 212 | + ) |
| 213 | + def test_solves( |
| 214 | + self, |
| 215 | + solver_name: str, |
| 216 | + make_active: GateBuilder, |
| 217 | + method: Method, |
| 218 | + disjunctive: bool, |
| 219 | + ) -> None: |
| 220 | + _solve_partial_gate( |
| 221 | + solver_name, make_active, method=method, disjunctive=disjunctive |
| 222 | + ) |
0 commit comments