Skip to content

v1 semantics: ragged (NaN-padded) breakpoints raise "NaN found in a user-supplied constant" in add_piecewise_formulation #884

Description

@FabianHofmann

likely the last issue I found for fully integrating v1 into pypsa

Note

The content of this issue was generated with AI assistance.

Version Checks

Issue Description

Under linopy.options["semantics"] = "v1", add_piecewise_formulation raises

ValueError: NaN found in a user-supplied constant. linopy treats this as ambiguous: ...

for ragged breakpoints — i.e. entities with different numbers of breakpoints, NaN-padded along _breakpoint. That padding is linopy's own ragged representation: _pad_ragged builds it (np.full((len(keys), max_len), np.nan)), and _strip_nan / _has_trailing_nan_only consume it. So the NaN is structural, not a data error.

The formulation code already knows which pieces are padding — _add_incremental computes a piece_mask from bp_valid (piecewise.py:1930-1932) — but the arithmetic that trips the v1 NaN guard runs before that mask is applied:

  • _incremental_weighted (piecewise.py:1806) evaluates (delta_var * steps).sum(dim=piece_dim) + bp0_term, where steps are consecutive breakpoint differences and so are NaN in the padded slot.
  • _tangent_lines_impl (piecewise.py:769, called from _add_incremental at :1937) evaluates slopes * _to_linexpr(x) + intercepts with NaN intercepts from the same padding.

Under legacy this is silently tolerated, and linopy's own deprecation notice points straight at the site:

piecewise.py:1806: LinopySemanticsWarning: NaN in the multiplicative factor was silently
treated as 0 by legacy (so the variable was zeroed out at that slot). Under v1 this raises
ValueError.
  return (delta_var * steps).sum(dim=piece_dim) + bp0_term

All three formulation paths are affected (lp, incremental, and incremental with active=), so it is not specific to a method or to unit-commitment gating.

This blocks PyPSA's v1 migration (PyPSA/PyPSA#1829): piecewise cost/efficiency curves are naturally ragged across components, and the two remaining v1 test failures there both land in these two functions.

Reproducible Example

import numpy as np
import pandas as pd
import xarray as xr
import linopy
from linopy import Model, breakpoints
from linopy.constants import BREAKPOINT_DIM

names = pd.Index(["a", "b"], name="name")
dims = ["name", BREAKPOINT_DIM]

# 'a' has 3 breakpoints, 'b' only 2 -> trailing NaN pad
X = xr.DataArray([[0.0, 50.0, 100.0], [0.0, 100.0, np.nan]], coords={"name": names}, dims=dims)


def build(y_values, use_active):
    y = xr.DataArray(y_values, coords={"name": names}, dims=dims)
    m = Model()
    x_var = m.add_variables(0, 100, coords=[names], name="x")
    y_var = m.add_variables(coords=[names], name="y")
    kwargs = {}
    if use_active:
        status = m.add_variables(binary=True, coords=[names], name="s")
        kwargs["active"] = status.to_linexpr()
    m.add_piecewise_formulation(
        (y_var, breakpoints(y), ">="), (x_var, breakpoints(X)), name="pw", **kwargs
    )


CASES = {
    "lp (convex)": ([[0.0, 30.0, 100.0], [0.0, 60.0, np.nan]], False),
    "incremental (concave)": ([[0.0, 70.0, 100.0], [0.0, 60.0, np.nan]], False),
    "incremental + active": ([[0.0, 70.0, 100.0], [0.0, 60.0, np.nan]], True),
}

for mode in ("legacy", "v1"):
    linopy.options["semantics"] = mode
    for label, (y_values, use_active) in CASES.items():
        try:
            build(y_values, use_active)
            print(f"{mode:7s} | {label:22s} | OK")
        except Exception as e:
            print(f"{mode:7s} | {label:22s} | {type(e).__name__}")

Output:

legacy  | lp (convex)            | OK
legacy  | incremental (concave)  | OK
legacy  | incremental + active   | OK
v1      | lp (convex)            | ValueError
v1      | incremental (concave)  | ValueError
v1      | incremental + active   | ValueError

Expected Behavior

All six rows print OK. NaN padding along _breakpoint marks absent pieces, which linopy itself created and already detects via bp_valid / piece_mask — it should not be reported to the user as an ambiguous NaN constant.

Applying the existing piece_mask (or resolving the padded slots) before the arithmetic in _incremental_weighted and _tangent_lines_impl would fix both sites. If instead ragged input is meant to be rejected under v1, a dedicated error naming the ragged entity would be far easier to act on than the generic NaN-constant message, which gives no hint that padding is the cause.

Installed Versions

Details
linopy   0.8.0.post1.dev140+g346943317  (PR #717 head, 34694331)
xarray   2026.7.0
pandas   2.3.3
numpy    2.4.6
python   3.12.3

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions