Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ Construction helpers
piecewise.breakpoints
piecewise.segments
piecewise.Slopes
active_gate

PiecewiseFormulation
--------------------
Expand Down
16 changes: 16 additions & 0 deletions doc/piecewise-linear-constraints.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~~~~~

Expand Down
1 change: 1 addition & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
2 changes: 2 additions & 0 deletions linopy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -67,6 +68,7 @@
"SolverFeature",
"Variable",
"Variables",
"active_gate",
"align",
"available_solvers",
"licensed_solvers",
Expand Down
75 changes: 75 additions & 0 deletions linopy/_active_gate.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
FBumann marked this conversation as resolved.
Outdated
38 changes: 37 additions & 1 deletion linopy/piecewise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading