Skip to content

Commit fe798b1

Browse files
FBumannclaude
andauthored
feat(piecewise): active_fill for partial active gates (alt. to #797, #796) (#798)
* feat(piecewise): partial `active` gate support via active_gate helper (#796) `add_piecewise_formulation(active=...)` assumed the gate covered the whole indexed dimension. A gate defined over only a subset of coordinate labels (or with masked entries) silently forced the uncovered entries to zero — breaking mixed committable / non-committable formulations (PyPSA#1755). - Add `linopy.active_gate(active, coords, fill_value=1)`: pads a partial gate to full coverage, treating missing/masked entries as always-active (1) or always-off (0). Lives in its own module `linopy/_active_gate.py` as a temporary legacy stopgap; under v1 the bare `active.reindex(coords).fillna(fill_value)` idiom suffices and the helper is expected to be deprecated. - `add_piecewise_formulation` now rejects an under-defined `active` (strict subset or masked) with an actionable error instead of mis-solving. A lower-dimensional gate still broadcasts and is accepted. - Docs (api, piecewise guide, release notes) and tests in a dedicated `test/test_piecewise_active_gate.py` (parametrized over both partial shapes and incremental/sos2/disjunctive paths). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(piecewise): use LinearExpression.has_terms for gate gap detection Apply review suggestion (#797): replace the hand-rolled vars/term-dim reduction with the public `has_terms` property in `active_gate` and the coverage validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(piecewise): replace active_gate helper with active_fill parameter Per PR #797 review: instead of a standalone `active_gate` helper, gate a partial `active` via an `active_fill` parameter on `add_piecewise_formulation`. The function derives its own coordinate, so the caller supplies nothing extra; `active_fill=None` (default) keeps the function strict (partial `active` raises), and `0`/`1` opt into always-off / always-on. - Drop `linopy/_active_gate.py` and the `active_gate` export. - Add `active_fill: int | None` to `add_piecewise_formulation`; fold the guard + padding into `_resolve_active`. - `active_fill` is transitional (removed once v1 makes `active.reindex(coords).fillna(value)` sufficient) — documented as such. - Tests renamed to test_piecewise_active_fill.py; docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(piecewise): state explicitly that a partial active = subset labels or masked Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(piecewise): type active_fill as int to satisfy mypy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1dbde37 commit fe798b1

4 files changed

Lines changed: 301 additions & 1 deletion

File tree

doc/piecewise-linear-constraints.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,24 @@ manual gating.
565565
variable: combined with the ``y ≤ 0`` constraint from deactivation,
566566
this forces ``y = 0`` automatically.
567567

568+
Partial gates
569+
^^^^^^^^^^^^^
570+
571+
``active`` must cover the formulation's full coordinate; a gate defined
572+
over only a subset (or with masked entries) is rejected unless
573+
``active_fill`` is set. ``active_fill`` gates the missing entries as
574+
always-active (``1``) or always-off (``0``) — handy when one formulation
575+
mixes committable and non-committable units sharing a single ``status``:
576+
577+
.. code-block:: python
578+
579+
m.add_piecewise_formulation(
580+
(power, [30, 60, 100]), (fuel, [40, 90, 170]), active=status, active_fill=1
581+
)
582+
583+
``active_fill`` is transitional: under v1 semantics, pad ``active``
584+
explicitly with ``active.reindex(coords).fillna(value)`` instead.
585+
568586
Auto-broadcasting
569587
~~~~~~~~~~~~~~~~~
570588

doc/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Upcoming Version
2020
*Other*
2121

2222
* ``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)
23+
* ``add_piecewise_formulation`` gained an ``active_fill`` parameter that gates a partial ``active`` (defined over a subset of the indexed dimension, or masked) as always-active (``1``) or always-off (``0``); without it, a partial ``active`` — which was previously zeroed silently — now raises. Useful when one formulation mixes gated and ungated entities (e.g. committable and non-committable units sharing a ``status``). ``active_fill`` is transitional and will be removed once v1 semantics make ``active.reindex(coords).fillna(value)`` sufficient. (https://github.com/PyPSA/linopy/issues/796)
2324

2425
**Deprecations**
2526

linopy/piecewise.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,44 @@ def tangent_lines(
838838
# ---------------------------------------------------------------------------
839839

840840

841+
def _resolve_active(
842+
active: LinearExpression, reference: DataArray, active_fill: int | None
843+
) -> LinearExpression:
844+
"""
845+
Resolve a possibly-partial ``active`` gate against the formulation.
846+
847+
A gate defined over only a subset of the indexed dimension (or with
848+
masked entries) would otherwise be gated as if ``active=0`` and forced
849+
to zero. With ``active_fill is None`` such a gate is rejected; otherwise
850+
the gaps are filled with ``active_fill`` (``1`` = always active, ``0`` =
851+
always off). Dimensions absent from ``active`` broadcast and are left
852+
untouched.
853+
"""
854+
skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS)
855+
indexers = {
856+
d: reference.indexes[d]
857+
for d in active.coord_dims
858+
if d in reference.indexes and d not in skip
859+
}
860+
aligned = active.reindex(indexers) if indexers else active
861+
862+
if active_fill is not None:
863+
return aligned.where(aligned.has_terms, active_fill)
864+
865+
term_dims = [d for d in aligned.vars.dims if d not in aligned.coord_dims]
866+
dangling = ((aligned.vars < 0) & aligned.coeffs.notnull()).any(term_dims)
867+
covered = aligned.has_terms | (aligned.const.notnull() & ~dangling)
868+
if not bool(covered.all()):
869+
raise ValueError(
870+
"`active` is not defined over the full coordinate of the "
871+
"piecewise formulation: it is missing labels (a subset of the "
872+
"coordinate) or has masked entries, which would be gated to "
873+
"zero. Pass `active_fill=1` to treat those entries as always "
874+
"active (or `0` as always off), or pass a fully-defined `active`."
875+
)
876+
return active
877+
878+
841879
def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool:
842880
"""
843881
Validate that two breakpoint arrays have compatible shapes.
@@ -1035,6 +1073,7 @@ def add_piecewise_formulation(
10351073
| tuple[LinExprLike, BreaksOrSlopes, Literal["==", "<=", ">="]],
10361074
method: PWL_METHOD = "auto",
10371075
active: LinExprLike | None = None,
1076+
active_fill: int | None = None,
10381077
name: str | None = None,
10391078
) -> PiecewiseFormulation:
10401079
r"""
@@ -1118,6 +1157,11 @@ def add_piecewise_formulation(
11181157
``active=0``, all auxiliary variables are forced to zero.
11191158
Not supported with ``method="lp"``.
11201159
1160+
``active`` must cover the formulation's full coordinate. A
1161+
*partial* gate — one defined over only a subset of the coordinate's
1162+
labels, or carrying masked entries — is rejected unless
1163+
``active_fill`` is set (see below).
1164+
11211165
With all-equality tuples (the default), the output is then pinned
11221166
to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation
11231167
only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0
@@ -1129,6 +1173,16 @@ def add_piecewise_formulation(
11291173
automatically. For outputs that genuinely need both signs you
11301174
must add the complementary bound yourself (e.g., a big-M
11311175
coupling ``y`` with ``active``).
1176+
active_fill : int, optional
1177+
Fill value for the gap entries of a partial ``active`` — those where
1178+
``active`` has no label (a subset of the coordinate) or is masked:
1179+
``1`` treats them as always active (ungated), ``0`` as always off.
1180+
When ``None`` (the default) a partial ``active`` is rejected instead.
1181+
Useful when one formulation mixes gated and ungated entities (e.g.
1182+
committable and non-committable units sharing a ``status``).
1183+
Transitional convenience: under v1 semantics, pad ``active``
1184+
explicitly with ``active.reindex(coords).fillna(value)`` instead —
1185+
this parameter is slated for removal then.
11321186
name : str, optional
11331187
Base name for generated variables/constraints.
11341188
@@ -1285,7 +1339,12 @@ def add_piecewise_formulation(
12851339
# can't collide with the synthetic coord for an unnamed expr.
12861340
link_coords.append(f"_pwl_{i}")
12871341

1288-
active_expr = _to_linexpr(active) if active is not None else None
1342+
if active is None:
1343+
if active_fill is not None:
1344+
raise ValueError("`active_fill` has no effect without `active`.")
1345+
active_expr = None
1346+
else:
1347+
active_expr = _resolve_active(_to_linexpr(active), bp_list[0], active_fill)
12891348

12901349
if signed_idx is None:
12911350
inputs = _PwlInputs(

test/test_piecewise_active_fill.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)