Note
You can download this example as a Jupyter notebook or start it in interactive mode.
Piecewise Linear Constraints Tutorial#
add_piecewise_formulation links variables through shared breakpoint weights. Every section below stacks one feature on top of a small shared dispatch pattern — if you want the math, see the reference page. For inequality bounds and the LP chord formulation in depth, see the inequality bounds notebook.
The baseline we extend:
m.add_piecewise_formulation(
(power, [0, 30, 60, 100]),
(fuel, [0, 36, 84, 170]),
)
[1]:
import matplotlib.pyplot as plt
import pandas as pd
import xarray as xr
import linopy
time = pd.Index([1, 2, 3], name="time")
def plot_curve(bp_x, bp_y, operating_x, operating_y, *, color="C0", ax=None):
"""PWL curve with solver's operating points overlaid."""
ax = ax or plt.subplots(figsize=(4.5, 3.5))[1]
ax.plot(bp_x, bp_y, "o-", color=color, label="breakpoints")
ax.plot(operating_x, operating_y, "D", color=color, ms=10, label="solved")
ax.set(xlabel="power", ylabel="fuel")
ax.legend()
return ax
1. Getting started#
A gas turbine with a convex heat rate. Each (variable, 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 curve.
[2]:
x_pts = [0, 30, 60, 100]
y_pts = [0, 36, 84, 170]
demand = xr.DataArray([50, 80, 30], coords=[time])
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=100, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
pwf = m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))
m.add_constraints(power == demand, name="demand")
m.add_objective(fuel.sum())
m.solve(reformulate_sos="auto")
print(pwf) # inspect the auto-resolved method
m.solution[["power", "fuel"]].to_pandas()
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-6k4r5pc8.lp
Reading time = 0.00 seconds
obj: 30 rows, 24 columns, 69 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 30 rows, 24 columns and 69 nonzeros (Min)
Model fingerprint: 0x97b589da
Model has 3 linear objective coefficients
Variable types: 15 continuous, 9 integer (9 binary)
Coefficient statistics:
Matrix range [1e+00, 9e+01]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [3e+01, 8e+01]
Presolve removed 30 rows and 24 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 231 231
Optimal solution found (tolerance 1.00e-04)
Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%
/tmp/ipykernel_2228/290362823.py:9: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
pwf = m.add_piecewise_formulation((power, x_pts), (fuel, y_pts))
Dual values of MILP couldn't be parsed
PiecewiseFormulation `pwl0` [time: 3] — incremental, concave
Variables:
* pwl0_delta (time, _breakpoint_seg)
* pwl0_order_binary (time, _breakpoint_seg)
Constraints:
* pwl0_delta_bound (time, _breakpoint_seg)
* pwl0_fill_order (time, _breakpoint_seg)
* pwl0_binary_order (time, _breakpoint_seg)
* pwl0_link (_breakpoint, _pwl_var, time)
[2]:
| power | fuel | |
|---|---|---|
| time | ||
| 1 | 50.0 | 68.0 |
| 2 | 80.0 | 127.0 |
| 3 | 30.0 | 36.0 |
[3]:
plot_curve(x_pts, y_pts, m.solution["power"].values, m.solution["fuel"].values);
2. Picking a method#
method="auto" (default) picks the cheapest correct formulation based on sign, curvature and breakpoint layout. The explicit options — "sos2", "incremental", "lp" — give the same optimum on equality cases where they all apply, so the choice is about cost (auxiliary variables, solver capability), not correctness.
method |
needs |
creates |
|---|---|---|
|
SOS2-capable solver |
lambdas (continuous) |
|
MIP solver, strictly monotonic breakpoints |
deltas (continuous) + binaries |
|
any LP solver |
no variables — requires |
Below: all applicable methods yield the same fuel dispatch on this convex curve.
[4]:
def solve_method(method):
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=100, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
m.add_piecewise_formulation((power, x_pts), (fuel, y_pts), method=method)
m.add_constraints(power == demand, name="demand")
m.add_objective(fuel.sum())
m.solve(reformulate_sos="auto")
return m.solution["fuel"].to_pandas()
pd.DataFrame({m: solve_method(m) for m in ["auto", "sos2", "incremental"]})
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-5ebn9o3n.lp
Reading time = 0.00 seconds
obj: 30 rows, 24 columns, 69 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 30 rows, 24 columns and 69 nonzeros (Min)
Model fingerprint: 0x97b589da
Model has 3 linear objective coefficients
Variable types: 15 continuous, 9 integer (9 binary)
Coefficient statistics:
Matrix range [1e+00, 9e+01]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [3e+01, 8e+01]
Presolve removed 30 rows and 24 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 231 231
Optimal solution found (tolerance 1.00e-04)
Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%
/tmp/ipykernel_2228/2228433326.py:5: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation((power, x_pts), (fuel, y_pts), method=method)
Dual values of MILP couldn't be parsed
/tmp/ipykernel_2228/2228433326.py:5: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation((power, x_pts), (fuel, y_pts), method=method)
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-2skgk1gk.lp
Reading time = 0.00 seconds
obj: 12 rows, 18 columns, 39 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 12 rows, 18 columns and 39 nonzeros (Min)
Model fingerprint: 0x4a7cdf5d
Model has 3 linear objective coefficients
Model has 3 SOS constraints
Variable types: 18 continuous, 0 integer (0 binary)
Coefficient statistics:
Matrix range [1e+00, 2e+02]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [1e+00, 8e+01]
Presolve removed 12 rows and 18 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 231 231
Optimal solution found (tolerance 1.00e-04)
Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
Restricted license - for non-production use only - expires 2027-11-29
/tmp/ipykernel_2228/2228433326.py:5: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation((power, x_pts), (fuel, y_pts), method=method)
Read LP format model from file /tmp/linopy-problem-61mbnup_.lp
Reading time = 0.00 seconds
obj: 30 rows, 24 columns, 69 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 30 rows, 24 columns and 69 nonzeros (Min)
Model fingerprint: 0x97b589da
Model has 3 linear objective coefficients
Variable types: 15 continuous, 9 integer (9 binary)
Coefficient statistics:
Matrix range [1e+00, 9e+01]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [3e+01, 8e+01]
Presolve removed 30 rows and 24 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 231 231
Optimal solution found (tolerance 1.00e-04)
Best objective 2.310000000000e+02, best bound 2.310000000000e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
[4]:
| auto | sos2 | incremental | |
|---|---|---|---|
| time | |||
| 1 | 68.0 | 68.0 | 68.0 |
| 2 | 127.0 | 127.0 | 127.0 |
| 3 | 36.0 | 36.0 | 36.0 |
3. Disjunctive segments — gaps in the operating range#
When operating regions are disconnected (a diesel generator that is either off or running in [50, 80] MW, never in between), use segments() instead of breakpoints(). A binary picks which segment is active; inside it SOS2 interpolates as usual.
[5]:
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=80, coords=[time])
cost = m.add_variables(name="cost", lower=0, coords=[time])
backup = m.add_variables(name="backup", lower=0, coords=[time])
m.add_piecewise_formulation(
(power, linopy.segments([(0, 0), (50, 80)])), # two disjoint segments
(cost, linopy.segments([(0, 0), (125, 200)])),
)
m.add_constraints(power + backup == xr.DataArray([15, 60, 75], coords=[time]))
m.add_objective(cost.sum() + 10 * backup.sum())
m.solve(reformulate_sos="auto")
m.solution[["power", "cost", "backup"]].to_pandas()
/tmp/ipykernel_2228/1810515437.py:6: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation(
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-0552xuyw.lp
Reading time = 0.00 seconds
obj: 18 rows, 27 columns, 48 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 18 rows, 27 columns and 48 nonzeros (Min)
Model fingerprint: 0x389448c8
Model has 6 linear objective coefficients
Model has 6 SOS constraints
Variable types: 21 continuous, 6 integer (6 binary)
Coefficient statistics:
Matrix range [1e+00, 2e+02]
Objective range [1e+00, 1e+01]
Bounds range [1e+00, 8e+01]
RHS range [1e+00, 8e+01]
Presolve removed 18 rows and 27 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 487.5 487.5
Optimal solution found (tolerance 1.00e-04)
Best objective 4.875000000000e+02, best bound 4.875000000000e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
[5]:
| power | cost | backup | |
|---|---|---|---|
| time | |||
| 1 | 0.0 | 0.0 | 15.0 |
| 2 | 60.0 | 150.0 | 0.0 |
| 3 | 75.0 | 187.5 | 0.0 |
At t=1 the 15 MW demand falls in the forbidden zone; the unit sits at 0 and backup fills the gap.
4. Inequality bounds — sign="<="#
sign="<=" / ">=" relaxes one tuple into a one-sided bound; the remaining N−1 tuples stay pinned to the curve and move together along it in lockstep.
With 2 tuples, this is the familiar hypograph
{(x, y) : y ≤ f(x)}.With 3+ tuples, the N−1 “pinned” inputs cannot be constrained independently — they share a single curve-segment position.
On a concave curve with <= (or convex with >=), method="auto" dispatches to a pure LP chord formulation — no binaries, no SOS2. The first tuple is the bounded output.
See the inequality bounds notebook for mismatched curvature, auto-dispatch fallbacks, and more geometry.
[6]:
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=120, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
# concave curve: diminishing marginal fuel per MW
pwf = m.add_piecewise_formulation(
(fuel, [0, 50, 90, 120]), # bounded output (listed FIRST)
(power, [0, 40, 80, 120]),
sign="<=",
)
m.add_constraints(power == xr.DataArray([30, 80, 100], coords=[time]))
m.add_objective(-fuel.sum()) # push fuel against the bound
m.solve(reformulate_sos="auto")
print(f"resolved method={pwf.method}, curvature={pwf.convexity}")
m.solution[["power", "fuel"]].to_pandas()
/tmp/ipykernel_2228/836155957.py:6: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
pwf = m.add_piecewise_formulation(
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-ief11ncc.lp
Reading time = 0.00 seconds
obj: 18 rows, 6 columns, 27 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 18 rows, 6 columns and 27 nonzeros (Min)
Model fingerprint: 0x3396fd74
Model has 3 linear objective coefficients
Coefficient statistics:
Matrix range [8e-01, 1e+00]
Objective range [1e+00, 1e+00]
Bounds range [1e+02, 1e+02]
RHS range [1e+01, 1e+02]
Presolve removed 18 rows and 6 columns
Presolve time: 0.01s
Presolve: All rows and columns removed
Iteration Objective Primal Inf. Dual Inf. Time
0 -2.3250000e+02 0.000000e+00 0.000000e+00 0s
Solved in 0 iterations and 0.01 seconds (0.00 work units)
Optimal objective -2.325000000e+02
resolved method=lp, curvature=concave
[6]:
| power | fuel | |
|---|---|---|
| time | ||
| 1 | 30.0 | 37.5 |
| 2 | 80.0 | 90.0 |
| 3 | 100.0 | 105.0 |
3-variable case — CHP plant with heat rejection
A CHP plant is characterised by a 1-parameter family of operating points (the load parameter). Power, fuel and heat are joint outputs of that parameter, tracing the characteristic curve simultaneously.
For the inequality formulation to be physically meaningful, the first (bounded) tuple must correspond to a quantity with an available dissipation mechanism. A canonical example is heat rejection (also called thermal curtailment): when downstream heating demand falls below the plant’s co-generation capacity at its committed electrical output, excess thermal output is rejected via a cooling tower. Electrical output and fuel draw remain pinned to the load parameter; heat delivery can be anywhere from the rejection floor up to the characteristic curve.
Other admissible choices for the bounded tuple: electrical curtailment, emissions after post-treatment. Placing a consumption-side variable (such as fuel intake) in the bounded position yields a valid but loose formulation — safe only when no objective rewards driving it below the curve.
Inequality formulations can also be faster to solve than the equality variant (see Choice of bounded tuple in the reference page), so the speed-vs-tightness trade-off is worth weighing even when the physics is strictly equality.
Below: heat is the bounded output (rejection); power and fuel are pinned to the characteristic curve.
[7]:
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=100, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
heat = m.add_variables(name="heat", lower=0, coords=[time])
# bounded output listed FIRST (heat rejection); power, fuel pinned to the curve
m.add_piecewise_formulation(
(heat, [0, 25, 55, 95]), # bounded above — heat rejection
(power, [0, 30, 60, 100]), # pinned — electrical output at load
(fuel, [0, 40, 85, 160]), # pinned — fuel draw at load
sign="<=",
method="sos2",
)
# fix the load via a power target — remaining outputs are determined
m.add_constraints(power == xr.DataArray([30, 60, 100], coords=[time]))
m.add_objective(-heat.sum()) # maximise heat — no rejection required
m.solve(reformulate_sos="auto")
m.solution[["power", "fuel", "heat"]].to_pandas()
/tmp/ipykernel_2228/3453121093.py:7: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation(
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-h4_lasek.lp
Reading time = 0.00 seconds
obj: 15 rows, 21 columns, 51 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 15 rows, 21 columns and 51 nonzeros (Min)
Model fingerprint: 0xac8f59a8
Model has 3 linear objective coefficients
Model has 3 SOS constraints
Variable types: 21 continuous, 0 integer (0 binary)
Coefficient statistics:
Matrix range [1e+00, 2e+02]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [1e+00, 1e+02]
Presolve removed 15 rows and 21 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 1: -175
No other solutions better than -175
Optimal solution found (tolerance 1.00e-04)
Best objective -1.750000025894e+02, best bound -1.750000025894e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
[7]:
| power | fuel | heat | |
|---|---|---|---|
| time | |||
| 1 | 30.0 | 40.000003 | 25.000003 |
| 2 | 60.0 | 85.000000 | 55.000000 |
| 3 | 100.0 | 160.000000 | 95.000000 |
5. Unit commitment — active#
A binary variable gates the whole formulation. active=0 forces the PWL variables (and thus all linked outputs) to zero. Combined with the natural lower=0 on cost/fuel/heat, this gives a clean on/off coupling:
active=1: the unit operates in its full range, outputs tied to the curve.active=0:power = 0,fuel = 0.
[8]:
m = linopy.Model()
p_min, p_max = 30, 100
power = m.add_variables(name="power", lower=0, upper=p_max, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
backup = m.add_variables(name="backup", lower=0, coords=[time])
commit = m.add_variables(name="commit", binary=True, coords=[time])
m.add_piecewise_formulation(
(power, [p_min, 60, p_max]),
(fuel, [40, 90, 170]),
active=commit,
)
# demand below p_min at t=1 — commit must be 0 and backup covers it
m.add_constraints(power + backup == xr.DataArray([15, 80, 40], coords=[time]))
m.add_objective(fuel.sum() + 50 * commit.sum() + 200 * backup.sum())
m.solve(reformulate_sos="auto")
m.solution[["commit", "power", "fuel", "backup"]].to_pandas()
/tmp/ipykernel_2228/2981981563.py:9: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation(
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-vcxldcmc.lp
Reading time = 0.00 seconds
obj: 27 rows, 24 columns, 66 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 27 rows, 24 columns and 66 nonzeros (Min)
Model fingerprint: 0xbd86defa
Model has 9 linear objective coefficients
Variable types: 15 continuous, 9 integer (9 binary)
Coefficient statistics:
Matrix range [1e+00, 8e+01]
Objective range [1e+00, 2e+02]
Bounds range [1e+00, 1e+02]
RHS range [2e+01, 8e+01]
Presolve removed 27 rows and 24 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 3286.67 3286.67
Optimal solution found (tolerance 1.00e-04)
Best objective 3.286666666667e+03, best bound 3.286666666667e+03, gap 0.0000%
Dual values of MILP couldn't be parsed
[8]:
| commit | power | fuel | backup | |
|---|---|---|---|---|
| time | ||||
| 1 | 0.0 | 0.0 | 0.000000 | 15.0 |
| 2 | 1.0 | 80.0 | 130.000000 | 0.0 |
| 3 | 1.0 | 40.0 | 56.666667 | 0.0 |
6. N-variable linking — CHP plant#
More than two variables can share the same interpolation — useful for combined heat-and-power plants where power, fuel and heat are all functions of a single operating point.
[9]:
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=100, coords=[time])
fuel = m.add_variables(name="fuel", lower=0, coords=[time])
heat = m.add_variables(name="heat", lower=0, coords=[time])
m.add_piecewise_formulation(
(power, [0, 30, 60, 100]),
(fuel, [0, 40, 85, 160]),
(heat, [0, 25, 55, 95]),
)
m.add_constraints(fuel == xr.DataArray([20, 100, 160], coords=[time]))
m.add_objective(power.sum())
m.solve(reformulate_sos="auto")
m.solution[["power", "fuel", "heat"]].to_pandas().round(2)
/tmp/ipykernel_2228/4286209043.py:6: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation(
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-zada4wek.lp
Reading time = 0.00 seconds
obj: 33 rows, 27 columns, 81 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 33 rows, 27 columns and 81 nonzeros (Min)
Model fingerprint: 0x21acaf66
Model has 3 linear objective coefficients
Variable types: 18 continuous, 9 integer (9 binary)
Coefficient statistics:
Matrix range [1e+00, 8e+01]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 1e+02]
RHS range [2e+01, 2e+02]
Presolve removed 33 rows and 27 columns
Presolve time: 0.00s
Presolve: All rows and columns removed
Explored 0 nodes (0 simplex iterations) in 0.00 seconds (0.00 work units)
Thread count was 1 (of 2 available processors)
Solution count 2: 183 183
Optimal solution found (tolerance 1.00e-04)
Best objective 1.830000000000e+02, best bound 1.830000000000e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
[9]:
| power | fuel | heat | |
|---|---|---|---|
| time | |||
| 1 | 15.0 | 20.0 | 12.5 |
| 2 | 68.0 | 100.0 | 63.0 |
| 3 | 100.0 | 160.0 | 95.0 |
7. Per-entity breakpoints — a fleet of generators#
Pass a dict to breakpoints() with entity names as keys for different curves per entity. Ragged lengths are NaN-padded automatically, and breakpoints broadcast over any remaining dimensions (here, time).
[10]:
gens = pd.Index(["gas", "coal"], name="gen")
x_gen = linopy.breakpoints(
{"gas": [0, 30, 60, 100], "coal": [0, 50, 100, 150]}, dim="gen"
)
y_gen = linopy.breakpoints(
{"gas": [0, 40, 90, 180], "coal": [0, 55, 130, 225]}, dim="gen"
)
m = linopy.Model()
power = m.add_variables(name="power", lower=0, upper=150, coords=[gens, time])
fuel = m.add_variables(name="fuel", lower=0, coords=[gens, time])
m.add_piecewise_formulation((power, x_gen), (fuel, y_gen))
m.add_constraints(power.sum("gen") == xr.DataArray([80, 120, 50], coords=[time]))
m.add_objective(fuel.sum())
m.solve(reformulate_sos="auto")
m.solution[["power", "fuel"]].to_dataframe()
/tmp/ipykernel_2228/1402927855.py:12: EvolvingAPIWarning: piecewise: add_piecewise_formulation is a new API; some details (e.g. the sign/first-tuple convention, active+sign semantics) may be refined in minor releases. Please share your use cases or concerns at https://github.com/PyPSA/linopy/issues — your feedback shapes what stabilises. Silence with `warnings.filterwarnings("ignore", category=linopy.EvolvingAPIWarning)`.
m.add_piecewise_formulation((power, x_gen), (fuel, y_gen))
Restricted license - for non-production use only - expires 2027-11-29
Read LP format model from file /tmp/linopy-problem-0auqnwpa.lp
Reading time = 0.00 seconds
obj: 57 rows, 48 columns, 138 nonzeros
Gurobi Optimizer version 13.0.1 build v13.0.1rc0 (linux64 - "Ubuntu 24.04 LTS")
CPU model: AMD EPYC 9R14, instruction set [SSE2|AVX|AVX2|AVX512]
Thread count: 2 physical cores, 2 logical processors, using up to 2 threads
Optimize a model with 57 rows, 48 columns and 138 nonzeros (Min)
Model fingerprint: 0x0f6c7ea8
Model has 6 linear objective coefficients
Variable types: 30 continuous, 18 integer (18 binary)
Coefficient statistics:
Matrix range [1e+00, 1e+02]
Objective range [1e+00, 1e+00]
Bounds range [1e+00, 2e+02]
RHS range [5e+01, 1e+02]
Found heuristic solution: objective 333.3333333
Presolve removed 49 rows and 38 columns
Presolve time: 0.00s
Presolved: 8 rows, 10 columns, 22 nonzeros
Found heuristic solution: objective 310.0000000
Variable types: 6 continuous, 4 integer (4 binary)
Root relaxation: objective 3.050000e+02, 1 iterations, 0.00 seconds (0.00 work units)
Nodes | Current Node | Objective Bounds | Work
Expl Unexpl | Obj Depth IntInf | Incumbent BestBd Gap | It/Node Time
0 0 305.00000 0 1 310.00000 305.00000 1.61% - 0s
* 0 0 0 305.0000000 305.00000 0.00% - 0s
Explored 1 nodes (2 simplex iterations) in 0.01 seconds (0.00 work units)
Thread count was 2 (of 2 available processors)
Solution count 3: 305 310 333.333
Optimal solution found (tolerance 1.00e-04)
Best objective 3.050000000000e+02, best bound 3.050000000000e+02, gap 0.0000%
Dual values of MILP couldn't be parsed
[10]:
| power | fuel | ||
|---|---|---|---|
| gen | time | ||
| gas | 1 | 30.0 | 40.0 |
| 2 | 30.0 | 40.0 | |
| 3 | 0.0 | 0.0 | |
| coal | 1 | 50.0 | 55.0 |
| 2 | 90.0 | 115.0 | |
| 3 | 50.0 | 55.0 |