fix(piecewise): declare ragged breakpoints with mask= instead of inferring NaN - #885
Merged
FabianHofmann merged 2 commits intoAug 10, 2026
Merged
Conversation
…rring NaN Ragged curves — entities with different numbers of breakpoints — are stored densely along `_breakpoint` with the surplus slots left absent. Under v1 that tripped the §5 user-NaN guard from deep inside the formulation's arithmetic, with a message pointing at remedies that do not apply to breakpoint data. All formulation paths were affected: `lp` and `sos2` and `incremental` (with and without `active=`) and disjunctive. §5 is right to refuse the input: a shorter curve and a data error look identical, and linopy trusts NaN only from its own structural operations (§4). What was missing is a way to declare the absence. Add `mask=` to `add_piecewise_formulation` — §4's mechanism, already the vocabulary of `add_variables`/`add_constraints` — and make it the authoritative breakpoint mask. Legacy keeps inferring from NaN placement, now with a LinopySemanticsWarning. Once declared, the padding still must not reach the arithmetic as a *constant*: provenance is gone by the time a breakpoint table multiplies a variable, so §5 applies to it there. Zero those coefficients — the absence is already carried by the masked variable and propagates on its own (§6). `tangent_lines` has no mask to declare with, so it can only report; it now names raggedness rather than surfacing the generic user-NaN message. Closes #884 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FBumann
marked this pull request as ready for review
August 9, 2026 15:05
FabianHofmann
left a comment
Collaborator
There was a problem hiding this comment.
wonderful and faster than allowed. There is some references in the code (docstrings & comments) which point to the paragraphs of the convention. I think we should keep those out and all these would need to be updated once we change the convention. let's keep this information absolute
Comment on lines
+839
to
+841
| # No mask to declare absence on this low-level helper, so a ragged curve | ||
| # can only be reported (§5), not resolved — say so here rather than let | ||
| # the generic user-NaN message surface from the chord arithmetic. |
| """ | ||
| Make a breakpoint table safe to use as a *constant* operand. | ||
|
|
||
| Absent slots are marked with ``NaN`` (§2), but as soon as the table |
Collaborator
There was a problem hiding this comment.
reference to convention in code.
Collaborator
There was a problem hiding this comment.
make the docstring a one-liner
FabianHofmann
requested changes
Aug 10, 2026
|
|
||
| * Default internal integer labels to ``int32``, cutting memory ~25% and speeding up model build 10-35%. Models exceeding the int32 maximum (~2.1 billion labels) widen to ``int64`` automatically with a ``UserWarning``; pass ``Model(dtypes={"labels": np.int64})`` upfront to avoid the mid-build upcast (exposed read-only via ``Model.dtypes``). | ||
| * ``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) | ||
| * ``add_piecewise_formulation`` gained a ``mask`` parameter declaring which breakpoint slots hold a real breakpoint. It is needed for **ragged** curves — entities with different numbers of breakpoints — which are stored densely with the surplus slots left absent. Under v1 that absence must be declared (``mask=x_pts.notnull()``) rather than read off the NaN padding, since §5 does not let linopy tell a shorter curve from a data error; legacy keeps inferring it, with a ``LinopySemanticsWarning``. Previously ragged curves failed under v1 with a generic "NaN found in a user-supplied constant" raised from deep inside the formulation's arithmetic, on all of the ``lp``, ``sos2``, ``incremental`` and disjunctive paths. (https://github.com/PyPSA/linopy/issues/884) |
Collaborator
There was a problem hiding this comment.
Suggested change
| * ``add_piecewise_formulation`` gained a ``mask`` parameter declaring which breakpoint slots hold a real breakpoint. It is needed for **ragged** curves — entities with different numbers of breakpoints — which are stored densely with the surplus slots left absent. Under v1 that absence must be declared (``mask=x_pts.notnull()``) rather than read off the NaN padding, since §5 does not let linopy tell a shorter curve from a data error; legacy keeps inferring it, with a ``LinopySemanticsWarning``. Previously ragged curves failed under v1 with a generic "NaN found in a user-supplied constant" raised from deep inside the formulation's arithmetic, on all of the ``lp``, ``sos2``, ``incremental`` and disjunctive paths. (https://github.com/PyPSA/linopy/issues/884) | |
| * ``add_piecewise_formulation`` gained a ``mask`` parameter declaring which breakpoint slots hold a real breakpoint. It is needed for **ragged** curves — entities with different numbers of breakpoints — which are stored densely with the surplus slots left absent. Under v1 that absence must be declared (``mask=x_pts.notnull()``) rather than read off the NaN padding. ``` |
| """ | ||
| Make a breakpoint table safe to use as a *constant* operand. | ||
|
|
||
| Absent slots are marked with ``NaN`` (§2), but as soon as the table |
Collaborator
There was a problem hiding this comment.
make the docstring a one-liner
Comment on lines
+1264
to
+1281
| Which breakpoint slots hold a real breakpoint — ``True`` where one | ||
| exists, ``False`` where it is absent. Shaped like the breakpoint | ||
| arrays (entity dims × ``_breakpoint``), or anything that broadcasts | ||
| against them. | ||
|
|
||
| Needed only for **ragged** curves, where entities have different | ||
| numbers of breakpoints. These are stored densely with the surplus | ||
| slots left absent, and under v1 that absence has to be declared | ||
| rather than read off the data: a shorter curve and a stray NaN look | ||
| identical, so linopy refuses to guess (see the convention, §4/§5). | ||
| For breakpoints that are already NaN-padded, the declaration is | ||
| ``mask=x_pts.notnull()``. | ||
|
|
||
| Slots marked absent are excluded from the formulation: no auxiliary | ||
| variable is created for them and no constraint references them. | ||
| Passing a mask that hides a *present* value is allowed and drops | ||
| that breakpoint; a NaN at a slot the mask calls present is a data | ||
| error and raises. |
Collaborator
There was a problem hiding this comment.
should be more compact
Per review on #885: keep docstrings and comments absolute instead of citing convention paragraphs, shorten the _drop_absent docstring to a one-liner, compact the mask parameter docs, and apply the suggested trim of the release-note entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FabianHofmann
approved these changes
Aug 10, 2026
Collaborator
|
wonderful! let's merge this |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR fixes #884. It takes the simplest, strict path, disallowing nan like everywhere else, except if a matching mask is passed. This works without adding a new primitive like
Breaks, which would be another option but a bigger api change and less flexibility (or a complex, dataarray-like class).Note
The content below this line was generated by AI.
Stacked on #717 (
feat/arithmetic-convention). Fixes #884, which blocks PyPSA/PyPSA#1829.What this changes
Ragged curves — entities with different numbers of breakpoints — are stored densely along
_breakpointwith the surplus slots left absent. Under v1 that tripped the §5 user-NaN guard from deep inside the formulation's arithmetic:— advice that does not apply to breakpoint data, raised from
(delta_var * steps).sum(...)rather than from the input.§5 is right to refuse the input. A shorter curve and a stray NaN look identical, and linopy trusts NaN only from its own structural operations (§4). What was missing is a way to declare the absence. So:
add_piecewise_formulation(..., mask=...)— §4's mechanism, already the vocabulary ofadd_variables/add_constraints. It declares which(entity, breakpoint)slots hold a real breakpoint and becomes the authoritative breakpoint mask, replacing theisnull()inference. For NaN-padded input the declaration ismask=x_pts.notnull().LinopySemanticsWarning: it keeps inferring from NaN placement, so nothing breaks before opt-in.tangent_lineshas no mask to declare with, so it can only report; it now names raggedness instead of surfacing the generic user-NaN message.Scope: five paths, not three
The issue lists
lp,incremental, andincremental + active.sos2and disjunctive fail identically — they reach the padded slot through(lambda_var * links.eq_bp)instead. All five are covered.Resulting behaviour
mask=mask=declaredVerified across
lp/sos2/incremental/incremental + active/ disjunctive.Verification detail
lppath's LP file is byte-identical.incrementalLP file differs from legacy only by three redundant rows (-1.0 x5 <= -0.0) that legacy emits for fully-maskeddelta_bound/fill_orderconstraints and v1 correctly drops. Pre-existing v1 improvement, not from this PR.y: 0 → 60overx: 0 → 100,x=50givesy=30.test_declared_mask_matches_legacy_oraclereuses the oracle from the existing legacy-only padding tests (f_b(10) = 12.5) to assert the declared model is the model legacy inferred.ruffclean;mypyclean (the onesolvers.py:297error pre-exists on the base branch).Notes for review
test_lp_per_entity_nan_paddingandtest_sos2_per_entity_nan_paddingwere marked@pytest.mark.legacywith the comment "Legacy-only: NaN-as-mask in user input (see convention.md §5)". Those models now run under v1 too, viamask=.doc/design/convention.rstis untouched.Slopes(align="leading")is arguably inconsistent with §5 — it requiresvalues[0]to be NaN as a positional sentinel in user data. Out of scope here; worth a separate issue if you agree it is one.mask=x_pts.notnull()), not "linopy tolerates the NaN".Alternatives considered
Breakscarrier returned bybreakpoints()/segments()— carries provenance so raggeddictinput stays undeclared, but changes what those public factories return. Rejected in favour of zero new public types; the cost is that raggeddict/list input must now be declared too.