Skip to content

feat(piecewise): add sign parameter and LP method to add_piecewise_formulation#663

Merged
FBumann merged 30 commits intofeat/piecewise-api-refactorfrom
feat/piecewise-lp-inequality
Apr 23, 2026
Merged

feat(piecewise): add sign parameter and LP method to add_piecewise_formulation#663
FBumann merged 30 commits intofeat/piecewise-api-refactorfrom
feat/piecewise-lp-inequality

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented Apr 22, 2026

Draft — targets feat/piecewise-api-refactor (PR #638). Merge into that branch, not master.

What this PR adds on top of #638

PR #638 establishes add_piecewise_formulation as the stateless construction entry point, covering equality formulations (SOS2 / incremental / disjunctive). Inequality bounds are left to the user via the bare tangent_lines helper, 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_formulation itself:

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

  • First-tuple convention: when 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.
  • Automatic domain bounds (x_0 ≤ x ≤ x_n) added by the LP path. tangent_lines alone doesn't add these, and chord extrapolation past the breakpoints produces surprising feasible regions; the LP method in add_piecewise_formulation closes that footgun.
  • Convexity check (_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.
  • Auto-dispatch: 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

  • Split link constraint only when 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.
  • New constraint suffixes: PWL_LP_SUFFIX, PWL_DOMAIN_LO_SUFFIX, PWL_DOMAIN_HI_SUFFIX, PWL_OUTPUT_LINK_SUFFIX.
  • Uses existing SIGNS / EQUAL / LESS_EQUAL / GREATER_EQUAL from linopy.constants, plus sign_replace_dict to normalise "==""=".

API before vs after

# --- PR #638 state ---

# Equality
m.add_piecewise_formulation((x, xp), (y, yp))

# Inequality: user assembles manually; no domain bounds; no convexity check
t = linopy.tangent_lines(x, xp, yp)        # bare chord expressions
m.add_constraints(y <= t)                  # extrapolates past [x_0, x_n]
# (user is on their own if convexity is wrong — silently gets a loose/wrong region)


# --- This PR ---

# Equality (unchanged)
m.add_piecewise_formulation((x, xp), (y, yp))

# Inequality: single call, auto dispatch, domain bounds + convexity check
m.add_piecewise_formulation((y, yp), (x, xp), sign="<=")
# → concave curve → LP (no aux vars, domain bounds added)
# → non-concave  → SOS2/incremental, exact bound via output-link split

# tangent_lines remains public as the bare escape hatch for users who want
# chord expressions without the LP dispatch / checks / domain bounds.

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:

valid or rejectable
curvature sign="<=" sign=">="
concave hypograph (exact ✓) y ≥ max_k chord_k(x) > f(x) — rejects y = f(x)
convex y ≤ min_k chord_k(x) < f(x) — rejects y = f(x) epigraph (exact ✓)
linear / mixed tight / convex hull tight / concave hull

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 for method="auto". Users who want the chord expressions anyway can still call linopy.tangent_lines() directly.

Notebook

examples/piecewise-inequality-bounds.ipynb:

  • Mathematical formulations (equality, inequality, LP, incremental)
  • 2-D feasibility plot
  • 3-D feasibility ribbon for 3-variable inequality (three viewpoints)
  • First-tuple convention + why we restrict to it
  • Convexity × sign table with the wrong-region warning
  • Worked fallback example for non-convex curves

Test plan

  • 14 new tests: all sign values, all methods × curvature, LP vs SOS2 vs incremental solution consistency, error cases
  • 135 piecewise tests pass
  • Notebook runs end-to-end
  • Review with @FabianHofmann @coroa

🤖 Generated with Claude Code

FBumann and others added 7 commits April 22, 2026 21:47
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>
FBumann and others added 2 commits April 22, 2026 22:50
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>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 22, 2026

🤖 Claude Code review

Overall 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 bugs

1. _detect_convexity flips sign when x is strictly decreasing

linopy/piecewise.py:571-605 assumes strictly increasing x_points but only strict monotonicity is checked upstream. With decreasing x, dy/dx picks up an extra sign, so a concave graph is classified as convex (and vice-versa).

Repro:

m.add_piecewise_formulation(
    (y, [35, 30, 20, 0]),     # same concave graph as the PR example,
    (x, [30, 20, 10, 0]),     # just passed with x decreasing
    sign=">=",                  # docs call this case "wrong, not loose"
)
# auto picks method="lp"; solving gives y=30 at x=15
# correct SOS2 solution: y=25 = f(15)

Auto dispatches LP on a case the PR explicitly says must fall back, and the resulting constraints cut the curve. Explicit method=\"lp\" has the same hole — validation happily accepts it.

Fix options: (a) flip y by sign(dx) before classifying; (b) require dx > 0 strictly; (c) sort before classifying. Add a test with decreasing x.

2. _add_lp produces wrong constraints with NaN-padded per-entity breakpoints

_add_lp (piecewise.py:1278-1313) never consumes bp_mask. For multi-entity inputs where some entities have fewer breakpoints (NaN tail), NaN slopes/intercepts end up as 0 coefficients → constraint y ≤ 0 for the padded segment.

Repro:

bp_y = pd.DataFrame([[0,20,30,35],[0,10,20,nan]], index=['a','b'])
bp_x = pd.DataFrame([[0,10,20,30],[0,5,15,nan]],  index=['a','b'])
# LP:  y_b forced to 0 (f_b(10) = 15 expected)
# SOS2: y_b = 15 ✓

LP's siblings (_add_sos2, _add_incremental, _add_disjunctive) all respect bp_mask via lambda_mask / delta_mask. LP needs either (a) a constraint mask on the chord line, or (b) skip padded segments, or (c) reject per-entity masked input upfront.

🟠 Docstring / API contradictions

3. Wrong sign doc in the Parameters section

add_piecewise_formulation contradicts itself:

  • Prose (piecewise.py:706-713): "The sign parameter follows the first-tuple convention… the first tuple's expression is bounded above…"
  • Parameters (piecewise.py:735-737): "Constraint sign applied to the last tuple's link constraint."

Implementation matches the prose (first tuple = output). The Parameters block is stale.

4. Silently accepted sign aliases aren't in Literal[…]

sign = sign_replace_dict.get(sign, sign) (piecewise.py:757) accepts \"=\", \">\", \"<\", but the type hint is Literal[\"==\", \"<=\", \">=\"]. Either widen the Literal or drop the normalization.

🟠 Semantic gaps

5. active + sign != EQUAL is under-specified and untested

With active=0:

  • sign=\"==\": output forced to 0 (desired "off" behavior). ✓
  • sign=\"<=\": output is only forced to ≤ 0, not = 0. Likely surprising — a deactivated unit can still take any non-positive output.

No test combines active with a non-equality sign. Either document the asymmetry or add a both-sided constraint when combined. At minimum, a test.

6. Per-entity convexity classification is global

_detect_convexity aggregates across all entities. Safe for the concave+convex mix (\"mixed\" → fall back), but brittle if entities share a label while one has decreasing x (see bug 1). Either iterate per-entity or at least comment that convexity must be homogeneous.

🟡 Duplication / refactor

7. _add_lp duplicates tangent_lines

_add_lp (piecewise.py:1292-1304) and tangent_lines (piecewise.py:443-505) compute slopes / x_base / y_base / intercepts identically. Since tangent_lines is the documented escape hatch, _add_lp should call it:

def _add_lp(model, name, x_expr, y_expr, x_points, y_points, sign):
    tangents = tangent_lines(x_expr, x_points, y_points)
    _add_signed_link(model, y_expr, tangents, sign, f\"{name}{PWL_LP_SUFFIX}\")
    x_min = x_points.min(dim=BREAKPOINT_DIM)
    x_max = x_points.max(dim=BREAKPOINT_DIM)
    model.add_constraints(x_expr >= x_min, name=f\"{name}{PWL_DOMAIN_LO_SUFFIX}\")
    model.add_constraints(x_expr <= x_max, name=f\"{name}{PWL_DOMAIN_HI_SUFFIX}\")

Fixing mask handling once in tangent_lines benefits both call sites.

8. _add_sos2 / _add_incremental signatures ballooned

Both now take input_expr, output_expr, stacked_bp, input_stacked_bp, output_bp, bp_mask, link_dim, rhs, sign (+ active). Nine-ten params with Optional pairs that must stay consistent. Either package into a small _PwlLinks dataclass, or split so each function builds both links from bp_list internally.

9. _add_continuous has an EQUAL / non-EQUAL split that duplicates structure

Lines 993-1014 branch on sign == EQUAL to build either a single stacked link or a split output+input. Both paths then flow into the same _add_sos2 / _add_incremental which again branch internally on input_expr is None. The split-vs-single distinction crosses three function boundaries — unify or push up.

10. Auto-dispatch is a 5-deep nested if

piecewise.py:919-939. Flatten with early return and a helper _can_use_lp(lin_exprs, bp_list, sign, active) -> bool.

🟡 Housekeeping

11. Stale # type: ignore reintroduced

linopy/expressions.py:2359 re-adds a # type: ignore that the base branch removed in f0267f5 fix: remove unused type: ignore on merge() cls assignment. Will vanish on rebase.

12. Growing suffix-constant surface

_lp, _domain_lo, _domain_hi, _output_link — four new module-level constants for names not reused anywhere else. Consider inlining the f-strings or bundling.

13. PWL_LP_SUFFIX = \"_lp\" is generic

pwl0_lp doesn't identify which direction. Last chance to rename — _chord or _tangent (matches tangent_lines) would be clearer.

🟡 Test coverage gaps

  • No test with decreasing x (would have caught bug 1).
  • No test with per-entity masked breakpoints + LP (would have caught bug 2).
  • No test solving a domain-bound violation (fix x beyond x_max, check infeasibility).
  • No test with active + sign != EQUAL (issue 5).
  • No test for LP on a linear curve (docs say it's accepted).
  • No test for LP on N-D variables (extra dim beyond breakpoint dim).
  • test_lp_consistency_with_sos2 checks only the max side — stronger if it also solved with a negative-f objective, testing both sides of the inequality.

✅ Things I liked

  • _add_signed_link avoids if/elif ladders at every call site.
  • "Raise, don't relax" stance on method=\"lp\" with mismatched curvature is correct and well-justified in both docstring and notebook.
  • Reuse of SIGNS / sign_replace_dict from constants.py rather than re-parsing.
  • First-tuple convention is the right call — per-tuple signs would create ambiguous cases.
  • The notebook's treatment of why concave + >= is wrong (not loose) is the best explanation of chord-intersection semantics I've seen.

Suggested minimum before merging

  1. Fix _detect_convexity order-dependence (bug 1) + test.
  2. Fix LP NaN-mask handling (bug 2) — ideally by delegating _add_lp to tangent_lines (issue 7) + test.
  3. Fix the sign Parameters docstring (issue 3).
  4. Decide & document active + non-EQUAL sign semantics (issue 5).
  5. Rebase to drop the stale expressions.py diff (issue 11).

Issues 8-10, 12-13 are refactor hygiene — nice to do now before the shape freezes, but not merge blockers.


🤖 Review generated with Claude Code (Opus 4.7)

FBumann and others added 14 commits April 22, 2026 23:32
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>
_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
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 22, 2026

🤖 Claude Code review — round 2 (holistic, combined with #638)

Reviewing this PR as a unit with #638, against master. Stats: ~5.5k lines added / 2.3k removed across piecewise.py, tests, two notebooks, and the RST guide.

Big picture

The combined diff turns add_piecewise_formulation(...) into the single entry point for piecewise work — equality, inequality, N-variable linking, per-entity breakpoints, unit-commitment gating, and auto method dispatch all live behind one call. tangent_lines() survives as the bare "give me chord expressions" escape hatch. That is a coherent story, and the layered split between #638 (stateless construction + N-variable) and #663 (sign / LP) reads well as two landmarks.

Code-wise, after the last round of refactors the module is in good shape: _try_lp + _build_links + _resolve_sos2_vs_incremental + _PwlLinks cleanly separate concerns; _detect_convexity is now 8 lines and x-direction-invariant; _add_lp delegates to tangent_lines with a proper per-segment mask. The two correctness bugs from round 1 are genuinely gone.

🟠 The main gap: docs didn't keep up with the code

doc/piecewise-linear-constraints.rst is stale. It still reads like the #638-only state:

  • method="auto" docstring (line 50) says "auto, sos2, or incremental""lp" is missing.
  • The sign parameter is nowhere in the RST.
  • The "When to Use What" table (lines 93-128) still positions add_piecewise_formulation as equality-only and tangent_lines as inequality-only. After this PR that's no longer true — add_piecewise_formulation(..., sign="<=") does inequality with domain bounds and curvature check baked in.
  • Worst: line 127 says "For other combinations the bound is valid but loose (a relaxation)." — the opposite of the "wrong, not loose" conclusion the PR body and notebook argue at length. This one contradicts shipped code.
  • "Generated variables and constraints" (lines 372-388) doesn't list the new suffixes (_chord, _domain_lo, _domain_hi, _output_link) and lists _x_link / _fill which aren't even correct for the current code (which uses _link / _fill_order). The latter is pre-existing staleness from feat: Unify piecewise API behind add_piecewise_formulation (sign + LP dispatch) #638, but it shows nobody has swept this page recently.
  • No mention of PiecewiseFormulation.convexity on the result object.

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 sign / LP feature deserves its own bullet ("Add one-sided bounds via sign parameter; pure-LP tangent dispatch for convex/concave cases with automatic domain bounds and convexity check"). The active parameter also vanished from the notes during the rewrite — it was in the pre-#638 entry, got folded into the add_piecewise_formulation mega-bullet, and a reader scanning for unit-commitment support won't find the keyword.

🟠 Notebook coordination

You now ship two piecewise notebooks:

After this PR the bare-tangent_lines recipe is arguably the worse one (no domain bounds, no convexity check). Options:

  1. Preferred: fold the new notebook into example 4 of the main notebook (or add an "Inequality bounds" section), delete the standalone. Users get one tutorial, not two.
  2. Keep both but add a one-line "this shows the low-level building block; see the other notebook for the ergonomic API" in example 4, and a reciprocal link in the new notebook.

Right now a reader has no hint that two equivalent recipes exist.

🟡 netCDF round-trip drops convexity

before save: incremental  concave
after load:  incremental  None

linopy/io.py serialises method / variable_names / constraint_names but not convexity, so the attribute silently becomes None on reload. Either persist it or document that it's recomputed-only (it actually could just be recomputed on load from the saved constraints, though that's extra plumbing). Simpler: add it to the dict.

🟡 PR description nits

  • "sign: Literal["==", "<=", ">="], default "="" — default should be "==" to match the Literal. Minor.
  • Test plan line still says "135 piecewise tests", but you've added more since; actual count is higher.
  • The description doesn't mention two shipped features: PiecewiseFormulation.convexity metadata + LP skip-reason logging. Both are user-visible — worth a line.
  • Consider adding a short "tangent_lines vs sign="<=" — when to reach for which" note. Right now a first-time reader sees both and has no guidance.

🟡 Smaller code-level observations (all post-refactor)

  1. _PwlLinks.eq_bp == stacked_bp when sign == EQUAL. Same object reference, just aliased. Fine, but either null out eq_bp in the EQUAL branch and have consumers fall back to stacked_bp, or add a one-line comment noting the intentional aliasing. Today it looks like two independent fields.

  2. _add_lp's per-segment validity mask is computed inline (piecewise.py:1441-1449). SOS2 / incremental / disjunctive all get their masks via links.bp_mask from _build_links. A small helper _segment_validity_mask(bp) used by both _add_lp and _add_incremental's delta_mask construction would make the parallel obvious and keep the "mask everywhere" discipline centralised.

  3. tangent_lines still says in its docstring "Breakpoint x-coordinates (must be strictly increasing)". After round 1 you made _detect_convexity direction-invariant, but tangent_lines itself doesn't enforce or rely on increasing order — yet the docstring says it's required. Either add validation or soften the docstring to "strictly monotonic".

  4. _detect_convexity and tangent_lines both recompute slopes from scratch. Considering _add_lp now delegates to tangent_lines, and LP dispatch always calls _detect_convexity right before, the slope computation happens twice per call. Not a performance issue — but _detect_convexity(slopes) taking the pre-computed slopes DataArray (or exposing a _segment_slopes(x, y) shared helper) would be the cleanest shape. Low priority.

  5. Is tangent_lines still the right public name? After this PR, it's the "advanced, no-checks, no-domain-bounds" escape hatch. The docstring still recommends it for the common case, which now has a better path. Consider a docstring note like "For most users, prefer add_piecewise_formulation(..., sign="<=") — this helper is the low-level building block."

✅ What I'd call shipped-quality

  • The first-tuple convention — rejecting per-tuple signs prevents ambiguous cases and keeps the math clean. The notebook explains it well.
  • _detect_convexity after the simplification + direction fix is a good example of "do one thing, 8 lines".
  • _PwlLinks dataclass is the right call over the original 9-parameter signatures.
  • LP skip-reason logging is a nice usability touch (user can tell why auto didn't pick LP).
  • Error messages are now terse and actionable.

Minimum before merging

  1. Update doc/piecewise-linear-constraints.rst to document sign, method="lp", the first-tuple convention, and fix the "loose" vs "wrong" statement. Also correct the stale constraint-suffix listings.
  2. Split the release-notes bullet; make sure sign / LP / active / convexity are each findable.
  3. Decide on the two-notebook story (merge, or cross-link).
  4. Persist convexity in to_netcdf so round-trip is lossless.
  5. Fix the default-"=" typo and refresh test count in the PR description.

Code changes I'd defer to a follow-up: the small _PwlLinks.eq_bp tidy, the _segment_validity_mask helper, the tangent_lines docstring tweak. None block merge.


🤖 Review generated with Claude Code (Opus 4.7)

FBumann and others added 4 commits April 23, 2026 00:56
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>
FBumann and others added 3 commits April 23, 2026 01:33
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>
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>
@FBumann FBumann marked this pull request as ready for review April 23, 2026 06:18
@FBumann FBumann merged commit faa4b01 into feat/piecewise-api-refactor Apr 23, 2026
2 checks passed
@FBumann FBumann deleted the feat/piecewise-lp-inequality branch April 23, 2026 06:19
FBumann added a commit that referenced this pull request Apr 23, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant