Skip to content

Commit c9c3298

Browse files
FBumannclaude
andcommitted
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>
1 parent 2d47d4a commit c9c3298

7 files changed

Lines changed: 335 additions & 1 deletion

File tree

doc/api.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ Construction helpers
480480
piecewise.breakpoints
481481
piecewise.segments
482482
piecewise.Slopes
483+
active_gate
483484

484485
PiecewiseFormulation
485486
--------------------

doc/piecewise-linear-constraints.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,22 @@ 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. Pad it with
573+
:func:`~linopy.active_gate`, which leaves missing entries always-active
574+
(``fill_value=1``) or off (``0``) — handy when one formulation mixes
575+
committable and non-committable units sharing a single ``status``:
576+
577+
.. code-block:: python
578+
579+
gate = linopy.active_gate(status, {"unit": units})
580+
m.add_piecewise_formulation(
581+
(power, [30, 60, 100]), (fuel, [40, 90, 170]), active=gate
582+
)
583+
568584
Auto-broadcasting
569585
~~~~~~~~~~~~~~~~~
570586

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+
* New ``linopy.active_gate`` helper pads a partial ``active`` gate for ``add_piecewise_formulation`` to full coverage (missing/masked entries become always-active, or off with ``fill_value=0``). Partial gates that were previously zeroed silently are now rejected with a clear error. (https://github.com/PyPSA/linopy/issues/796)
2324

2425
**Deprecations**
2526

linopy/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# Note: For intercepting multiplications between xarray dataarrays, Variables and Expressions
1313
# we need to extend their __mul__ functions with a quick special case
1414
import linopy.monkey_patch_xarray # noqa: F401
15+
from linopy._active_gate import active_gate
1516
from linopy.alignment import align
1617
from linopy.config import options
1718
from linopy.constants import (
@@ -67,6 +68,7 @@
6768
"SolverFeature",
6869
"Variable",
6970
"Variables",
71+
"active_gate",
7072
"align",
7173
"available_solvers",
7274
"licensed_solvers",

linopy/_active_gate.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""
2+
Legacy helper for padding a partial piecewise ``active`` gate.
3+
4+
This module is a temporary stopgap. Under the planned v1 arithmetic
5+
semantics (#717) the bare idiom ``active.reindex(coords).fillna(fill_value)``
6+
is correct on its own, so :func:`active_gate` is expected to be deprecated
7+
and this file removed once v1 lands. Keeping it isolated makes that
8+
removal a single-file delete.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Mapping
14+
from typing import TYPE_CHECKING, Any
15+
16+
from linopy.piecewise import _to_linexpr, _warn_evolving_api
17+
18+
if TYPE_CHECKING:
19+
from linopy.expressions import LinearExpression
20+
from linopy.types import LinExprLike
21+
22+
23+
def active_gate(
24+
active: LinExprLike,
25+
coords: Mapping[Any, Any],
26+
fill_value: float = 1,
27+
) -> LinearExpression:
28+
r"""
29+
Pad a partial ``active`` gate to full coverage for piecewise gating.
30+
31+
Reindexes ``active`` to ``coords`` and fills missing/masked entries with
32+
``fill_value`` (``1`` = always active, ``0`` = always off), so a gate
33+
defined over only a subset of :meth:`~linopy.Model.add_piecewise_formulation`'s
34+
coordinate does not force the uncovered entries to zero. Equivalent to the
35+
v1 idiom ``active.reindex(coords).fillna(fill_value)`` but correct under
36+
legacy too (see the module docstring).
37+
38+
.. code-block:: python
39+
40+
gate = active_gate(status, {"component": components})
41+
m.add_piecewise_formulation((power, xs), (fuel, ys), active=gate)
42+
43+
Parameters
44+
----------
45+
active : Variable or LinearExpression
46+
The (possibly partial) gate expression.
47+
coords : mapping of dim to labels
48+
Reindex target, passed straight to ``reindex``; unlisted dims
49+
broadcast.
50+
fill_value : float, default 1
51+
Value for missing/masked entries (``1`` = on, ``0`` = off).
52+
53+
Returns
54+
-------
55+
LinearExpression
56+
The padded gate, suitable to pass as ``active=``.
57+
58+
Warns
59+
-----
60+
EvolvingAPIWarning
61+
Part of the evolving piecewise API; may be refined.
62+
"""
63+
_warn_evolving_api(
64+
"active_gate",
65+
"piecewise: active_gate is a new API; its signature and the way it "
66+
"resolves missing/masked entries may be refined in minor releases. "
67+
"It is primarily a legacy stopgap and may be removed once legacy "
68+
"semantics are dropped. This warning fires once per session; "
69+
"silence with "
70+
'`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.',
71+
)
72+
gate = _to_linexpr(active).reindex(coords)
73+
term_dims = [d for d in gate.vars.dims if d not in gate.coord_dims]
74+
present = (gate.vars >= 0).any(term_dims)
75+
return gate.where(present, fill_value)

linopy/piecewise.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
# most once per process. Without dedup, a single model build emits the
6565
# verbose warning hundreds of times and drowns out other output.
6666
_EvolvingApiKey: TypeAlias = Literal[
67-
"tangent_lines", "add_piecewise_formulation", "Slopes"
67+
"tangent_lines", "add_piecewise_formulation", "Slopes", "active_gate"
6868
]
6969
_emitted_evolving_warnings: set[_EvolvingApiKey] = set()
7070

@@ -838,6 +838,36 @@ def tangent_lines(
838838
# ---------------------------------------------------------------------------
839839

840840

841+
def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> None:
842+
"""
843+
Reject an ``active`` gate that does not cover the formulation.
844+
845+
Entries where ``active`` is missing (a strict subset) or masked would
846+
otherwise be gated as if ``active=0`` and forced to zero. Such gates
847+
must be padded to full coverage (e.g. via :func:`active_gate`) before
848+
use. Dimensions absent from ``active`` broadcast and are not checked.
849+
"""
850+
skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS)
851+
indexers = {
852+
d: reference.indexes[d]
853+
for d in active.coord_dims
854+
if d in reference.indexes and d not in skip
855+
}
856+
aligned = active.reindex(indexers) if indexers else active
857+
term_dims = [d for d in aligned.vars.dims if d not in aligned.coord_dims]
858+
has_variable = (aligned.vars >= 0).any(term_dims)
859+
dangling = ((aligned.vars < 0) & aligned.coeffs.notnull()).any(term_dims)
860+
covered = has_variable | (aligned.const.notnull() & ~dangling)
861+
if not bool(covered.all()):
862+
raise ValueError(
863+
"`active` is not defined over the full coordinate of the "
864+
"piecewise formulation; it has missing or masked entries that "
865+
"would be gated to zero. Pad it to full coverage first, e.g. "
866+
"`active=linopy.active_gate(active, {dim: labels})` (missing "
867+
"entries become always-active), or pass a fully-defined `active`."
868+
)
869+
870+
841871
def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool:
842872
"""
843873
Validate that two breakpoint arrays have compatible shapes.
@@ -1118,6 +1148,10 @@ def add_piecewise_formulation(
11181148
``active=0``, all auxiliary variables are forced to zero.
11191149
Not supported with ``method="lp"``.
11201150
1151+
``active`` must cover the formulation's full coordinate; a partial
1152+
gate (subset or masked) is rejected. Pad it with
1153+
:func:`~linopy.active_gate` to leave missing entries ungated.
1154+
11211155
With all-equality tuples (the default), the output is then pinned
11221156
to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation
11231157
only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0
@@ -1286,6 +1320,8 @@ def add_piecewise_formulation(
12861320
link_coords.append(f"_pwl_{i}")
12871321

12881322
active_expr = _to_linexpr(active) if active is not None else None
1323+
if active_expr is not None:
1324+
_validate_active_coverage(active_expr, bp_list[0])
12891325

12901326
if signed_idx is None:
12911327
inputs = _PwlInputs(

0 commit comments

Comments
 (0)