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);
_images/piecewise-linear-constraints-tutorial_4_0.svg

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

SOS2-capable solver

lambdas (continuous)

incremental

MIP solver, strictly monotonic breakpoints

deltas (continuous) + binaries

lp

any LP solver

no variables — requires sign != "==", 2 tuples, matching curvature

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