feat(piecewise): add sign parameter and LP method to add_piecewise_formulation#663
Conversation
Introduces a sign parameter ("==", "<=", ">=") with a first-tuple
convention: the first tuple's expression is the signed output; all
remaining tuples are treated as inputs forced to equality.
A new method="lp" uses pure tangent lines (no aux variables) for
2-variable inequality cases on convex/concave curves. method="auto"
automatically dispatches to LP when applicable, otherwise falls back
to SOS2/incremental with the sign applied to the output link.
Internally:
- sign="==" keeps a single stacked link (unchanged behaviour)
- sign!="==" splits: one stacked equality link for inputs plus one
output link carrying the sign
- LP adds per-segment chord constraints plus domain bounds on x
Uses the existing SIGNS / EQUAL / LESS_EQUAL / GREATER_EQUAL constants
from linopy.constants for validation and dispatch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New examples/piecewise-inequality-bounds.ipynb walks through the sign parameter, the first-tuple convention, and the LP/SOS2/incremental equivalence within the x-domain. Includes a feasibility region plot and demonstrates auto-dispatch + non-convex fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds the full mathematical formulation (equality, inequality, LP, incremental) as a dedicated markdown section, and a 3D Poly3DCollection plot showing the feasible ribbon for 3-variable sign='<=' — a 1-D curve in 3-D space extruded downward in the output axis. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Keeps matplotlib (consistent with other notebooks, no new deps) but renders the 3D ribbon in three side-by-side projections: perspective, (power, fuel) side view, (power, heat) top view. Easier to read than a single 3D plot in a static doc. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…oose For concave+">=" or convex+"<=", tangent lines give a feasible region that is a strict subset of the true hypograph/epigraph — rejecting points that satisfy the true constraint. This is wrong, not merely a loose relaxation. - Update error message in method="lp" to make this explicit - Correct the convexity×sign table in the notebook to mark the ✗ cases as "wrong region", not "loose" - Add tests covering concave+">=" and convex+"<=" auto-fallback + explicit lp raise Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Error messages should state the problem and point to a fix, not teach the theory. The detailed convexity × sign semantics live in the notebook/docs, not in runtime errors. Also removes the "strict subset" claim, which was true in common cases but not watertight at domain boundaries. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Users who care which formulation they got (e.g. LP vs MIP for performance)
can see the dispatch decision in the normal log output without checking
PiecewiseFormulation.method manually.
Example:
INFO linopy.piecewise: piecewise formulation 'pwl0': auto selected
method='lp' (sign='<=', 2 pairs)
Logged at info level, only when method='auto' (explicit choices are not
logged — the user already knows).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When method='auto' and the inequality case can't use LP (wrong number
of tuples, non-monotonic x, mismatched curvature, active=...), log an
info-level message explaining why before falling back to SOS2/incremental.
Example:
INFO linopy.piecewise: piecewise formulation 'pwl0': LP not applicable
(sign='<=' needs concave/linear curvature, got 'convex'); will use
SOS2/incremental instead
Factored the LP-eligibility check into a new _lp_eligibility helper that
returns (ok, reason) — used by auto dispatch to decide + log.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
🤖 Claude Code reviewOverall this is a well-motivated feature with a clean public API and a genuinely good notebook. But there are two real correctness bugs that let the LP path silently produce wrong feasible regions, plus several lesser issues. 🔴 Critical — correctness bugs1.
|
Adds a ``convexity`` attribute ({"convex", "concave", "linear", "mixed"}
or None) set automatically when the shape is well-defined (exactly two
tuples, non-disjunctive, strictly monotonic x). Widens two helper
signatures to ``LinearExpression | None`` / ``DataArray | None`` to
match their actual usage.
Adds PWL_METHODS and PWL_CONVEXITIES sets to back the runtime
validation; the user-facing ``Literal[...]`` hints remain the static
source of truth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
_detect_convexity previously treated a concave curve with decreasing x as convex (and vice-versa), because the slope sequence appears reversed when x descends. As a result, method="auto" could dispatch LP on a curvature+sign combination the implementation explicitly documents as "wrong region", and explicit method="lp" would accept the same case. Sort each entity's breakpoints by x ascending before classifying. Adds two regression tests covering auto-dispatch and explicit LP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
_add_lp built one chord constraint per breakpoint segment without honouring the breakpoint mask. For per-entity inputs where some entities have fewer breakpoints (NaN tail), the NaN slope/intercept became 0 in the constraint, producing a spurious ``y ≤ 0`` for the padded segments and forcing the output to zero. Compute a per-segment validity mask (both endpoints non-NaN) and pass it through to the chord constraint via ``_add_signed_link``. Also delegates the tangent-line construction to the existing public ``tangent_lines`` helper to remove the duplicated slope/intercept math. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Parameters block contradicted the prose and the implementation, which use the first-tuple convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collapse the per-slice numpy loop into an xarray-native classifier: NaN propagation through .diff() handles masked breakpoints, and multiplying the second-slope-difference by ``sign(dx.sum(...))`` keeps the ascending/descending-x invariance from the previous fix. Scope is deliberately single-curve; multi-entity inputs aggregate across entities. For N>2 variables (not supported by LP today) the right shape is a single-pair classifier plus a combinator at the call site — left for when the LP path generalizes. Adds TestDetectConvexity covering: basic convex/concave/linear/mixed, floating-point tolerance, too-few-points, ascending-vs-descending invariance, trailing-NaN padding, multi-entity same-shape, multi-entity mixed direction, multi-entity mixed curvature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active=0 pins auxiliary variables to zero, which under sign="==" forces the output to 0 exactly. Under sign="<=" or ">=" it only pushes the signed bound to 0 — the complementary side still falls back to the output variable's own upper/lower bound, which is often not what a reader expects from a "deactivated" unit. Call out the asymmetry in the ``active`` docstring and add a regression test that pins the current behaviour (minimising y under active=0 + sign="<=" goes to the variable's lb, not 0). A future change to auto-couple the complementary bound should flip that test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Parametrise the SOS2 regression over incremental as well, and add a matching test for the disjunctive (segments) path. All three methods show the same asymmetry: input pinned to 0 via the equality input link, output only signed-bounded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the docstring note actionable: the usual fuel/cost/heat outputs are naturally non-negative, so setting lower=0 on the output turns the documented sign="<=" + active=0 asymmetry into a non-issue (the variable bound combined with y ≤ 0 forces y = 0 automatically). Genuinely signed outputs still need the big-M coupling called out. Pins the recipe down with a test that maximises y under active=0 and asserts y = 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- method='lp' + active raises (silent would produce wrong model) - LP accepts a linear curve (convexity='linear', either sign) - method='auto' emits an INFO log when it skips LP - LP domain bound is enforced (x > x_max → infeasible) - LP matches SOS2 on multi-dim (entity) variables - LP vs SOS2 consistency on both sides of y ≤ f(x) - Disjunctive + sign='<=' is respected by the solver Placed in TestSignParameter (LP/sign behaviour) and TestDisjunctive (disjunctive solver) rather than a separate review-named bucket. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses review issues 8, 9, 10: - Introduces ``_PwlLinks``, a single dataclass carrying the stacked-for-lambda breakpoints plus the equality- and signed-side link expressions the three builders need. The EQUAL / non-EQUAL split lives in one place (``_build_links``) instead of being duplicated in ``_add_continuous`` and ``_add_disjunctive``. - ``_add_sos2``/``_add_incremental``/``_add_disjunctive`` drop from 9–11 parameters with correlated ``Optional`` pairs down to a short list taking the links struct. ``_add_incremental`` also loses its unused ``rhs`` parameter (incremental gates via ``delta <= active``, not via a convex-sum = rhs constraint). - ``_add_continuous`` becomes ~10 lines: it either dispatches LP via ``_try_lp`` (returns bool) or builds links and hands off to a single ``_resolve_sos2_vs_incremental`` helper before calling the chosen builder. No more 5-way ``method`` branching in one body. Behaviour is unchanged — same 147 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``_lp`` echoed the method name without saying what the constraint does. The LP formulation adds one chord-line constraint per segment (``y <= m·x + c`` per breakpoint pair), so ``_chord`` describes the actual object being added and is independent of which method built it. Reviewer-suggested alternative; also matches the chord-of-a- piecewise-curve framing used in the notebook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts: # linopy/piecewise.py
for more information, see https://pre-commit.ci
🤖 Claude Code review — round 2 (holistic, combined with #638)Reviewing this PR as a unit with #638, against Big pictureThe combined diff turns Code-wise, after the last round of refactors the module is in good shape: 🟠 The main gap: docs didn't keep up with the code
This is the single biggest blocker for me. A user reading the docs after merge will be misled. Either update this RST page in this PR, or carve it out as an explicit follow-up ticket in the description. Release notes — one bullet for all of #638 + all of #663 is hard to scan. The 🟠 Notebook coordinationYou now ship two piecewise notebooks:
After this PR the bare-
Right now a reader has no hint that two equivalent recipes exist. 🟡 netCDF round-trip drops
|
to_netcdf was dropping the convexity field; reload defaulted it to None (e.g. concave → None). Include it in the JSON payload and pass it back to the constructor on read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Compare all __slots__ (except the model back-reference) so the test auto-catches any future field the IO layer forgets to persist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thorough pass over the user-facing piecewise docs to match the current API (sign, method="lp", first-tuple convention) rather than the pre-PR equality-only surface. doc/piecewise-linear-constraints.rst: - Quick Start now shows both equality and inequality forms. - API block updated: sign in the signature, method now lists "lp". - New top-level section "The sign parameter — equality vs inequality" covering the first-tuple convention, math for 2-var <=, hypograph/ epigraph framing, and when to reach for inequality (primarily to unlock the LP chord formulation). Spells out the equality-is-often- the-right-call recommendation when curvature doesn't match sign. - Formulation Methods gains a full "LP (chord-line) formulation" subsection with the per-segment chord math, domain bound and the curvature+sign matching rule. The auto-dispatch intro lists LP as the first branch. - Every other formulation (SOS2/incremental/disjunctive) gets a short note on how it handles sign != "==". - "Generated variables and constraints" rewritten with the current suffix names (_link, _output_link, _chord, _domain_lo/_hi, _order_binary, _delta_bound, _binary_order, _active_bound) grouped per method. - Active parameter gains a note on the non-equality sign asymmetry with a pointer to the lower=0 recipe. - tangent_lines demoted: no longer a top-level API section; one pointer lives under the LP formulation for manual-control use. - See Also now links the new inequality-bounds notebook. examples/piecewise-linear-constraints.ipynb: - Section 4 rewritten from "Tangent lines — Concave efficiency bound" to "Inequality bounds — sign='<=' on a concave curve". Shows the one-liner add_piecewise_formulation((fuel, y), (power, x), sign="<=") and prints the resolved method/convexity to make the auto-LP dispatch visible. Outro points to the dedicated inequality notebook rather than showing the low-level tangent_lines path. doc/index.rst + doc/piecewise-inequality-bounds-tutorial.nblink: - Register the existing examples/piecewise-inequality-bounds.ipynb as a Sphinx page under the User Guide toctree so it's discoverable from the docs nav. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Collapse the equality sections (SOS2 / incremental / disjunctive as separate walk-throughs of the same dispatch pattern) into a single getting-started + a method-comparison table + one disjunctive example. Factor the shared dispatch pattern out of each example — model construction, demand and objective follow the same shape in every section, so the "new" cell in each only shows the one feature being introduced. 47 cells → 20; no loss of coverage (all 8 features still demonstrated: basic equality, method selection, disjunctive, sign/LP, slopes, active, N-variable, per-entity). Plot helper slimmed down to a one-curve overlay used once in the intro; later sections rely on the solution DataFrame. Links to the inequality-bounds notebook placed in the relevant sections. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
for more information, see https://pre-commit.ci
Round-2 review items that weren't already handled by earlier commits: - Split the single mega-bullet in release notes into five findable bullets: core add_piecewise_formulation API, sign / LP dispatch, active (unit commitment), .method/.convexity metadata, and tangent_lines as the low-level helper. Each of sign/LP/active/ convexity is now greppable. - tangent_lines docstring: relax "strictly increasing" to "strictly monotonic" (_detect_convexity is already direction-invariant and tangent_lines doesn't care either way), and open with a pointer to add_piecewise_formulation(sign="<=") as the preferred high-level path — tangent_lines is the low-level escape hatch. - One-line comment on _build_links explaining the intentional eq_bp/stacked_bp aliasing in the sign="==" branch. The other round-2 items (stale RST, netCDF convexity persistence) are already handled by earlier commits fbc90d4 and 3dc1c6c/5889d04 — the reviewer was working against an older snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squashes 17 commits of follow-up work on top of the #663 merge into this branch. Tests (test/test_piecewise_feasibility.py — new, 400+ lines): - Strategic feasibility-region equivalence. The strong test is TestRotatedObjective: for every rotation (α, β) on the unit circle, the support function min α·x + β·y under the PWL must match a vertex-enumeration oracle. Equal support functions over a dense direction set imply equal convex feasible regions. - Additional classes: TestDomainBoundary (x outside the breakpoint range is infeasible under all methods), TestPointwiseInfeasibility (y nudged past f(x) is infeasible), TestHandComputedAnchors (arithmetically trivial expected values that sanity-check the oracle itself), and TestNVariableInequality hardened with a 3-D rotated oracle, heat-off-curve infeasibility, and interior-point feasibility. - Curve dataclass + CURVES list covering concave/convex/linear/ two-segment/offset variants. Method/Sign/MethodND literal aliases for mypy-tight fixture and loop typing. - ~406 pytest items, ~30s runtime, TOL = 1e-5 globally. Tests (test/test_piecewise_constraints.py): - Hardened TestDisjunctive with sign_le_hits_correct_segment (six x-values across two segments with different slopes) and sign_le_in_forbidden_zone_infeasible. Confirms the binary-select + signed-output-link combination routes each x to the right segment's interpolation. - Local Method/Sign literal aliases so the existing loop-over-methods tests survive the tightened add_piecewise_formulation signature. EvolvingAPIWarning: - New linopy.EvolvingAPIWarning(FutureWarning) — visible by default, subclass so users can filter it precisely without affecting other FutureWarnings. Added to __all__ and re-exported at top level. - Emitted from add_piecewise_formulation and tangent_lines with a "piecewise:" message prefix. Every message points users at https://github.com/PyPSA/linopy/issues so feedback shapes what stabilises. - tangent_lines split into a public wrapper (warns) and a private _tangent_lines_impl (no warn) so _add_lp doesn't double-fire. - Message-based filter in pyproject.toml (``"ignore:piecewise:FutureWarning"``) avoids forcing pytest to import linopy at config-parse time (which broke --doctest-modules collection on Windows via a site-packages vs source-tree module clash). Docs: - doc/piecewise-linear-constraints.rst: soften "sign unlocks LP" to reflect that disjunctive + sign is always exact regardless of curvature. New paragraph in the Disjunctive Methods subsection positioning it as a first-class tool for "bounded output on disconnected operating regions". - doc/release_notes.rst: update the piecewise bullet to mention the EvolvingAPIWarning, how to silence it, and the feedback URL. - dev-scripts/piecewise-feasibility-tests-walkthrough.ipynb (new, gitignored→force-added for PR review): visual explanation of each test class — 16-direction probes + extreme points, domain-boundary probes, pointwise nudge, 3-D CHP ribbon. Dropped before master merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
What this PR adds on top of #638
PR #638 establishes
add_piecewise_formulationas the stateless construction entry point, covering equality formulations (SOS2 / incremental / disjunctive). Inequality bounds are left to the user via the baretangent_lineshelper, which returns chord expressions but adds no variables, no domain bounds, and no convexity check.This PR closes those gaps by folding inequality support into
add_piecewise_formulationitself:API additions
sign: Literal["==", "<=", ">="], default"="— selects equality (current behaviour) or a one-sided bound.method: Literal["sos2", "incremental", "auto", "lp"]— new"lp"method uses tangent lines directly, no auxiliary variables.Semantic additions
sign != "=", the first tuple is the output bounded by the sign; all other tuples are inputs forced to equality on the curve. Only one variable carries the sign — prevents ambiguous cases (dominated region, mixed per-variable signs) from slipping in through the main API.x_0 ≤ x ≤ x_n) added by the LP path.tangent_linesalone doesn't add these, and chord extrapolation past the breakpoints produces surprising feasible regions; the LP method inadd_piecewise_formulationcloses that footgun._detect_convexity) — classifies the curve as convex / concave / linear / mixed and requires the sign to match curvature for LP to be a valid (tight) bound. Mismatched combinations raise rather than silently producing a wrong feasible region.method="auto"picks"lp"for 2-variable inequalities when curvature matches sign (concave +<=or convex +>=). Otherwise falls back to SOS2 (non-monotonic / mixed) or incremental (monotonic), always with the sign applied to the output link.Implementation details
sign != "=". Equality keeps the single stacked link as in feat: Unify piecewise API behind add_piecewise_formulation (sign + LP dispatch) #638 (no regression). Inequality splits into one stacked equality link for inputs plus one single-row signed link for the output.PWL_LP_SUFFIX,PWL_DOMAIN_LO_SUFFIX,PWL_DOMAIN_HI_SUFFIX,PWL_OUTPUT_LINK_SUFFIX.SIGNS/EQUAL/LESS_EQUAL/GREATER_EQUALfromlinopy.constants, plussign_replace_dictto normalise"=="→"=".API before vs after
Why the sign × curvature check raises instead of relaxing
Tangent lines impose the intersection of chord inequalities. Whether that equals the true hypograph/epigraph depends on curvature:
sign="<="sign=">="y ≥ max_k chord_k(x) > f(x)— rejectsy = f(x)✗y ≤ min_k chord_k(x) < f(x)— rejectsy = f(x)✗In the ✗ cases, the feasible region is a strict subset of the true hypograph/epigraph — it silently rejects valid points. That's wrong, not merely loose, so we raise on explicit
method="lp"and fall back to an exact MIP method formethod="auto". Users who want the chord expressions anyway can still calllinopy.tangent_lines()directly.Notebook
examples/piecewise-inequality-bounds.ipynb:Test plan
🤖 Generated with Claude Code