Skip to content

Add public per-slot emptiness check on expressions (has_terms) — PyPSA reaches into .vars/_term internals #741

@FBumann

Description

@FBumann

Motivation

PyPSA's nodal balance constraint needs to know which slots of an expression have no live variable terms, and currently has to reach into linopy internals to find out (pypsa/optimization/constraints.py#L1166):

lhs = merge(exprs, join="outer").reindex(name=buses)
...
empty_nodal_balance = (lhs.vars == -1).all("_term")

if empty_nodal_balance.any():
    if (empty_nodal_balance & (rhs != 0)).any().item():
        raise ValueError("Empty LHS with non-zero RHS in nodal balance constraint.")
    mask = ~empty_nodal_balance
else:
    mask = None

Buses with no attached components end up with all-dead terms (vars = -1) after the outer merge + reindex. PyPSA needs them to (a) raise on demand at a disconnected bus, and (b) mask those constraint rows out.

vars and "_term" are internal representation details. Downstream code depending on them couples PyPSA to linopy's storage layout, and shows a gap in the public API.

Why the existing API doesn't cover it

  • expr.empty is a single bool for the whole expression.
  • expr.isnull() is (vars == -1).all(helper_dims) & const.isnull()absence (no terms and no constant). In the merge + reindex pattern above it happens to give the same answer (reindexed slots carry a NaN constant), but it tests a different concept: a slot whose constant has been filled — e.g. after fillna(0) — is no longer "null", yet still has no terms. The two concepts are also explicitly distinct in the v1 convention (feat: v1 semantic convention #717): dead terms at a present slot vs an absent slot.

So the check PyPSA actually means — "this constraint row has no variables" — has no public spelling.

Proposed API

@property
def has_terms(self) -> DataArray:
    """
    Boolean array, True at slots where the expression has at least one
    live term (vars != -1), reduced over the helper dims.
    """

on BaseExpression, so both LinearExpression and QuadraticExpression get it.

PyPSA's code then becomes internals-free:

has_terms = lhs.has_terms
if not has_terms.all():
    if (~has_terms & (rhs != 0)).any().item():
        raise ValueError("Empty LHS with non-zero RHS in nodal balance constraint.")
    mask = has_terms
else:
    mask = None

Relation to isnull()

has_terms and isnull() answer different questions and both have a place:

slot state isnull() ~has_terms
live terms False False
no terms, const = NaN (absent, e.g. after reindex) True True
no terms, const = 0 (present slot, e.g. after fillna(0)) False True

I'll follow up with a PR implementing this.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions