Skip to content

feat: Unify piecewise API behind add_piecewise_formulation (sign + LP dispatch)#638

Open
FBumann wants to merge 37 commits intomasterfrom
feat/piecewise-api-refactor
Open

feat: Unify piecewise API behind add_piecewise_formulation (sign + LP dispatch)#638
FBumann wants to merge 37 commits intomasterfrom
feat/piecewise-api-refactor

Conversation

@FBumann
Copy link
Copy Markdown
Collaborator

@FBumann FBumann commented Apr 1, 2026

Summary

Follows up on the discussion in #602. Replaces the descriptor pattern (PiecewiseExpression, PiecewiseConstraintDescriptor, piecewise()) with a single, stateless construction layer: add_piecewise_formulation.

add_piecewise_formulation becomes the one entry point for piecewise work — equality, one-sided inequality, N-variable linking, per-entity breakpoints, unit-commitment gating, and automatic method dispatch all live behind one call. tangent_lines() survives as a low-level helper for users who want to build chord expressions manually.

The API

Each (expression, breakpoints) tuple links a variable to its breakpoints. All tuples share interpolation weights, coupling them on the same curve segment.

Equality (default)

# 2-variable: fuel = f(power)
m.add_piecewise_formulation(
    (power, [0, 30, 60, 100]),
    (fuel,  [0, 36, 84, 170]),
)

# N-variable (CHP plant — same pattern, more tuples)
m.add_piecewise_formulation(
    (power, [0, 30, 60, 100]),
    (fuel,  [0, 40, 85, 160]),
    (heat,  [0, 25, 55, 95]),
)

# Disjunctive (disconnected operating regions)
m.add_piecewise_formulation(
    (power, linopy.segments([(0, 0), (50, 80)])),
    (cost,  linopy.segments([(0, 0), (125, 200)])),
)

# Per-entity breakpoints (different curves per generator)
m.add_piecewise_formulation(
    (power, linopy.breakpoints({"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen")),
    (fuel,  linopy.breakpoints({"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen")),
)

# Breakpoints from slopes
m.add_piecewise_formulation(
    (power, [0, 30, 60, 100]),
    (fuel,  linopy.breakpoints(slopes=[1.2, 1.4, 1.7], x_points=[0, 30, 60, 100], y0=0)),
)

Inequality bounds — sign="<=" / ">="

The sign parameter applies the bound to the first tuple (first-tuple convention); remaining tuples are inputs pinned to the curve.

# fuel ≤ f(power).  "auto" picks the cheapest correct formulation — a pure
# LP chord formulation when the curvature matches the sign (concave + "<=",
# or convex + ">="), SOS2/incremental otherwise.
m.add_piecewise_formulation(
    (fuel,  [0, 20, 30, 35]),   # bounded output listed FIRST
    (power, [0, 10, 20, 30]),   # input, always equality
    sign="<=",
)

On a matching-curvature curve this dispatches to method="lp": one chord inequality per segment plus a domain bound x_min ≤ x ≤ x_max, and no auxiliary variables. Mismatched curvature (convex + "<=", or concave + ">=") describes the wrong region (not just a looser one) — method="auto" detects this and falls back to SOS2/incremental with an explanatory info log; method="lp" raises with a clear error.

Inequalities with 3+ variables cant go lp. They are always sos/...

valid or rejectable

Unit-commitment gating — active

# active=1: power in [30, 100], fuel = f(power)
# active=0: power = 0, fuel = 0
m.add_piecewise_formulation(
    (power, [30, 60, 100]),
    (fuel,  [40, 90, 170]),
    active=commit,  # binary variable
)

Works with SOS2, incremental, and disjunctive. With sign != "==", deactivation only pushes the signed bound to 0 — set lower=0 on naturally non-negative outputs (fuel, cost, heat) and the combination forces y = 0 automatically.

Low-level: tangent_lines

# Returns a LinearExpression with one chord per segment — no variables.
# Most users should prefer add_piecewise_formulation(..., sign="<="),
# which builds on this helper and adds domain bounds + curvature checks.
t = linopy.tangent_lines(power, x_pts, y_pts)
m.add_constraints(fuel <= t)

Key changes

New public API

  • add_piecewise_formulation — one entry point for all piecewise constructions. Returns a PiecewiseFormulation object carrying .method (resolved formulation), .convexity (when well-defined), .variables, .constraints.
  • sign parameter — "==" (default) / "<=" / ">=", with the first-tuple convention.
  • method parameter — "auto" (default), "sos2", "incremental", "lp".
  • active parameter — binary gating for unit commitment.
  • linopy.breakpoints() / linopy.segments() factories.
  • linopy.slopes_to_points() utility.
  • linopy.tangent_lines() low-level helper.

Auto-dispatch (method="auto")

  1. 2-variable inequality on a matching-curvature curvelp (chord + domain bounds, no aux vars)
  2. Strictly monotonic breakpointsincremental (deltas + binaries)
  3. Otherwisesos2 (lambdas + SOS2 constraint)
  4. Disjunctive (segments)sos2 with binary segment selection

The resolved method is logged at INFO; when LP is skipped, the log explains why (curvature, NaN layout, tuple count, or active).

Correctness properties

  • _detect_convexity is direction-invariant — ascending and descending x give the same label for the same graph.
  • NaN-padded per-entity breakpoints are masked everywhere, including the LP chord constraints (padded segments don't create spurious y ≤ 0 constraints).
  • method="lp" validates curvature + sign + tuple count + active absence upfront; mismatch raises with a clear fix pointer to method="auto".
  • PiecewiseFormulation.method and .convexity persist across netCDF round-trip.

Architecture (internal)

  • _PwlLinks dataclass carries the equality-side and signed-side link expressions; _build_links() is the single place where the sign split happens.
  • _try_lp() / _resolve_sos2_vs_incremental() flatten the dispatch — _add_continuous is ~10 lines of real logic.
  • _add_sos2 / _add_incremental / _add_disjunctive take 4–5 arguments (down from 9–11).
  • _add_lp delegates chord construction to the public tangent_lines helper.

Removed / deprecated

  • PiecewiseExpression, PiecewiseConstraintDescriptor, linopy.piecewise() — descriptor pattern gone.
  • Old add_piecewise_constraints shape that dispatched on Variable.__le__/__ge__/__eq__ return types.
  • Variable operator overloads (__le__, __ge__, __eq__) simplified — no more piecewise dispatch.

Design principles

  • API surface stays minimal: Model, Variables, Constraints, Expression, plus the single PiecewiseFormulation return type. No user-facing intermediate state.
  • Piecewise is a construction layer that produces regular linopy objects.
  • One entry point for equality and inequality — sign selects the semantics, method selects the cost.
  • Variable names as link coords: the internal _pwl_var dimension shows power, fuel, heat instead of 0, 1, 2.

Alternative designs considered

A) Descriptor pattern (PR #602, previous master)

pw = linopy.piecewise(x, x_pts, y_pts)
m.add_piecewise_constraints(pw == y)

Rejected: Introduces PiecewiseExpression and PiecewiseConstraintDescriptor as user-facing state. Breaks the "only Variables, Constraints, Expressions" principle. Structurally limited to 2-variable x → y.

B) Dict of expressions + shared breakpoints DataArray

m.add_piecewise_formulation(exprs={"power": power, "fuel": fuel}, breakpoints=bp)

Considered: Supports N-variable. But requires string keys to match breakpoint coordinates — an indirection layer.

C) Per-tuple signs

m.add_piecewise_formulation((power, xp, "<="), (fuel, yp, "<="))

Rejected: Creates ambiguous cases (what if tuples disagree on sign? what if only some have it?). Global sign= + the first-tuple convention keeps the math clean.

D) Last-tuple convention (output listed last)

m.add_piecewise_formulation((power, xp), (fuel, yp), sign="<=")  # fuel bounded

Rejected: The y = f(x) convention reads left-to-right; listing the bounded output first ("first expression ≤ f(the rest)") is more natural. First-tuple convention chosen after discussion.

E) Chosen: tuple-based, global sign, first-tuple convention

m.add_piecewise_formulation(
    (fuel,  y_pts),    # bounded output, listed first
    (power, x_pts),    # input
    sign="<=",
)

Notebook examples

examples/piecewise-linear-constraints.ipynb (main tutorial, 7 sections):

# Section Feature
1 Getting started Basic 2-variable equality + method inspection via pwf
2 Picking a method auto vs sos2 vs incremental give the same optimum
3 Disjunctive segments segments() for gaps in operation
4 Inequality bounds sign="<=" (auto-dispatches LP)
5 Unit commitment active parameter
6 N-variable linking CHP plant (3 variables)
7 Per-entity breakpoints Fleet with different curves

examples/piecewise-inequality-bounds.ipynb (dedicated deep-dive on sign / LP):

  • Mathematical formulation (equality, inequality, LP, incremental)
  • 2D hypograph feasibility plot
  • 3D ribbon for 3-variable case (multiple viewpoints)
  • Convexity × sign rule table, "wrong region" analysis
  • Auto-dispatch fallback demonstration

Test plan

  • All 148 piecewise tests pass (incl. 11 direct unit tests on _detect_convexity)
  • Full test suite passes
  • Both notebooks execute cleanly
  • Ruff lint + format clean, mypy clean on touched files
  • Round-trip tested: PiecewiseFormulation.method and .convexity persist across to_netcdf / read_netcdf
  • Review

🤖 Generated with Claude Code

FBumann and others added 22 commits April 1, 2026 08:36
…on layer

Remove PiecewiseExpression, PiecewiseConstraintDescriptor, and the
piecewise() function. Replace with an overloaded add_piecewise_constraints()
that supports both a 2-variable positional API and an N-variable dict API
for linking 3+ expressions through shared lambda weights.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change add_piecewise_constraints() to use keyword-only parameters
(x=, y=, x_points=, y_points=) instead of positional args.  Add
detailed docstring documenting the mathematical meaning of equality
vs inequality constraints.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The N-variable path was not broadcasting breakpoints to cover extra
dimensions from the expressions (e.g. time), resulting in shared
lambda variables across timesteps.  Also simplify CHP example to
use breakpoints() factory and add plot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The plotting helper now accepts a single breakpoints DataArray with a
"var" dimension, supporting both 2-variable and N-variable examples.
Replaces the inline CHP plot with a single function call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Document the N-variable core formulation with shared lambda weights,
explain how the 2-variable case maps to it, and detail the inequality
case (auxiliary variable + bound).  Remove all references to the
removed piecewise() function and descriptor classes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add linopy.piecewise_envelope() as a standalone linearization utility
that returns tangent-line LinearExpressions — no auxiliary variables.
Users combine it with regular add_constraints for inequality bounds.

Remove sign parameter, LP method, convexity detection, and all
inequality logic from add_piecewise_constraints. The piecewise API
now only does equality linking (the core formulation).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
More accurate name — the function computes tangent lines per segment,
not necessarily a convex/concave envelope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single function doesn't justify a separate module. tangent_lines
lives next to breakpoints() and segments() — all stateless helpers
for the piecewise workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add prominent section explaining the fundamental difference:
- add_piecewise_constraints: exact equality, needs aux variables
- tangent_lines: one-sided bounds, pure LP, no aux variables
- tangent_lines with == is infeasible (overconstrained)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace keyword-only (x=, y=, x_points=, y_points=) and dict-based
(exprs=, breakpoints=) forms with a single tuple-based API:

    m.add_piecewise_constraints(
        (power, [0, 30, 60, 100]),
        (fuel,  [0, 36, 84, 170]),
    )

2-var and N-var are the same pattern — no separate convenience API.
Internally stacks all breakpoints along a link dimension and uses
a unified formulation path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The _pwl_var dimension now shows variable names (e.g. "power", "fuel")
instead of generic indices ("0", "1"), making generated constraints
easier to debug and inspect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… notebook

The piecewise() function was removed but api.rst still referenced it.
Also replace xr.concat with breakpoints() in plot cells to avoid
pandas StringDtype compatibility issue on newer xarray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Silences xarray FutureWarning about default coords kwarg changing.
No behavior change — we concatenate along new dimensions where
coord handling is irrelevant.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Example 8 (fleet of generators with per-entity breakpoints) to
the notebook.  Also drop scalar coordinates from breakpoints before
stacking to handle bp.sel(var="power") without MergeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The descriptor API was never released, so for users this is all new.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FBumann FBumann marked this pull request as ready for review April 1, 2026 11:53
@FBumann FBumann changed the title Refactor piecewise API: stateless construction layer + tangent_lines utility refac: Refactor piecewise API to be a stateless construction layer + tangent_lines utility Apr 1, 2026
@FBumann FBumann changed the title refac: Refactor piecewise API to be a stateless construction layer + tangent_lines utility refac: Refactor piecewise API to a stateless construction layer + tangent_lines utility Apr 1, 2026
@FBumann FBumann requested a review from FabianHofmann April 1, 2026 11:54
FBumann and others added 3 commits April 1, 2026 14:03
Reorder: Quick Start -> API -> When to Use What -> Breakpoint
Construction -> Formulation Methods -> Advanced Features.
Add per-entity, slopes, and N-variable examples. Deduplicate
code samples. Fold generated-variables tables into compact lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 1, 2026

@FabianHofmann This is pretty urgent to me, as the current piecewise API is already on master and should NOT be included in the next release in my opinion.
But i understand that the CSR PR is more important atm.

The current code can be reorganized a bit, but id like to hear your thoughts about the API first.

FBumann and others added 3 commits April 1, 2026 14:32
…code

Remove _add_pwl_sos2_core and _add_pwl_incremental_core which were
never called, and inline the single-caller _add_dpwl_sos2_core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refac: use _to_linexpr in tangent_lines instead of manual dispatch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: rename _validate_xy_points to _validate_breakpoint_shapes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: clean up duplicate section headers in piecewise.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: convert expressions once in _broadcast_points

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove unused _compute_combined_mask

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: validate method early, compute trailing_nan_only once

Move method validation to add_piecewise_constraints entry point and
avoid calling _has_trailing_nan_only multiple times on the same data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: deduplicate stacked mask expansion in _add_continuous_nvar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove redundant isinstance guards in tangent_lines

_coerce_breaks already returns DataArray inputs unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: rename _extra_coords to _var_coords_from with explicit exclude set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: clarify transitive validation in breakpoint shape check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove skip_nan_check parameter

NaN breakpoints are always handled automatically via masking.
The skip_nan_check flag added API surface for minimal value —
it only asserted no NaN (misleading name) and skipped mask
computation (negligible performance gain).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove unused PWL_AUX/LP/LP_DOMAIN constants

Remnants of the old LP method that was removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: always return link constraint from incremental path

Both SOS2 and incremental branches now consistently return the
link constraint, making the return value predictable for callers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: split _add_continuous into _add_sos2 and _add_incremental

Extract the SOS2 and incremental formulations into separate functions.
Add _stack_along_link helper to deduplicate the expand+concat pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: rename test classes to match current function names

TestPiecewiseEnvelope -> TestTangentLines
TestSolverEnvelope -> TestSolverTangentLines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: use _stack_along_link for expression stacking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: use generic param names in _validate_breakpoint_shapes

Rename x_points/y_points to bp_a/bp_b to reflect N-variable context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: extract _to_seg helper in tangent_lines for rename+reassign pattern

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: extract _strip_nan helper for NaN filtering in slopes mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: extract _breakpoints_from_slopes, add _to_seg docstring

Move the ~50 line slopes-to-points conversion out of breakpoints()
into _breakpoints_from_slopes, keeping breakpoints() as a clean
validation-then-dispatch function.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve mypy errors in _strip_nan and _stack_along_link types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove duplicate slopes validation in breakpoints()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: move _rename_to_segments to module level, fix extra blank line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add validation and edge-case tests for piecewise module

Cover error paths and edge cases: non-1D input, slopes mode with
DataArray y0, non-numeric breakpoint coords, segment dim mismatch,
disjunctive >2 pairs, disjunctive interior NaN, expression name
fallback, incremental NaN masking, and scalar coord handling.

Coverage: 92% -> 97%

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve ruff and mypy errors

- Use `X | Y` instead of `(X, Y)` in isinstance (UP038)
- Remove unused `dim` variable in _add_continuous (F841)
- Fix docstring formatting (D213)
- Remove unnecessary type: ignore comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 1, 2026

edit: did some refactoring anyway, grouped in #641

FBumann and others added 2 commits April 1, 2026 20:21
Refactor _add_disjunctive to use the same stacked N-variable pattern
as _add_continuous. Removes the 2-variable restriction — disjunctive
now supports any number of (expression, breakpoints) pairs with a
single unified link constraint.

- Remove separate x_link/y_link in favor of single _link with _pwl_var dim
- Remove PWL_Y_LINK_SUFFIX import (no longer needed)
- Add test for 3-variable disjunctive

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 1, 2026

N-variable disjunctive + follow-up notes

Done: Refactored _add_disjunctive to use the same stacked N-variable pattern as _add_continuous. The 2-variable restriction is removed — disjunctive now works with any number of (expression, breakpoints) pairs, using a single _link constraint with a _pwl_var dimension (same as continuous SOS2/incremental).

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 1, 2026

Additional follow-ups

Single-variable support: All formulation methods (SOS2, incremental, disjunctive) should support a single (expression, breakpoints) pair. Use case: constraining a variable to lie on a set of discrete segments or breakpoints without linking to another variable. The stacking logic should handle len(pairs) == 1 naturally — just relax the len(pairs) < 2 check.

Segments with 2+ breakpoints: Currently segments() only supports pairs of points (start, end) per segment. It should support segments with 2 or more breakpoints, enabling piecewise-within-segment curves (e.g. a nonlinear segment approximated by multiple linear pieces within each disconnected region. Currently, a user could express this with "touching" segments, but its mathematically less efficient).

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 2, 2026

Comparison with JuMP's piecewise linear formulations

JuMP's approach (via PiecewiseLinearOpt.jl)

JuMP's core piecewiselinear() returns a single variable representing the PWL output. All auxiliary variables/constraints are added internally. The package offers 8 formulation methods that all share the same convex-combination (λ) core — they differ only in how SOS2 adjacency is enforced:

Method Binary vars Notes linopy
:SOS2 0 Delegates to solver's native SOS2 branching _add_sos2
:CC (Convex Combination) O(n) Same as SOS2 but adjacency enforced with explicit binaries instead of SOS2 set ✅ same formulation as _add_sos2but with our internal sos_reformulation
:MC (Multiple Choice) O(n) Disaggregated segment copies with binary selection ✅ essentially _add_disjunctive — and we extend it with gap support via segments()
:Incremental O(n) Delta chaining _add_incremental
:Logarithmic (default) O(log n) Gray code encoding ❌ not yet
:DisaggLogarithmic O(log n) Disaggregated + Gray codes ❌ not yet
:ZigZag O(log n) Zig-zag codes ❌ not yet
:ZigZagInteger 1 general int Most compact ❌ not yet

The default is :Logarithmicceil(log2(n-1)) binary variables, significantly more efficient for branch-and-bound with many breakpoints.

JuMP also supports bivariate PWL via triangulation (UnionJack, K1, Stencil, BestFit patterns) for z = f(x, y) with independent inputs.

Reference: Huchette & Vielma, "Nonconvex piecewise linear functions: Advanced formulations and simple modeling tools" (2017).

Two separate concerns: piecewise structure vs. SOS2 enforcement

JuMP mixes these into a single method parameter. We can do better by separating them:

Piecewise formulation structure (what add_piecewise_formulation handles):

Method What it does linopy
SOS2 λ weights + sum=1 + linking constraints. Delegates adjacency enforcement to add_sos_constraints. _add_sos2
Incremental Completely different structure — δ deltas + binary chaining. No λ, no SOS2 at all. _add_incremental
Disjunctive Per-segment λ + binary segment selection. Uses SOS2 within each segment, but segment decomposition is piecewise logic. _add_disjunctive

SOS2 adjacency enforcement (what add_sos_constraints / sos_reformulation.py handles — orthogonal to piecewise):

Method Binary vars Notes linopy
Native 0 Solver handles branching add_sos_constraints
CC / Big-M O(n) x[i] <= M[i] * (z[i-1] + z[i]) sos_reformulation.py (auto via reformulate_sos=True)
Logarithmic (Gray code) O(log n) Best trade-off for many breakpoints ❌ not yet, high value follow op ⭐
DisaggLogarithmic O(log n) Disaggregated + Gray codes ❌ not yet
ZigZag O(log n) Different code pattern, similar performance ❌ not yet
ZigZagInteger 1 general int Most compact ❌ not yet

This separation means:

  • Piecewise stays simple: 3 structural methods, already complete
  • SOS2 reformulations benefit all SOS constraints in the model, not just piecewise
  • The two choices compose: e.g. disjunctive piecewise + logarithmic SOS2 enforcement

JuMP bundles all 8 as piecewise methods. We separate structure from enforcement — cleaner architecture, broader applicability.

What linopy has on top of JuMP

Feature Description
Disconnected segments segments() factory + _add_disjunctive for forbidden operating zones with gaps (e.g. off or 50–80 MW). JuMP's MC handles contiguous segments only.
N-variable linking Link 3+ expressions through shared λ weights (CHP: power/fuel/heat). JuMP's piecewiselinear() is 1→1 only.
active parameter (unit commitment) Binary variable gates the entire PWL — when active=0, all aux variables are zero. In JuMP this requires manual formulation.
tangent_lines() utility Pure LP inequality bounds (no auxiliary variables). Returns a LinearExpression per segment for use with regular add_constraints.
Per-entity breakpoints Different curves per generator via breakpoints({"gas": [...], "coal": [...]}, dim="gen") with automatic xarray broadcasting.
Auto method selection method="auto" detects monotonicity and chooses incremental (faster) vs SOS2 (flexible). JuMP requires explicit method choice.

Why our architecture doesn't block any of these

The design in this PR is method-agnostic by construction:

  1. The dispatch is a simple if/else on a string in _add_continuous. The 3 structural methods are already complete.

  2. All formulation methods reduce to the same primitives: add_variables(continuous/binary/integer) + add_constraints + optionally add_sos_constraints. Every JuMP method uses exactly these building blocks.

  3. Result tracking is formulation-blind: PiecewiseFormulation snapshots which variable/constraint names were added via before/after diffing. It doesn't care how they were structured — any new method's artifacts get captured automatically.

  4. SOS2 reformulations are additive: Adding logarithmic/zigzag to sos_reformulation.py requires zero changes to the piecewise layer — it just gets faster SOS2 enforcement for free.

  5. No formulation-specific assumptions leak into the public API. The user writes method="sos2" and gets back the same PiecewiseFormulation with .variables and .constraints, regardless of how SOS2 is enforced.

Our N-variable linking vs. JuMP's triangulation

These solve different problems (complementary, not competing):

Our N-variable (1D curve) Triangulation (2D+ mesh)
Inputs 1 hidden parameter (e.g. load) 2+ independent variables (x, y)
Breakpoints 1D sequence 2D grid, triangulated into simplices
λ weights 2 adjacent points (SOS2 along 1 axis) 3 vertices of one triangle
Degrees of freedom 1 2+
Use case Coupled outputs of one parameter (CHP, efficiency curves) z = f(x, y) with independent inputs

Our 3-variable (power, fuel, heat) links three outputs coupled by a single operating parameter — you can't set power=60 and heat=25 independently; the curve determines heat given power. Triangulation handles z = f(x, y) where x and y move independently (e.g., cost as a function of temperature AND load).

Follow up: Triangulation

Triangulation is mathematically quite a bit more complex and needs different data. It takes a mesh instead of a 1D breakpoint sequence. (mesh). Its clearly a follow up, probably as a new Model.add_*()* method that can reuse the PiecewiseFormulation return type, name tracking, and model primitives.

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 14, 2026

@coroa Can we discuss this together with @FabianHofmann ?

@FBumann FBumann added this to the v0.7.0 milestone Apr 14, 2026
@coroa
Copy link
Copy Markdown
Member

coroa commented Apr 14, 2026

@coroa Can we discuss this together with @FabianHofmann ?

@FabianHofmann is on vacation this week. Can we set up something next week? Or is pressing more urgent than that?

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 14, 2026

@coroa Can we discuss this together with @FabianHofmann ?

@FabianHofmann is on vacation this week. Can we set up something next week? Or is pressing more urgent than that?

Ah ok i missed that. No, next week is fine :) Could you setup a meeting?

FBumann and others added 2 commits April 22, 2026 18:45
…cewise_formulation (#642)

* feat: PiecewiseFormulation return type, model groups, rename to add_piecewise_formulation

- Add PiecewiseFormulation dataclass grouping all auxiliary variables
  and constraints created by a piecewise formulation
- Add _groups registry on Model to track grouped artifacts
- Model repr hides grouped items from Variables/Constraints sections
  and shows them in a new "Groups" section
- Rename add_piecewise_constraints -> add_piecewise_formulation
- Export PiecewiseFormulation from linopy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update notebook to show PiecewiseFormulation repr

Reorder cells so add_piecewise_formulation is the last statement,
letting Jupyter display the PiecewiseFormulation repr automatically.
Add print(m) cell to show the grouped model repr.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: show tangent_lines repr in notebook

Split tangent_lines cell so its LinearExpression repr is displayed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show dims in PiecewiseFormulation repr and user dims in Model groups

PiecewiseFormulation now shows full dims (including internal) for each
variable and constraint. Model groups section shows "over (dim1, dim2)"
for user-facing dims only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: remove counts from PiecewiseFormulation repr

Match style of Variables/Constraints containers which don't show counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: rename _groups to _piecewise_formulations, use direct section name

Replace generic "Groups" with "Piecewise Formulations" in Model repr.
Rename internal registry and helper to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: move method after counts in repr to avoid looking like a dim

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: show dims before name like regular variables/constraints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: compact piecewise formulation line in model repr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: use backtick name style in PiecewiseFormulation repr

Match Constraint repr pattern: `name` instead of 'name'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show user dims with sizes in PiecewiseFormulation header

Match Constraint repr style: `name` [dim: size, ...] — method

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear notebook outputs to fix nbformat validation

Remove jetTransient metadata and normalize cell format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: store names in PiecewiseFormulation, add IO persistence

PiecewiseFormulation now stores variable/constraint names as strings
with a model reference. Properties return live Views on access.
This makes serialization trivial — persist as JSON in netcdf attrs,
reconstruct on load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename remaining add_piecewise_constraints reference after rebase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refac: rename PWL suffix constants for clarity

- PWL_X_LINK_SUFFIX/_Y_LINK_SUFFIX → PWL_LINK_SUFFIX (N-var, single link)
- PWL_BINARY_SUFFIX → PWL_SEGMENT_BINARY_SUFFIX (disjunctive segment selection)
- PWL_INC_BINARY_SUFFIX → PWL_ORDER_BINARY_SUFFIX (incremental ordering)
- PWL_INC_LINK_SUFFIX → PWL_DELTA_BOUND_SUFFIX (δ ≤ binary)
- PWL_INC_ORDER_SUFFIX → PWL_BINARY_ORDER_SUFFIX (binary_{i+1} ≤ δ_i)
- PWL_FILL_SUFFIX → PWL_FILL_ORDER_SUFFIX (δ_{i+1} ≤ δ_i)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 22, 2026

From call:

  • Add a sign parameter to add_piecewise_constraints()
  • add_piecewise_constraints() should forward to tangent lines if possible
  • Add new Slopes object, drop slopes support from breakpoints
  • Maybe add a reference variable in add_piecewise_constraints() so other variables/slopes can refer to it (especially for tangent lines)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 22, 2026

@coroa I thought a lot about the inequality in non lp piecewise relationships.
I added a notebook and plot about it in #662 (5992788). Essentially, the sign needs to be "translated" to behave like tangent lines. And we need to make a decision about the convention, because givin all options to the user makes a pretty bad UX whie "allowing" cases which probably noone wants.
Here is a visualization for feasible regions for a 2-variable case with SOS2/lambda/delta formulations based on which sign is used for which variable:

fig

Compared to tangent line one:

tangent

We can see that we need to fix one of the variables to the slope! TO be precise, all but one variable needs to be fixed!

…rmulation (#663)

* feat: add sign parameter and LP method to add_piecewise_formulation

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>

* docs: add piecewise inequality notebook and update release notes

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>

* docs: add 3D feasibility ribbon and mathematical formulation

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>

* docs: show 3D feasibility ribbon from multiple viewpoints

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>

* docs,fix: clarify that mismatched curvature+sign is wrong, not just loose

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>

* refac: make LP error messages terse

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>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat: log resolved method when method='auto'

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>

* feat: log when LP is skipped and why

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>

* feat: expose convexity on PiecewiseFormulation

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>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: make convexity detection invariant to x-direction

_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>

* fix: mask trailing-NaN segments in LP path

_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>

* docs: correct sign param — applies to first tuple, not last

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>

* refac,test: simplify _detect_convexity and add direct unit tests

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>

* docs,test: document active + non-equality sign asymmetry

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>

* test: extend active + sign='<=' coverage to incremental and disjunctive

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>

* docs,test: show the y.lower=0 recipe for active + non-equality sign

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>

* test: add 7 regression tests for review-flagged coverage gaps

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

* refac: package PWL links in a dataclass and flatten auto-dispatch

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>

* refac: rename PWL_LP_SUFFIX → PWL_CHORD_SUFFIX

``_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>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: persist PiecewiseFormulation.convexity across netCDF round-trip

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>

* test: regression for PiecewiseFormulation netCDF round-trip

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>

* docs: rewrite piecewise reference + tutorial for the new sign/LP API

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>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* docs: compact the main piecewise tutorial notebook

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>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* docs: split piecewise release notes + tidy tangent_lines docstring

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>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@FBumann FBumann changed the title refac: Refactor piecewise API to a stateless construction layer + tangent_lines utility feat: Unify piecewise API behind add_piecewise_formulation (sign + LP dispatch) 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>
@FBumann FBumann force-pushed the feat/piecewise-api-refactor branch from 45d3ea3 to 1f6b563 Compare April 23, 2026 11:15
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 23, 2026

@coroa What do you think about the new api?
I think its quite good and clear now. The testing is also already quite good imo.

One thing we should discuss is the notebooks and docs. If you have the time to read it, feedback and potentially changes would be great!
The one who implemented it usually has a different angle on the docs than the reviewer/user.
https://linopy--638.org.readthedocs.build/en/638/piecewise-linear-constraints.html

@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 23, 2026

@coroa One API detail im not sure about yet, is where to but the sign.
It could be per tuple as a third optional parameter:

m.add_piecewise_formulation(
   (power, xp, "<="),
   (fuel, yp)
)

with defaulting to '==' where not specified.

I decided against this api for simplicity, as using more than i single equality produces mathematically weird bounds as far as i understand it (only relevant for the 3 var case). ALso, for the 2 variable LP case, it doestn make any sense to have more than a single sign. So there is a lot of API surface that would lead to errors we need to catch.

We could however also just disallow the 3 variable case with inequalities for now, as i don`t understand the use case that well. This would allow the arguably more obvious notation of having the sing right next to the expression...

With the EvolvingAPIWarning however, im fine with keeping it and adjusting it if needed.

Looking forward, this API seems better suited for triangular bounding, where we could actually allow multiple inequalities. But the implementation of this is not part of this PR and quite complex i think, so we can just change it as soon as this potential feature lands

FBumann and others added 2 commits April 23, 2026 17:46
The previous framing ("first bounded, rest forced to equality") was
correct but left two things unclear:

1. What "rest forced to equality" means when there are multiple
   equality-side tuples — they are jointly constrained to a single
   segment position on the curve.  Pinning power AND heat to
   independent values is infeasible; their values are coupled by the
   shared segment parameter.

2. Which variable should occupy the first (bounded) position.  A
   consumption-side variable such as fuel intake yields a valid but
   *loose* formulation — the characteristic curve fixes fuel draw at
   a given load, so sign="<=" on fuel admits operating points the
   plant cannot physically realise.  Safe only when no objective
   rewards driving it below the curve; otherwise the optimum can be
   non-physical.  The canonical choice is a dissipation path: heat
   rejection (also called thermal curtailment), electrical
   curtailment, or emissions after post-treatment.

The reference page also now notes that inequality can be faster than
equality — 2-variable cases with matching curvature dispatch to pure
LP, and the relaxed feasible region typically tightens the LP
relaxation for N≥3 too.  Choice of sign is a speed-vs-tightness
trade-off in addition to a physics one.

Updates:

- doc/piecewise-linear-constraints.rst: reframe the sign section as
  "N−1 jointly-pinned, 1 bounded", with an explicit 3-variable
  example showing independent pinning of equality-side tuples is
  infeasible.  New "Choice of bounded tuple" paragraph opens with
  heat rejection and closes with the speed-vs-tightness trade-off.
- examples/piecewise-linear-constraints.ipynb Section 4: the 3-var
  CHP example now bounds ``heat`` (heat rejection) with ``power``
  and ``fuel`` pinned.  Prose introduces "heat rejection" /
  "thermal curtailment" and notes the speed benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@FBumann FBumann requested a review from coroa April 24, 2026 17:09
@FBumann
Copy link
Copy Markdown
Collaborator Author

FBumann commented Apr 24, 2026

One thing I didn't cover yet, is a dedicated slopes object. Should we add it? Or can we do it in a follow up?

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.

2 participants