|
64 | 64 | # most once per process. Without dedup, a single model build emits the |
65 | 65 | # verbose warning hundreds of times and drowns out other output. |
66 | 66 | _EvolvingApiKey: TypeAlias = Literal[ |
67 | | - "tangent_lines", "add_piecewise_formulation", "Slopes" |
| 67 | + "tangent_lines", "add_piecewise_formulation", "Slopes", "active_gate" |
68 | 68 | ] |
69 | 69 | _emitted_evolving_warnings: set[_EvolvingApiKey] = set() |
70 | 70 |
|
@@ -838,6 +838,36 @@ def tangent_lines( |
838 | 838 | # --------------------------------------------------------------------------- |
839 | 839 |
|
840 | 840 |
|
| 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 | + |
841 | 871 | def _validate_breakpoint_shapes(bp_a: DataArray, bp_b: DataArray) -> bool: |
842 | 872 | """ |
843 | 873 | Validate that two breakpoint arrays have compatible shapes. |
@@ -1118,6 +1148,10 @@ def add_piecewise_formulation( |
1118 | 1148 | ``active=0``, all auxiliary variables are forced to zero. |
1119 | 1149 | Not supported with ``method="lp"``. |
1120 | 1150 |
|
| 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 | +
|
1121 | 1155 | With all-equality tuples (the default), the output is then pinned |
1122 | 1156 | to ``0``. With a bounded tuple (``"<="`` / ``">="``), deactivation |
1123 | 1157 | only pushes the signed bound to ``0`` (the output is ≤ 0 or ≥ 0 |
@@ -1286,6 +1320,8 @@ def add_piecewise_formulation( |
1286 | 1320 | link_coords.append(f"_pwl_{i}") |
1287 | 1321 |
|
1288 | 1322 | 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]) |
1289 | 1325 |
|
1290 | 1326 | if signed_idx is None: |
1291 | 1327 | inputs = _PwlInputs( |
|
0 commit comments