From c9c329802fc3f0702269dd7440d6003a4a43defd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:45:16 +0200 Subject: [PATCH 1/5] feat(piecewise): partial `active` gate support via active_gate helper (#796) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- doc/api.rst | 1 + doc/piecewise-linear-constraints.rst | 16 +++ doc/release_notes.rst | 1 + linopy/__init__.py | 2 + linopy/_active_gate.py | 75 ++++++++++ linopy/piecewise.py | 38 ++++- test/test_piecewise_active_gate.py | 203 +++++++++++++++++++++++++++ 7 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 linopy/_active_gate.py create mode 100644 test/test_piecewise_active_gate.py diff --git a/doc/api.rst b/doc/api.rst index 6fb3434f2..7dfce7902 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -480,6 +480,7 @@ Construction helpers piecewise.breakpoints piecewise.segments piecewise.Slopes + active_gate PiecewiseFormulation -------------------- diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 2acf886da..1881f6574 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -565,6 +565,22 @@ manual gating. variable: combined with the ``y ≤ 0`` constraint from deactivation, this forces ``y = 0`` automatically. +Partial gates +^^^^^^^^^^^^^ + +``active`` must cover the formulation's full coordinate; a gate defined +over only a subset (or with masked entries) is rejected. Pad it with +:func:`~linopy.active_gate`, which leaves missing entries always-active +(``fill_value=1``) or off (``0``) — handy when one formulation mixes +committable and non-committable units sharing a single ``status``: + +.. code-block:: python + + gate = linopy.active_gate(status, {"unit": units}) + m.add_piecewise_formulation( + (power, [30, 60, 100]), (fuel, [40, 90, 170]), active=gate + ) + Auto-broadcasting ~~~~~~~~~~~~~~~~~ diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7e849a421..e491e4c66 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -20,6 +20,7 @@ Upcoming Version *Other* * ``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) +* 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) **Deprecations** diff --git a/linopy/__init__.py b/linopy/__init__.py index b813f71d5..794a105ca 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -12,6 +12,7 @@ # Note: For intercepting multiplications between xarray dataarrays, Variables and Expressions # we need to extend their __mul__ functions with a quick special case import linopy.monkey_patch_xarray # noqa: F401 +from linopy._active_gate import active_gate from linopy.alignment import align from linopy.config import options from linopy.constants import ( @@ -67,6 +68,7 @@ "SolverFeature", "Variable", "Variables", + "active_gate", "align", "available_solvers", "licensed_solvers", diff --git a/linopy/_active_gate.py b/linopy/_active_gate.py new file mode 100644 index 000000000..b17699c64 --- /dev/null +++ b/linopy/_active_gate.py @@ -0,0 +1,75 @@ +""" +Legacy helper for padding a partial piecewise ``active`` gate. + +This module is a temporary stopgap. Under the planned v1 arithmetic +semantics (#717) the bare idiom ``active.reindex(coords).fillna(fill_value)`` +is correct on its own, so :func:`active_gate` is expected to be deprecated +and this file removed once v1 lands. Keeping it isolated makes that +removal a single-file delete. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +from linopy.piecewise import _to_linexpr, _warn_evolving_api + +if TYPE_CHECKING: + from linopy.expressions import LinearExpression + from linopy.types import LinExprLike + + +def active_gate( + active: LinExprLike, + coords: Mapping[Any, Any], + fill_value: float = 1, +) -> LinearExpression: + r""" + Pad a partial ``active`` gate to full coverage for piecewise gating. + + Reindexes ``active`` to ``coords`` and fills missing/masked entries with + ``fill_value`` (``1`` = always active, ``0`` = always off), so a gate + defined over only a subset of :meth:`~linopy.Model.add_piecewise_formulation`'s + coordinate does not force the uncovered entries to zero. Equivalent to the + v1 idiom ``active.reindex(coords).fillna(fill_value)`` but correct under + legacy too (see the module docstring). + + .. code-block:: python + + gate = active_gate(status, {"component": components}) + m.add_piecewise_formulation((power, xs), (fuel, ys), active=gate) + + Parameters + ---------- + active : Variable or LinearExpression + The (possibly partial) gate expression. + coords : mapping of dim to labels + Reindex target, passed straight to ``reindex``; unlisted dims + broadcast. + fill_value : float, default 1 + Value for missing/masked entries (``1`` = on, ``0`` = off). + + Returns + ------- + LinearExpression + The padded gate, suitable to pass as ``active=``. + + Warns + ----- + EvolvingAPIWarning + Part of the evolving piecewise API; may be refined. + """ + _warn_evolving_api( + "active_gate", + "piecewise: active_gate is a new API; its signature and the way it " + "resolves missing/masked entries may be refined in minor releases. " + "It is primarily a legacy stopgap and may be removed once legacy " + "semantics are dropped. This warning fires once per session; " + "silence with " + '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', + ) + gate = _to_linexpr(active).reindex(coords) + term_dims = [d for d in gate.vars.dims if d not in gate.coord_dims] + present = (gate.vars >= 0).any(term_dims) + return gate.where(present, fill_value) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 25a0ce17c..bf2a02aec 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -64,7 +64,7 @@ # most once per process. Without dedup, a single model build emits the # verbose warning hundreds of times and drowns out other output. _EvolvingApiKey: TypeAlias = Literal[ - "tangent_lines", "add_piecewise_formulation", "Slopes" + "tangent_lines", "add_piecewise_formulation", "Slopes", "active_gate" ] _emitted_evolving_warnings: set[_EvolvingApiKey] = set() @@ -838,6 +838,36 @@ def tangent_lines( # --------------------------------------------------------------------------- +def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> None: + """ + Reject an ``active`` gate that does not cover the formulation. + + Entries where ``active`` is missing (a strict subset) or masked would + otherwise be gated as if ``active=0`` and forced to zero. Such gates + must be padded to full coverage (e.g. via :func:`active_gate`) before + use. Dimensions absent from ``active`` broadcast and are not checked. + """ + skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS) + indexers = { + d: reference.indexes[d] + for d in active.coord_dims + if d in reference.indexes and d not in skip + } + aligned = active.reindex(indexers) if indexers else active + term_dims = [d for d in aligned.vars.dims if d not in aligned.coord_dims] + has_variable = (aligned.vars >= 0).any(term_dims) + dangling = ((aligned.vars < 0) & aligned.coeffs.notnull()).any(term_dims) + covered = has_variable | (aligned.const.notnull() & ~dangling) + if not bool(covered.all()): + raise ValueError( + "`active` is not defined over the full coordinate of the " + "piecewise formulation; it has missing or masked entries that " + "would be gated to zero. Pad it to full coverage first, e.g. " + "`active=linopy.active_gate(active, {dim: labels})` (missing " + "entries become always-active), or pass a fully-defined `active`." + ) + + def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: """ Validate that two breakpoint arrays have compatible shapes. @@ -1118,6 +1148,10 @@ def add_piecewise_formulation( ``active=0``, all auxiliary variables are forced to zero. Not supported with ``method="lp"``. + ``active`` must cover the formulation's full coordinate; a partial + gate (subset or masked) is rejected. Pad it with + :func:`~linopy.active_gate` to leave missing entries ungated. + With all-equality tuples (the default), the output is then pinned to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 @@ -1286,6 +1320,8 @@ def add_piecewise_formulation( link_coords.append(f"_pwl_{i}") active_expr = _to_linexpr(active) if active is not None else None + if active_expr is not None: + _validate_active_coverage(active_expr, bp_list[0]) if signed_idx is None: inputs = _PwlInputs( diff --git a/test/test_piecewise_active_gate.py b/test/test_piecewise_active_gate.py new file mode 100644 index 000000000..9dccdb145 --- /dev/null +++ b/test/test_piecewise_active_gate.py @@ -0,0 +1,203 @@ +""" +Tests for the partial-``active`` gate and the ``active_gate`` helper (#796). + +Kept in a dedicated module because ``active_gate`` is a temporary legacy +stopgap (see ``linopy/_active_gate.py``): once the v1 arithmetic semantics +(#717) land, the helper is expected to be deprecated and these tests removed +or rewritten against the bare ``reindex().fillna()`` idiom. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal, TypeAlias + +import numpy as np +import pandas as pd +import pytest + +from linopy import Model, active_gate, available_solvers, segments +from linopy.solver_capabilities import ( + SolverFeature, + get_available_solvers_with_feature, +) + +Method: TypeAlias = Literal["sos2", "incremental", "lp", "auto"] +GateBuilder: TypeAlias = Callable[[Model], Any] + +_any_solvers = [ + s for s in ["highs", "gurobi", "glpk", "cplex"] if s in available_solvers +] +_sos2_solvers = get_available_solvers_with_feature( + SolverFeature.SOS_CONSTRAINTS, available_solvers +) + + +# ``active`` is meaningful only for the committable subset {a, c}; "b" stays +# ungated. The partial-gate shapes below all leave "b" as the gap. +_PWL_GENS = pd.Index(["a", "b", "c"], name="gen") +_COMMITTABLE = pd.Index(["a", "c"], name="gen") + + +def _subset_gate(m: Model) -> Any: + """``active`` indexed over a strict subset of the formulation's dim.""" + return m.add_variables(binary=True, coords=[_COMMITTABLE], name="u") + + +def _masked_gate(m: Model) -> Any: + """``active`` over the full dim but masked where it does not apply.""" + mask = pd.Series([True, False, True], index=_PWL_GENS) + return m.add_variables(binary=True, coords=[_PWL_GENS], name="u", mask=mask) + + +_PARTIAL_GATES = [ + pytest.param(_subset_gate, id="strict-subset"), + pytest.param(_masked_gate, id="masked"), +] + + +def _full_gate(m: Model) -> Any: + return m.add_variables(binary=True, coords=[_PWL_GENS], name="u") + + +def _scalar_gate(m: Model) -> Any: + return m.add_variables(binary=True, name="u") + + +def _padded(make: GateBuilder, fill_value: float = 1) -> GateBuilder: + return lambda m: active_gate(make(m), {"gen": _PWL_GENS}, fill_value) + + +# (builder, should_raise): raw partial gates are rejected; padded/full/scalar ok. +_COVERAGE_CASES = [ + pytest.param(_subset_gate, True, id="strict-subset-raises"), + pytest.param(_masked_gate, True, id="masked-raises"), + pytest.param(_padded(_subset_gate), False, id="padded-subset-ok"), + pytest.param(_padded(_masked_gate), False, id="padded-masked-ok"), + pytest.param(_padded(_subset_gate, 0), False, id="padded-off-ok"), + pytest.param(_full_gate, False, id="full-ok"), + pytest.param(_scalar_gate, False, id="scalar-ok"), +] + + +def _solve_partial_gate( + solver_name: str, + make_active: GateBuilder, + *, + method: Method, + disjunctive: bool = False, +) -> None: + """Pad a partial gate, force the committable units off, demand "b" runs.""" + m = Model() + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") + u = make_active(m) + gate = active_gate(u, {"gen": _PWL_GENS}) + if disjunctive: + m.add_piecewise_formulation( + (x, segments([[0.0, 50.0], [50.0, 100.0]])), + (y, segments([[0.0, 10.0], [10.0, 50.0]])), + active=gate, + ) + else: + m.add_piecewise_formulation( + (x, [0, 50, 100]), (y, [0, 10, 50]), active=gate, method=method + ) + m.add_constraints(u <= 0, name="force_off") + m.add_constraints(x.sel(gen="b") >= 50, name="demand") + m.add_objective(y.sum(), sense="min") + status, _ = m.solve(solver_name=solver_name) + assert status == "ok" + np.testing.assert_allclose(float(x.solution.sel(gen="a")), 0, atol=1e-4) + np.testing.assert_allclose(float(x.solution.sel(gen="c")), 0, atol=1e-4) + np.testing.assert_allclose(float(x.solution.sel(gen="b")), 50, atol=1e-4) + np.testing.assert_allclose(float(y.solution.sel(gen="b")), 10, atol=1e-4) + + +class TestActiveGateHelper: + """``active_gate`` pads a partial gate; gaps -> ``fill_value``.""" + + @pytest.mark.parametrize("fill_value", [1, 0]) + @pytest.mark.parametrize( + "make_active", + [*_PARTIAL_GATES, pytest.param(lambda m: 2 * _subset_gate(m), id="linexpr")], + ) + def test_fills_gap(self, make_active: GateBuilder, fill_value: float) -> None: + gate = active_gate(make_active(Model()), {"gen": _PWL_GENS}, fill_value) + assert gate.const.sel(gen="b").item() == fill_value + assert bool((gate.vars.sel(gen="b") < 0).all()) + assert bool((gate.vars.sel(gen="a") >= 0).any()) + + +class TestPartialActiveValidation: + """``add_piecewise_formulation`` rejects an under-defined ``active`` (#796).""" + + @pytest.mark.parametrize("make_active, should_raise", _COVERAGE_CASES) + def test_coverage(self, make_active: GateBuilder, should_raise: bool) -> None: + m = Model() + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") + + def build() -> None: + m.add_piecewise_formulation( + (x, [0, 50, 100]), + (y, [0, 10, 50]), + active=make_active(m), + method="incremental", + ) + + if should_raise: + with pytest.raises(ValueError, match="active_gate"): + build() + else: + build() + + def test_lower_dimensional_active_broadcasts(self) -> None: + """A gate missing an entire dim broadcasts and must not be rejected.""" + ts = pd.Index([0, 1], name="t") + m = Model() + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS, ts], name="x") + y = m.add_variables(lower=0, coords=[_PWL_GENS, ts], name="y") + u = m.add_variables(binary=True, coords=[_PWL_GENS], name="u") + m.add_piecewise_formulation( + (x, [0, 50, 100]), (y, [0, 10, 50]), active=u, method="incremental" + ) + + +@pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available") +class TestSolverPartialActiveGate: + """End-to-end: a padded partial gate leaves ungated units free (#796).""" + + @pytest.fixture(params=_any_solvers) + def solver_name(self, request: pytest.FixtureRequest) -> str: + return request.param + + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) + def test_incremental(self, solver_name: str, make_active: GateBuilder) -> None: + _solve_partial_gate(solver_name, make_active, method="incremental") + + +@pytest.mark.skipif(len(_sos2_solvers) == 0, reason="No SOS2-capable solver") +class TestSolverPartialActiveGateSOS2: + @pytest.fixture(params=_sos2_solvers) + def solver_name(self, request: pytest.FixtureRequest) -> str: + return request.param + + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) + @pytest.mark.parametrize( + "method, disjunctive", + [ + pytest.param("sos2", False, id="sos2"), + pytest.param("auto", True, id="disjunctive"), + ], + ) + def test_solves( + self, + solver_name: str, + make_active: GateBuilder, + method: Method, + disjunctive: bool, + ) -> None: + _solve_partial_gate( + solver_name, make_active, method=method, disjunctive=disjunctive + ) From 16fc4a2e50f9ead0ebb7291a607a93f1ff04ea2d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:54:29 +0200 Subject: [PATCH 2/5] 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) --- linopy/_active_gate.py | 4 +--- linopy/piecewise.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/linopy/_active_gate.py b/linopy/_active_gate.py index b17699c64..d0d00bf61 100644 --- a/linopy/_active_gate.py +++ b/linopy/_active_gate.py @@ -70,6 +70,4 @@ def active_gate( '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', ) gate = _to_linexpr(active).reindex(coords) - term_dims = [d for d in gate.vars.dims if d not in gate.coord_dims] - present = (gate.vars >= 0).any(term_dims) - return gate.where(present, fill_value) + return gate.where(gate.has_terms, fill_value) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index bf2a02aec..5ec131e2f 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -855,9 +855,8 @@ def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> } aligned = active.reindex(indexers) if indexers else active term_dims = [d for d in aligned.vars.dims if d not in aligned.coord_dims] - has_variable = (aligned.vars >= 0).any(term_dims) dangling = ((aligned.vars < 0) & aligned.coeffs.notnull()).any(term_dims) - covered = has_variable | (aligned.const.notnull() & ~dangling) + covered = aligned.has_terms | (aligned.const.notnull() & ~dangling) if not bool(covered.all()): raise ValueError( "`active` is not defined over the full coordinate of the " From 3f680c52ea6251e34386d163e2b8d1e65bbc897d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:21:35 +0200 Subject: [PATCH 3/5] refactor(piecewise): replace active_gate helper with active_fill parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- doc/api.rst | 1 - doc/piecewise-linear-constraints.rst | 14 ++- doc/release_notes.rst | 2 +- linopy/__init__.py | 2 - linopy/_active_gate.py | 73 ------------ linopy/piecewise.py | 54 ++++++--- ..._gate.py => test_piecewise_active_fill.py} | 107 +++++++++++------- 7 files changed, 110 insertions(+), 143 deletions(-) delete mode 100644 linopy/_active_gate.py rename test/{test_piecewise_active_gate.py => test_piecewise_active_fill.py} (63%) diff --git a/doc/api.rst b/doc/api.rst index 7dfce7902..6fb3434f2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -480,7 +480,6 @@ Construction helpers piecewise.breakpoints piecewise.segments piecewise.Slopes - active_gate PiecewiseFormulation -------------------- diff --git a/doc/piecewise-linear-constraints.rst b/doc/piecewise-linear-constraints.rst index 1881f6574..c57d26943 100644 --- a/doc/piecewise-linear-constraints.rst +++ b/doc/piecewise-linear-constraints.rst @@ -569,18 +569,20 @@ Partial gates ^^^^^^^^^^^^^ ``active`` must cover the formulation's full coordinate; a gate defined -over only a subset (or with masked entries) is rejected. Pad it with -:func:`~linopy.active_gate`, which leaves missing entries always-active -(``fill_value=1``) or off (``0``) — handy when one formulation mixes -committable and non-committable units sharing a single ``status``: +over only a subset (or with masked entries) is rejected unless +``active_fill`` is set. ``active_fill`` gates the missing entries as +always-active (``1``) or always-off (``0``) — handy when one formulation +mixes committable and non-committable units sharing a single ``status``: .. code-block:: python - gate = linopy.active_gate(status, {"unit": units}) m.add_piecewise_formulation( - (power, [30, 60, 100]), (fuel, [40, 90, 170]), active=gate + (power, [30, 60, 100]), (fuel, [40, 90, 170]), active=status, active_fill=1 ) +``active_fill`` is transitional: under v1 semantics, pad ``active`` +explicitly with ``active.reindex(coords).fillna(value)`` instead. + Auto-broadcasting ~~~~~~~~~~~~~~~~~ diff --git a/doc/release_notes.rst b/doc/release_notes.rst index e491e4c66..80f9076e1 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -20,7 +20,7 @@ Upcoming Version *Other* * ``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) -* 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) +* ``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) **Deprecations** diff --git a/linopy/__init__.py b/linopy/__init__.py index 794a105ca..b813f71d5 100644 --- a/linopy/__init__.py +++ b/linopy/__init__.py @@ -12,7 +12,6 @@ # Note: For intercepting multiplications between xarray dataarrays, Variables and Expressions # we need to extend their __mul__ functions with a quick special case import linopy.monkey_patch_xarray # noqa: F401 -from linopy._active_gate import active_gate from linopy.alignment import align from linopy.config import options from linopy.constants import ( @@ -68,7 +67,6 @@ "SolverFeature", "Variable", "Variables", - "active_gate", "align", "available_solvers", "licensed_solvers", diff --git a/linopy/_active_gate.py b/linopy/_active_gate.py deleted file mode 100644 index d0d00bf61..000000000 --- a/linopy/_active_gate.py +++ /dev/null @@ -1,73 +0,0 @@ -""" -Legacy helper for padding a partial piecewise ``active`` gate. - -This module is a temporary stopgap. Under the planned v1 arithmetic -semantics (#717) the bare idiom ``active.reindex(coords).fillna(fill_value)`` -is correct on its own, so :func:`active_gate` is expected to be deprecated -and this file removed once v1 lands. Keeping it isolated makes that -removal a single-file delete. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import TYPE_CHECKING, Any - -from linopy.piecewise import _to_linexpr, _warn_evolving_api - -if TYPE_CHECKING: - from linopy.expressions import LinearExpression - from linopy.types import LinExprLike - - -def active_gate( - active: LinExprLike, - coords: Mapping[Any, Any], - fill_value: float = 1, -) -> LinearExpression: - r""" - Pad a partial ``active`` gate to full coverage for piecewise gating. - - Reindexes ``active`` to ``coords`` and fills missing/masked entries with - ``fill_value`` (``1`` = always active, ``0`` = always off), so a gate - defined over only a subset of :meth:`~linopy.Model.add_piecewise_formulation`'s - coordinate does not force the uncovered entries to zero. Equivalent to the - v1 idiom ``active.reindex(coords).fillna(fill_value)`` but correct under - legacy too (see the module docstring). - - .. code-block:: python - - gate = active_gate(status, {"component": components}) - m.add_piecewise_formulation((power, xs), (fuel, ys), active=gate) - - Parameters - ---------- - active : Variable or LinearExpression - The (possibly partial) gate expression. - coords : mapping of dim to labels - Reindex target, passed straight to ``reindex``; unlisted dims - broadcast. - fill_value : float, default 1 - Value for missing/masked entries (``1`` = on, ``0`` = off). - - Returns - ------- - LinearExpression - The padded gate, suitable to pass as ``active=``. - - Warns - ----- - EvolvingAPIWarning - Part of the evolving piecewise API; may be refined. - """ - _warn_evolving_api( - "active_gate", - "piecewise: active_gate is a new API; its signature and the way it " - "resolves missing/masked entries may be refined in minor releases. " - "It is primarily a legacy stopgap and may be removed once legacy " - "semantics are dropped. This warning fires once per session; " - "silence with " - '`warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.', - ) - gate = _to_linexpr(active).reindex(coords) - return gate.where(gate.has_terms, fill_value) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index 5ec131e2f..c275acdab 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -64,7 +64,7 @@ # most once per process. Without dedup, a single model build emits the # verbose warning hundreds of times and drowns out other output. _EvolvingApiKey: TypeAlias = Literal[ - "tangent_lines", "add_piecewise_formulation", "Slopes", "active_gate" + "tangent_lines", "add_piecewise_formulation", "Slopes" ] _emitted_evolving_warnings: set[_EvolvingApiKey] = set() @@ -838,14 +838,18 @@ def tangent_lines( # --------------------------------------------------------------------------- -def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> None: +def _resolve_active( + active: LinearExpression, reference: DataArray, active_fill: int | None +) -> LinearExpression: """ - Reject an ``active`` gate that does not cover the formulation. - - Entries where ``active`` is missing (a strict subset) or masked would - otherwise be gated as if ``active=0`` and forced to zero. Such gates - must be padded to full coverage (e.g. via :func:`active_gate`) before - use. Dimensions absent from ``active`` broadcast and are not checked. + Resolve a possibly-partial ``active`` gate against the formulation. + + A gate defined over only a subset of the indexed dimension (or with + masked entries) would otherwise be gated as if ``active=0`` and forced + to zero. With ``active_fill is None`` such a gate is rejected; otherwise + the gaps are filled with ``active_fill`` (``1`` = always active, ``0`` = + always off). Dimensions absent from ``active`` broadcast and are left + untouched. """ skip = {BREAKPOINT_DIM, SEGMENT_DIM} | set(HELPER_DIMS) indexers = { @@ -854,6 +858,10 @@ def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> if d in reference.indexes and d not in skip } aligned = active.reindex(indexers) if indexers else active + + if active_fill is not None: + return aligned.where(aligned.has_terms, active_fill) + term_dims = [d for d in aligned.vars.dims if d not in aligned.coord_dims] dangling = ((aligned.vars < 0) & aligned.coeffs.notnull()).any(term_dims) covered = aligned.has_terms | (aligned.const.notnull() & ~dangling) @@ -861,10 +869,11 @@ def _validate_active_coverage(active: LinearExpression, reference: DataArray) -> raise ValueError( "`active` is not defined over the full coordinate of the " "piecewise formulation; it has missing or masked entries that " - "would be gated to zero. Pad it to full coverage first, e.g. " - "`active=linopy.active_gate(active, {dim: labels})` (missing " - "entries become always-active), or pass a fully-defined `active`." + "would be gated to zero. Pass `active_fill=1` to treat them as " + "always active (or `0` as always off), or pass a fully-defined " + "`active`." ) + return active def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: @@ -1064,6 +1073,7 @@ def add_piecewise_formulation( | tuple[LinExprLike, BreaksOrSlopes, Literal["==", "<=", ">="]], method: PWL_METHOD = "auto", active: LinExprLike | None = None, + active_fill: int | None = None, name: str | None = None, ) -> PiecewiseFormulation: r""" @@ -1148,8 +1158,8 @@ def add_piecewise_formulation( Not supported with ``method="lp"``. ``active`` must cover the formulation's full coordinate; a partial - gate (subset or masked) is rejected. Pad it with - :func:`~linopy.active_gate` to leave missing entries ungated. + gate (subset or masked) is rejected unless ``active_fill`` is set + (see below). With all-equality tuples (the default), the output is then pinned to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation @@ -1162,6 +1172,15 @@ def add_piecewise_formulation( automatically. For outputs that genuinely need both signs you must add the complementary bound yourself (e.g., a big-M coupling ``y`` with ``active``). + active_fill : int, optional + Fill value for entries where ``active`` is missing/masked: ``1`` + treats them as always active (ungated), ``0`` as always off. When + ``None`` (the default) a partial ``active`` is rejected instead. + Useful when one formulation mixes gated and ungated entities (e.g. + committable and non-committable units sharing a ``status``). + Transitional convenience: under v1 semantics, pad ``active`` + explicitly with ``active.reindex(coords).fillna(value)`` instead — + this parameter is slated for removal then. name : str, optional Base name for generated variables/constraints. @@ -1318,9 +1337,12 @@ def add_piecewise_formulation( # can't collide with the synthetic coord for an unnamed expr. link_coords.append(f"_pwl_{i}") - active_expr = _to_linexpr(active) if active is not None else None - if active_expr is not None: - _validate_active_coverage(active_expr, bp_list[0]) + if active is None: + if active_fill is not None: + raise ValueError("`active_fill` has no effect without `active`.") + active_expr = None + else: + active_expr = _resolve_active(_to_linexpr(active), bp_list[0], active_fill) if signed_idx is None: inputs = _PwlInputs( diff --git a/test/test_piecewise_active_gate.py b/test/test_piecewise_active_fill.py similarity index 63% rename from test/test_piecewise_active_gate.py rename to test/test_piecewise_active_fill.py index 9dccdb145..5738a3ea8 100644 --- a/test/test_piecewise_active_gate.py +++ b/test/test_piecewise_active_fill.py @@ -1,10 +1,11 @@ """ -Tests for the partial-``active`` gate and the ``active_gate`` helper (#796). +Tests for the ``active_fill`` parameter of ``add_piecewise_formulation`` (#796). -Kept in a dedicated module because ``active_gate`` is a temporary legacy -stopgap (see ``linopy/_active_gate.py``): once the v1 arithmetic semantics -(#717) land, the helper is expected to be deprecated and these tests removed -or rewritten against the bare ``reindex().fillna()`` idiom. +``active_fill`` is a transitional convenience: it pads a partial ``active`` +gate (a subset of the indexed dimension, or a masked gate) to full coverage. +It is slated for removal once the v1 arithmetic semantics (#717) make +``active.reindex(coords).fillna(value)`` correct on its own, so these tests +live in a dedicated module that can be dropped with the parameter. """ from __future__ import annotations @@ -15,8 +16,10 @@ import numpy as np import pandas as pd import pytest +import xarray as xr -from linopy import Model, active_gate, available_solvers, segments +from linopy import Model, available_solvers, segments +from linopy.piecewise import _resolve_active from linopy.solver_capabilities import ( SolverFeature, get_available_solvers_with_feature, @@ -50,12 +53,6 @@ def _masked_gate(m: Model) -> Any: return m.add_variables(binary=True, coords=[_PWL_GENS], name="u", mask=mask) -_PARTIAL_GATES = [ - pytest.param(_subset_gate, id="strict-subset"), - pytest.param(_masked_gate, id="masked"), -] - - def _full_gate(m: Model) -> Any: return m.add_variables(binary=True, coords=[_PWL_GENS], name="u") @@ -64,19 +61,21 @@ def _scalar_gate(m: Model) -> Any: return m.add_variables(binary=True, name="u") -def _padded(make: GateBuilder, fill_value: float = 1) -> GateBuilder: - return lambda m: active_gate(make(m), {"gen": _PWL_GENS}, fill_value) - +_PARTIAL_GATES = [ + pytest.param(_subset_gate, id="strict-subset"), + pytest.param(_masked_gate, id="masked"), +] -# (builder, should_raise): raw partial gates are rejected; padded/full/scalar ok. +# (builder, active_fill, should_raise): partial gates raise unless active_fill +# is set; full/scalar gates are always fine. _COVERAGE_CASES = [ - pytest.param(_subset_gate, True, id="strict-subset-raises"), - pytest.param(_masked_gate, True, id="masked-raises"), - pytest.param(_padded(_subset_gate), False, id="padded-subset-ok"), - pytest.param(_padded(_masked_gate), False, id="padded-masked-ok"), - pytest.param(_padded(_subset_gate, 0), False, id="padded-off-ok"), - pytest.param(_full_gate, False, id="full-ok"), - pytest.param(_scalar_gate, False, id="scalar-ok"), + pytest.param(_subset_gate, None, True, id="subset-None-raises"), + pytest.param(_masked_gate, None, True, id="masked-None-raises"), + pytest.param(_subset_gate, 1, False, id="subset-fill1-ok"), + pytest.param(_masked_gate, 1, False, id="masked-fill1-ok"), + pytest.param(_subset_gate, 0, False, id="subset-fill0-ok"), + pytest.param(_full_gate, None, False, id="full-ok"), + pytest.param(_scalar_gate, None, False, id="scalar-ok"), ] @@ -87,21 +86,25 @@ def _solve_partial_gate( method: Method, disjunctive: bool = False, ) -> None: - """Pad a partial gate, force the committable units off, demand "b" runs.""" + """Fill a partial gate, force the committable units off, demand "b" runs.""" m = Model() x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") u = make_active(m) - gate = active_gate(u, {"gen": _PWL_GENS}) if disjunctive: m.add_piecewise_formulation( (x, segments([[0.0, 50.0], [50.0, 100.0]])), (y, segments([[0.0, 10.0], [10.0, 50.0]])), - active=gate, + active=u, + active_fill=1, ) else: m.add_piecewise_formulation( - (x, [0, 50, 100]), (y, [0, 10, 50]), active=gate, method=method + (x, [0, 50, 100]), + (y, [0, 10, 50]), + active=u, + active_fill=1, + method=method, ) m.add_constraints(u <= 0, name="force_off") m.add_constraints(x.sel(gen="b") >= 50, name="demand") @@ -114,26 +117,29 @@ def _solve_partial_gate( np.testing.assert_allclose(float(y.solution.sel(gen="b")), 10, atol=1e-4) -class TestActiveGateHelper: - """``active_gate`` pads a partial gate; gaps -> ``fill_value``.""" +class TestResolveActiveFill: + """The private ``_resolve_active`` fills gaps with ``active_fill``.""" @pytest.mark.parametrize("fill_value", [1, 0]) - @pytest.mark.parametrize( - "make_active", - [*_PARTIAL_GATES, pytest.param(lambda m: 2 * _subset_gate(m), id="linexpr")], - ) + @pytest.mark.parametrize("make_active", _PARTIAL_GATES) def test_fills_gap(self, make_active: GateBuilder, fill_value: float) -> None: - gate = active_gate(make_active(Model()), {"gen": _PWL_GENS}, fill_value) + reference = xr.DataArray(np.zeros(len(_PWL_GENS)), coords=[_PWL_GENS]) + gate = _resolve_active(1 * make_active(Model()), reference, fill_value) assert gate.const.sel(gen="b").item() == fill_value - assert bool((gate.vars.sel(gen="b") < 0).all()) - assert bool((gate.vars.sel(gen="a") >= 0).any()) + assert bool((gate.vars.sel(gen="b") < 0).all()) # no variable at "b" + assert bool((gate.vars.sel(gen="a") >= 0).any()) # variable kept at "a" -class TestPartialActiveValidation: - """``add_piecewise_formulation`` rejects an under-defined ``active`` (#796).""" +class TestActiveFillValidation: + """``add_piecewise_formulation`` gates a partial ``active`` via ``active_fill``.""" - @pytest.mark.parametrize("make_active, should_raise", _COVERAGE_CASES) - def test_coverage(self, make_active: GateBuilder, should_raise: bool) -> None: + @pytest.mark.parametrize("make_active, active_fill, should_raise", _COVERAGE_CASES) + def test_coverage( + self, + make_active: GateBuilder, + active_fill: float | None, + should_raise: bool, + ) -> None: m = Model() x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") @@ -143,15 +149,28 @@ def build() -> None: (x, [0, 50, 100]), (y, [0, 10, 50]), active=make_active(m), + active_fill=active_fill, method="incremental", ) if should_raise: - with pytest.raises(ValueError, match="active_gate"): + with pytest.raises(ValueError, match="active_fill"): build() else: build() + def test_active_fill_without_active_raises(self) -> None: + m = Model() + x = m.add_variables(lower=0, upper=100, coords=[_PWL_GENS], name="x") + y = m.add_variables(lower=0, coords=[_PWL_GENS], name="y") + with pytest.raises(ValueError, match="without `active`"): + m.add_piecewise_formulation( + (x, [0, 50, 100]), + (y, [0, 10, 50]), + active_fill=1, + method="incremental", + ) + def test_lower_dimensional_active_broadcasts(self) -> None: """A gate missing an entire dim broadcasts and must not be rejected.""" ts = pd.Index([0, 1], name="t") @@ -165,8 +184,8 @@ def test_lower_dimensional_active_broadcasts(self) -> None: @pytest.mark.skipif(len(_any_solvers) == 0, reason="No solver available") -class TestSolverPartialActiveGate: - """End-to-end: a padded partial gate leaves ungated units free (#796).""" +class TestSolverActiveFill: + """End-to-end: ``active_fill`` leaves ungated units free (#796).""" @pytest.fixture(params=_any_solvers) def solver_name(self, request: pytest.FixtureRequest) -> str: @@ -178,7 +197,7 @@ def test_incremental(self, solver_name: str, make_active: GateBuilder) -> None: @pytest.mark.skipif(len(_sos2_solvers) == 0, reason="No SOS2-capable solver") -class TestSolverPartialActiveGateSOS2: +class TestSolverActiveFillSOS2: @pytest.fixture(params=_sos2_solvers) def solver_name(self, request: pytest.FixtureRequest) -> str: return request.param From 02e77a2e513ee27ddb5f975c99b8e43e7538f793 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:28:02 +0200 Subject: [PATCH 4/5] docs(piecewise): state explicitly that a partial active = subset labels or masked Co-Authored-By: Claude Opus 4.8 (1M context) --- linopy/piecewise.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/linopy/piecewise.py b/linopy/piecewise.py index c275acdab..4099af95d 100644 --- a/linopy/piecewise.py +++ b/linopy/piecewise.py @@ -868,10 +868,10 @@ def _resolve_active( if not bool(covered.all()): raise ValueError( "`active` is not defined over the full coordinate of the " - "piecewise formulation; it has missing or masked entries that " - "would be gated to zero. Pass `active_fill=1` to treat them as " - "always active (or `0` as always off), or pass a fully-defined " - "`active`." + "piecewise formulation: it is missing labels (a subset of the " + "coordinate) or has masked entries, which would be gated to " + "zero. Pass `active_fill=1` to treat those entries as always " + "active (or `0` as always off), or pass a fully-defined `active`." ) return active @@ -1157,9 +1157,10 @@ def add_piecewise_formulation( ``active=0``, all auxiliary variables are forced to zero. Not supported with ``method="lp"``. - ``active`` must cover the formulation's full coordinate; a partial - gate (subset or masked) is rejected unless ``active_fill`` is set - (see below). + ``active`` must cover the formulation's full coordinate. A + *partial* gate — one defined over only a subset of the coordinate's + labels, or carrying masked entries — is rejected unless + ``active_fill`` is set (see below). With all-equality tuples (the default), the output is then pinned to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation @@ -1173,9 +1174,10 @@ def add_piecewise_formulation( must add the complementary bound yourself (e.g., a big-M coupling ``y`` with ``active``). active_fill : int, optional - Fill value for entries where ``active`` is missing/masked: ``1`` - treats them as always active (ungated), ``0`` as always off. When - ``None`` (the default) a partial ``active`` is rejected instead. + Fill value for the gap entries of a partial ``active`` — those where + ``active`` has no label (a subset of the coordinate) or is masked: + ``1`` treats them as always active (ungated), ``0`` as always off. + When ``None`` (the default) a partial ``active`` is rejected instead. Useful when one formulation mixes gated and ungated entities (e.g. committable and non-committable units sharing a ``status``). Transitional convenience: under v1 semantics, pad ``active`` From feed36eabb34056c0aa1959a9ef186ea1cbbe902 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:42:49 +0200 Subject: [PATCH 5/5] test(piecewise): type active_fill as int to satisfy mypy Co-Authored-By: Claude Opus 4.8 (1M context) --- test/test_piecewise_active_fill.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test_piecewise_active_fill.py b/test/test_piecewise_active_fill.py index 5738a3ea8..0af04c8c3 100644 --- a/test/test_piecewise_active_fill.py +++ b/test/test_piecewise_active_fill.py @@ -122,7 +122,7 @@ class TestResolveActiveFill: @pytest.mark.parametrize("fill_value", [1, 0]) @pytest.mark.parametrize("make_active", _PARTIAL_GATES) - def test_fills_gap(self, make_active: GateBuilder, fill_value: float) -> None: + def test_fills_gap(self, make_active: GateBuilder, fill_value: int) -> None: reference = xr.DataArray(np.zeros(len(_PWL_GENS)), coords=[_PWL_GENS]) gate = _resolve_active(1 * make_active(Model()), reference, fill_value) assert gate.const.sel(gen="b").item() == fill_value @@ -137,7 +137,7 @@ class TestActiveFillValidation: def test_coverage( self, make_active: GateBuilder, - active_fill: float | None, + active_fill: int | None, should_raise: bool, ) -> None: m = Model()