Piecewise Linear Constraints#
Piecewise linear (PWL) constraints approximate nonlinear functions as connected linear segments, allowing you to model cost curves, efficiency curves, or production functions within a linear programming framework.
Quick Start#
Equality — lock variables onto the piecewise curve:
import linopy
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=100)
fuel = m.add_variables(name="fuel")
# fuel = f(power) on the piecewise curve defined by these breakpoints
m.add_piecewise_formulation(
(power, [0, 30, 60, 100]),
(fuel, [0, 36, 84, 170]),
)
Inequality — bound one expression by the curve:
# fuel <= f(power). "auto" picks the cheapest correct formulation
# (pure LP with chord constraints when the curve's curvature matches
# the requested sign; SOS2/incremental otherwise).
m.add_piecewise_formulation(
(fuel, [0, 20, 30, 35]), # bounded output listed FIRST
(power, [0, 10, 20, 30]), # input always on the curve
sign="<=",
)
Each (expression, breakpoints) tuple pairs a variable with its breakpoint
values. All tuples share interpolation weights, so at any feasible point every
variable corresponds to the same point on the piecewise curve.
API#
add_piecewise_formulation#
m.add_piecewise_formulation(
(expr1, breakpoints1),
(expr2, breakpoints2),
...,
sign="==", # "==", "<=", or ">="
method="auto", # "auto", "sos2", "incremental", or "lp"
active=None, # binary variable to gate the constraint
name=None, # base name for generated variables/constraints
)
Creates auxiliary variables and constraints that enforce either an equality
(sign="==", default) or a one-sided bound (sign="<=" / ">=") of the
first expression by the piecewise function of the rest.
breakpoints and segments#
Factory functions that create DataArrays with the correct dimension names:
linopy.breakpoints([0, 50, 100]) # list
linopy.breakpoints({"gen1": [0, 50], "gen2": [0, 80]}, dim="gen") # per-entity
linopy.breakpoints(slopes=[1.2, 1.4], x_points=[0, 30, 60], y0=0) # from slopes
linopy.segments([(0, 10), (50, 100)]) # disjunctive
linopy.segments({"gen1": [(0, 10)], "gen2": [(0, 80)]}, dim="gen")
The sign parameter — equality vs inequality#
The sign argument of add_piecewise_formulation chooses whether all
expressions are locked onto the curve or whether the first one is bounded:
sign="=="(default): every expression lies exactly on the piecewise curve — joint equality. All tuples are symmetric. The feasible region is a 1-D curve in N-space.sign="<=": N−1 tuples are pinned to the curve (moving together along it), and the first tuple’s expression is bounded above by its interpolated value at that shared curve position — it can undershoot.sign=">=": same split, but the first is bounded below (overshoots admitted).
Inequality relaxes one tuple’s curve-equality into a one-sided bound. The others keep moving together along the curve in lockstep — this is the first-tuple convention.
What this means geometrically.
For 2 variables ((y, yp), (x, xp), sign="<="), “N−1 pinned, 1 bounded”
reduces to the familiar hypograph: x moves along the breakpoint axis,
y ranges from its lower bound up to f(x).
For 3+ variables, the N−1 pinned tuples are jointly constrained to a
single segment position on the piecewise curve. In a CHP characteristic
(power, fuel, heat) with sign="<=", fuel and heat trace the
curve simultaneously: specifying fuel = 85 determines the segment
position, which in turn fixes heat to its curve value at that position.
Assigning heat to any value inconsistent with the same segment renders
the system infeasible.
The feasible region in N-space is a 2-dimensional manifold: the 1-D
parametric curve at its upper boundary (for "<="), extended along the
first tuple’s axis down to that variable’s lower bound.
Choice of bounded tuple. The first tuple should correspond to a
quantity with a mechanism for below-curve operation — typically a
controllable dissipation path: heat rejection via cooling tower (also
called thermal curtailment), electrical curtailment, or emissions after
post-treatment. Placing a consumption-side variable such as fuel intake
in the bounded position 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. An
objective that rewards lower fuel may then find a non-physical optimum
— safe only when no such objective pressure exists.
Relatedly, inequality formulations can also be faster to solve: with
2 variables and matching curvature, method="auto" dispatches to the
pure-LP chord formulation (no SOS2, no binaries). For N≥3 the solver
still reaches for SOS2/incremental, but the relaxed feasible region
often tightens the LP relaxation and reduces branch-and-bound work.
Choose sign="==" when you want strict curve adherence (the
tightest feasible region) and sign="<=" / ">=" when either the
physics admits dissipation or the speedup is worth the relaxation.
When is a one-sided bound wanted?
For continuous curves, the main reason to reach for sign="<=" /
">=" is to unlock the LP chord formulation — no SOS2, no
binaries, just pure LP. On a convex/concave curve with a matching sign,
the chord inequalities are as tight as SOS2, so you get the same optimum
with a cheaper model.
For disjunctive curves (segments(...)), sign is a first-class
tool in its own right: disconnected operating regions with a bounded
output, always exact regardless of segment curvature (see the
disjunctive section below).
Beyond that: fuel-on-efficiency-envelope modelling (extra burn above the curve is admissible, cost is still bounded), emissions caps where the curve is itself a convex overestimator, or any situation where the curve bounds a variable that need not sit on it.
If the curvature doesn’t match the sign (convex + "<=", or concave +
">="), LP is not applicable — method="auto" falls back to
SOS2/incremental with the signed output link, which gives a valid but
much more expensive model. In that case prefer sign="==" unless you
genuinely need the one-sided semantics; the equality formulation is
typically simpler to reason about and no more expensive than the SOS2
inequality variant.
Math (2-variable ``sign=”<=”``, concave :math:`f`). The feasible region is the hypograph of \(f\) restricted to the breakpoint range:
For convex \(f\) with sign=">=", the feasible region is the epigraph.
Mismatched sign+curvature (convex + <=, or concave + >=) describes a
non-convex region — method="auto" will fall back to SOS2/incremental and
method="lp" will raise. See the
Piecewise inequalities — the sign parameter notebook for a full walkthrough.
Warning
With sign="<=" and active=0, the output is only bounded above by
0 — the lower side still comes from the output variable’s own lower
bound. In the common case of non-negative outputs (fuel, cost, heat), set
lower=0 on that variable: combined with the y ≤ 0 constraint from
deactivation, this forces y = 0 automatically. See the docstring for
the full recipe.
Breakpoint Construction#
From lists#
The simplest form — pass Python lists directly in the tuple:
m.add_piecewise_formulation(
(power, [0, 30, 60, 100]),
(fuel, [0, 36, 84, 170]),
)
With the breakpoints() factory#
Equivalent, but explicit about the DataArray construction:
m.add_piecewise_formulation(
(power, linopy.breakpoints([0, 30, 60, 100])),
(fuel, linopy.breakpoints([0, 36, 84, 170])),
)
From slopes#
When you know marginal costs (slopes) rather than absolute values:
m.add_piecewise_formulation(
(power, [0, 50, 100, 150]),
(
cost,
linopy.breakpoints(
slopes=[1.1, 1.5, 1.9], x_points=[0, 50, 100, 150], y0=0
),
),
)
# cost breakpoints: [0, 55, 130, 225]
Per-entity breakpoints#
Different generators can have different curves. Pass a dict to
breakpoints() with entity names as keys:
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"
),
),
)
Ragged lengths are NaN-padded automatically. Breakpoints are auto-broadcast
over remaining dimensions (e.g. time).
Disjunctive segments#
For disconnected operating regions (e.g. forbidden zones), use segments():
m.add_piecewise_formulation(
(power, linopy.segments([(0, 0), (50, 80)])),
(cost, linopy.segments([(0, 0), (125, 200)])),
)
The disjunctive formulation is selected automatically when breakpoints have a
segment dimension. sign="<=" / ">=" also works here; the signed link
is applied to the first tuple as usual.
N-variable linking#
Link any number of variables through shared breakpoints:
m.add_piecewise_formulation(
(power, [0, 30, 60, 100]),
(fuel, [0, 40, 85, 160]),
(heat, [0, 25, 55, 95]),
)
With sign="==" (default) all variables are symmetric. With a non-equality
sign the first tuple is the bounded output and the rest are forced to
equality.
Formulation Methods#
Pass method="auto" (the default) and linopy picks the cheapest correct
formulation based on sign, curvature and breakpoint layout:
2-variable inequality on a convex/concave curve →
lp(chord lines, no auxiliary variables)All breakpoints monotonic →
incrementalOtherwise →
sos2Disjunctive (segments) → always
sos2with binary segment selection
The resolved choice is exposed on the returned PiecewiseFormulation via
.method (and .convexity when well-defined). An INFO-level log line
explains the resolution whenever method="auto" is in play.
SOS2 (Convex Combination)#
Works for any breakpoint ordering. Introduces interpolation weights \(\lambda_i\) with an SOS2 adjacency constraint:
The SOS2 constraint ensures at most two adjacent \(\lambda_i\) are non-zero, so every expression is interpolated within the same segment.
With sign != "==" the input tuples still use the equality above; the
first tuple’s link is replaced by a one-sided e_1 \ \text{sign}\ \sum_i
\lambda_i B_{1,i} constraint.
Note
SOS2 is handled via branch-and-bound, similar to integer variables.
Prefer method="incremental" when breakpoints are monotonic.
m.add_piecewise_formulation((power, xp), (fuel, yp), method="sos2")
Incremental (Delta) Formulation#
For strictly monotonic breakpoints. Uses fill-fraction variables \(\delta_i\) with binary indicators \(z_i\):
With sign != "==" the same sign split as SOS2 applies: inputs use the
equality above; the first tuple’s link uses the requested sign.
m.add_piecewise_formulation((power, xp), (fuel, yp), method="incremental")
Limitation: breakpoint sequences must be strictly monotonic.
LP (chord-line) Formulation#
For 2-variable inequality on a convex or concave curve. Adds one chord inequality per segment plus a domain bound — no auxiliary variables and no MIP relaxation:
where \(m_k = (y_{k+1} - y_k)/(x_{k+1} - x_k)\) and
\(c_k = y_k - m_k\, x_k\). For concave \(f\) with sign="<=",
the intersection of all chord inequalities equals the hypograph of
\(f\) on its domain.
The LP dispatch requires curvature and sign to match: sign="<=" needs
concave (or linear); sign=">=" needs convex (or linear). A mismatch
is not just a loose bound — it describes the wrong region (see the
Piecewise inequalities — the sign parameter). method="auto" detects
this and falls back; method="lp" raises.
# y <= f(x) on a concave f — auto picks LP
m.add_piecewise_formulation((y, yp), (x, xp), sign="<=")
# Or explicitly:
m.add_piecewise_formulation((y, yp), (x, xp), sign="<=", method="lp")
Not supported with method="lp": sign="==", more than 2 tuples,
and active. method="auto" falls back to SOS2/incremental in all
three cases.
The underlying chord expressions are also exposed as a standalone helper,
linopy.tangent_lines(x, x_pts, y_pts), which returns the per-segment
chord as a LinearExpression with no variables
created. Use it directly if you want to compose the chord bound with other
constraints by hand, without the domain bound that method="lp" adds
automatically.
Disjunctive (Disaggregated Convex Combination)#
For disconnected segments (gaps between operating regions). Binary indicators \(z_k\) select exactly one segment; SOS2 applies within it:
No big-M constants are needed, giving a tight LP relaxation.
Disjunctive + ``sign``. sign="<=" / ">=" works here too,
applied to the first tuple exactly as for the continuous methods.
Because the disjunctive machinery already carries a per-segment binary,
there is no curvature requirement on the segments — inequality is
always exact on the hypograph (or epigraph) of the active segment,
whatever its slope pattern. This makes disjunctive + sign a first-class
tool for “bounded output on disconnected operating regions” that
method="lp" cannot handle.
Advanced Features#
Active parameter (unit commitment)#
The active parameter gates the piecewise function with a binary variable.
When active=0, all auxiliary variables (and thus the linked expressions)
are forced to zero:
commit = m.add_variables(name="commit", binary=True, coords=[time])
m.add_piecewise_formulation(
(power, [30, 60, 100]),
(fuel, [40, 90, 170]),
active=commit,
)
commit=1: power operates in [30, 100], fuel = f(power)commit=0: power = 0, fuel = 0
Not supported with method="lp".
Note
With a non-equality sign, deactivation only pushes the signed bound to
0 — the complementary side comes from the output variable’s own
lower/upper bound. Set lower=0 on naturally non-negative outputs
(fuel, cost, heat) to pin the output to zero on deactivation. See the
sign section above for details.
Auto-broadcasting#
Breakpoints are automatically broadcast to match expression dimensions — you
don’t need expand_dims:
time = pd.Index([1, 2, 3], name="time")
x = m.add_variables(name="x", lower=0, upper=100, coords=[time])
y = m.add_variables(name="y", coords=[time])
# 1D breakpoints auto-expand to match x's time dimension
m.add_piecewise_formulation((x, [0, 50, 100]), (y, [0, 70, 150]))
NaN masking#
Trailing NaN values in breakpoints mask the corresponding lambda / delta variables (and, for LP, the corresponding chord constraints). This is useful for per-entity breakpoints with ragged lengths:
# gen1 has 3 breakpoints, gen2 has 2 (NaN-padded)
bp = linopy.breakpoints({"gen1": [0, 50, 100], "gen2": [0, 80]}, dim="gen")
Interior NaN values (gaps in the middle) are not supported and raise an error.
Generated variables and constraints#
Given a base name N (either user-supplied or auto-assigned like pwl0),
each formulation creates a predictable set of names:
SOS2 (method="sos2"):
{N}_lambda— variable, interpolation weights{N}_convex— constraint,sum(lambda) == 1(or== active){N}_link— constraint, equality link (stacked inputs whensign != "==", all tuples whensign="=="){N}_output_link— constraint, signed link on the first tuple (only whensign != "==")
Incremental (method="incremental"):
{N}_delta— variable, fill fractions \(\delta_i\){N}_order_binary— variable, per-segment binaries \(z_i\){N}_delta_bound— constraint, \(\delta_i \le z_i\){N}_fill_order— constraint, \(\delta_{i+1} \le \delta_i\){N}_binary_order— constraint, \(z_{i+1} \le \delta_i\){N}_active_bound— constraint, \(\delta_i \le active\) (only whenactiveis given){N}_link/{N}_output_link— same split as SOS2
LP (method="lp"):
{N}_chord— constraint, per-segment chord inequality{N}_domain_lo,{N}_domain_hi— constraints, \(x_0 \le x \le x_n\)no auxiliary variables
Disjunctive (segments(...) input):
{N}_segment_binary— variable, per-segment selectors \(z_k\){N}_select— constraint,sum(z_k) == 1(or== active){N}_lambda— variable, within-segment weights{N}_convex— constraint, per-segment \(\sum_i \lambda_{k,i} = z_k\){N}_link/{N}_output_link— same split as SOS2
See Also#
Piecewise Linear Constraints Tutorial — worked examples of the equality API (notebook)
Piecewise inequalities — the sign parameter — the
signparameter, the LP formulation and the first-tuple convention (notebook)Special Ordered Sets (SOS) Constraints — low-level SOS1/SOS2 constraint API