Committable + Extendable Components¶
This tutorial demonstrates how to create components (Generators and Links) that are both committable and extendable simultaneously. This enables co-optimizing capacity expansion with unit commitment decisions—determining both the optimal capacity to build AND when to turn units on or off.
PyPSA uses a big-M formulation to linearize the nonlinear product of binary status variables and continuous capacity variables, maintaining the Mixed-Integer Linear Programming (MILP) structure.
For the complete mathematical formulation, constraint names, and detailed explanation of the big-M linearization, see Capacity Limits: Committable and Extendable Components.
For discrete capacity expansion with unit commitment (where capacity is built in fixed blocks), see the Modular Committable Components tutorial.
import matplotlib.pyplot as plt
import pandas as pd
import pypsa
Basic Example: Committable + Extendable Generator¶
Let's start with a simple example where a gas generator can be both expanded AND committed (turned on/off). The optimizer will decide both how much capacity to build AND when to turn the unit on or off.
To see on/off behavior, we include a load profile that drops to zero in some periods.
n = pypsa.Network(snapshots=range(6))
n.add("Bus", "bus", carrier="electricity")
n.add("Carrier", "electricity")
# Load profile with zero demand in period 3 - forcing generator to turn off
load_profile = [300, 500, 400, 0, 200, 350]
n.add("Load", "load", bus="bus", p_set=load_profile)
# Add a generator that is BOTH committable AND extendable
n.add(
"Generator",
"gas_ccgt",
bus="bus",
p_nom_extendable=True, # Can expand capacity
committable=True, # Can be turned on/off
p_nom_max=1000, # Maximum capacity that can be built
p_min_pu=0.3, # 30% minimum load when running
marginal_cost=50,
capital_cost=80_000, # Cost per MW of capacity
start_up_cost=500, # Cost to start the unit
shut_down_cost=200, # Cost to shut down the unit
)
n.optimize(log_to_console=False)
/tmp/ipykernel_3637/4229533631.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize(log_to_console=False)
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.06s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 25 primals, 62 duals Objective: 4.01e+07 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p-fixed-upper, Generator-start_up-p-fixed-upper, Generator-shut_down-p-fixed-upper, Generator-com-ext-p-lower, Generator-com-ext-p-upper-bigM, Generator-com-ext-p-upper-cap, Generator-com-ext-p-lower-nonneg, Generator-com-transition-start-up, Generator-com-transition-shut-down, Generator-com-status-min_up_time_must_stay_up were not assigned to the network.
('ok', 'optimal')
Let's examine the results. The optimizer has determined both the optimal capacity AND the commitment schedule:
print(f"Optimal capacity built: {n.generators.p_nom_opt['gas_ccgt']:.1f} MW")
Optimal capacity built: 500.0 MW
# Show commitment status and dispatch
results = pd.DataFrame(
{
"Load": load_profile,
"Status": n.generators_t.status["gas_ccgt"],
"Dispatch": n.generators_t.p["gas_ccgt"],
}
)
results
| Load | Status | Dispatch | |
|---|---|---|---|
| snapshot | |||
| 0 | 300 | 1.0 | 300.0 |
| 1 | 500 | 1.0 | 500.0 |
| 2 | 400 | 1.0 | 400.0 |
| 3 | 0 | 0.0 | -0.0 |
| 4 | 200 | 1.0 | 200.0 |
| 5 | 350 | 1.0 | 350.0 |
Notice how the generator:
- Is turned OFF when load is zero (no need to generate)
- Is turned ON when there is demand
- Always respects the minimum part-load constraint when online (dispatch >= 30% of capacity)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
# Plot dispatch vs load
ax1.plot(results.index, results["Load"], "o-", label="Load", color="red")
ax1.bar(
results.index, results["Dispatch"], alpha=0.7, label="Dispatch", color="steelblue"
)
min_stable = n.generators.p_nom_opt["gas_ccgt"] * 0.3
ax1.axhline(
y=min_stable,
color="orange",
linestyle="--",
label=f"Min stable gen ({min_stable:.0f} MW)",
)
ax1.set_ylabel("Power [MW]")
ax1.legend()
ax1.set_title("Dispatch Profile")
# Plot commitment status
ax2.bar(results.index, results["Status"], color="green", alpha=0.7)
ax2.set_ylabel("Status (1=ON, 0=OFF)")
ax2.set_xlabel("Hour")
ax2.set_title("Commitment Status")
ax2.set_ylim(-0.1, 1.1)
plt.tight_layout()
Ramp Rate Limits with Committable + Extendable¶
Ramp rate limits are fully compatible with the big-M formulation. This is important for modeling thermal generators with limited ramping capabilities.
n = pypsa.Network(snapshots=range(11))
n.add("Bus", "bus", carrier="electricity")
n.add("Carrier", "electricity")
# Load profile with low periods to trigger shut-downs
load_profile = [150, 200, 180, 500, 700, 650, 500, 180, 150, 500, 180]
n.add("Load", "load", bus="bus", p_set=load_profile)
# Fast-ramping peaker (expensive to build and run)
n.add(
"Generator",
"fast_peaker",
bus="bus",
p_nom_extendable=True,
p_nom_max=800,
marginal_cost=140,
capital_cost=2000,
)
# Slow-ramping baseload with commitment
n.add(
"Generator",
"slow_baseload",
bus="bus",
p_nom_extendable=True,
committable=True,
p_nom_max=800,
p_min_pu=0.6,
marginal_cost=30,
capital_cost=200,
ramp_limit_up=0.6, # Can ramp up 60% of capacity per hour
ramp_limit_down=0.6, # Can ramp down 60% of capacity per hour
start_up_cost=800,
)
n.optimize(log_to_console=False)
/tmp/ipykernel_3637/4229533631.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize(log_to_console=False)
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.08s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 57 primals, 176 duals Objective: 7.68e+05 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p-fixed-upper, Generator-start_up-p-fixed-upper, Generator-shut_down-p-fixed-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-com-ext-p-lower, Generator-com-ext-p-upper-bigM, Generator-com-ext-p-upper-cap, Generator-com-ext-p-lower-nonneg, Generator-com-transition-start-up, Generator-com-transition-shut-down, Generator-com-status-min_up_time_must_stay_up, Generator-p-ramp_limit_up, Generator-p-ramp_limit_down, Generator-p-ramp_limit_up-run-bigM, Generator-p-ramp_limit_up-start-bigM, Generator-p-ramp_limit_down-run-bigM, Generator-p-ramp_limit_down-shut-bigM were not assigned to the network.
('ok', 'optimal')
print("Optimal Capacities:")
print(n.generators[["p_nom_opt", "ramp_limit_up", "ramp_limit_down"]])
print("\nCommitment status (slow_baseload):")
status = n.generators_t.status["slow_baseload"].round(0)
print(status)
Optimal Capacities:
p_nom_opt ramp_limit_up ramp_limit_down
name
fast_peaker 200.0 NaN NaN
slow_baseload 650.0 0.6 0.6
Commitment status (slow_baseload):
snapshot
0 0.0
1 0.0
2 0.0
3 1.0
4 1.0
5 1.0
6 1.0
7 0.0
8 0.0
9 1.0
10 0.0
Name: slow_baseload, dtype: float64
# Check ramp rates are respected
dispatch = n.generators_t.p["slow_baseload"]
ramps = dispatch.diff().dropna()
p_nom = n.generators.p_nom_opt["slow_baseload"]
ramp_limit = n.generators.ramp_limit_up["slow_baseload"]
print(f"\nSlow baseload capacity: {p_nom:.1f} MW")
print(f"Max allowed ramp ({ramp_limit:.0%}): {p_nom * ramp_limit:.1f} MW/h")
print("\nActual ramps (MW/h):")
print(ramps)
Slow baseload capacity: 650.0 MW Max allowed ramp (60%): 390.0 MW/h Actual ramps (MW/h): snapshot 1 0.0 2 0.0 3 500.0 4 150.0 5 0.0 6 -150.0 7 -500.0 8 0.0 9 500.0 10 -500.0 Name: slow_baseload, dtype: float64
n.generators_t.p
| name | fast_peaker | slow_baseload |
|---|---|---|
| snapshot | ||
| 0 | 150.0 | -0.0 |
| 1 | 200.0 | -0.0 |
| 2 | 180.0 | -0.0 |
| 3 | 0.0 | 500.0 |
| 4 | 50.0 | 650.0 |
| 5 | 0.0 | 650.0 |
| 6 | 0.0 | 500.0 |
| 7 | 180.0 | -0.0 |
| 8 | 150.0 | -0.0 |
| 9 | 0.0 | 500.0 |
| 10 | 180.0 | -0.0 |
# Visualize dispatch and commitment with ramp constraints
fig, (ax1, ax2) = plt.subplots(
2, 1, figsize=(10, 7), sharex=True, gridspec_kw={"height_ratios": [3, 1]}
)
n.generators_t.p.plot.area(ax=ax1, alpha=0.7, linewidth=0)
ax1.plot(range(len(load_profile)), load_profile, "k--", linewidth=2, label="Load")
ax1.set_ylabel("Power [MW]")
ax1.set_title("Dispatch with Ramp Rate Constraints")
ax1.legend(loc="upper right")
status = n.generators_t.status["slow_baseload"].round(0)
ax2.step(status.index, status.values, where="mid", color="tab:green", linewidth=2)
ax2.set_ylabel("Status")
ax2.set_xlabel("Hour")
ax2.set_ylim(-0.1, 1.1)
ax2.set_yticks([0, 1])
ax2.set_yticklabels(["OFF", "ON"])
plt.tight_layout()
Big-M Configuration¶
The big-M formulation uses a large constant $M$ to linearize constraints. PyPSA automatically infers an appropriate value based on the network's peak load ($M = 10 \times$ peak load), but you can override it manually if needed.
For details on the big-M linearization and configuration options, see Big-M Parameter Configuration.
# The big-M value can be set via the committable_big_m parameter
# None means PyPSA will auto-infer from network peak load
print("By default, PyPSA auto-infers big-M from network peak load")
By default, PyPSA auto-infers big-M from network peak load
# You can set a custom big-M value using the committable_big_m parameter
n.optimize(committable_big_m=10000, log_to_console=False)
print("Optimization with custom big-M (10000) successful!")
/tmp/ipykernel_3637/1513049326.py:2: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize(committable_big_m=10000, log_to_console=False)
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.08s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 57 primals, 176 duals Objective: 7.68e+05 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p-fixed-upper, Generator-start_up-p-fixed-upper, Generator-shut_down-p-fixed-upper, Generator-ext-p-lower, Generator-ext-p-upper, Generator-com-ext-p-lower, Generator-com-ext-p-upper-bigM, Generator-com-ext-p-upper-cap, Generator-com-ext-p-lower-nonneg, Generator-com-transition-start-up, Generator-com-transition-shut-down, Generator-com-status-min_up_time_must_stay_up, Generator-p-ramp_limit_up, Generator-p-ramp_limit_down, Generator-p-ramp_limit_up-run-bigM, Generator-p-ramp_limit_up-start-bigM, Generator-p-ramp_limit_down-run-bigM, Generator-p-ramp_limit_down-shut-bigM were not assigned to the network.
Optimization with custom big-M (10000) successful!
Summary¶
This tutorial demonstrated committable + extendable components in PyPSA:
- Basic Usage: Set both
committable=Trueandp_nom_extendable=Trueon a Generator or Link - Mixed Portfolios: Combine different generator types with various attribute combinations
- Ramp Limits: Fully compatible with ramp rate constraints
- Configuration: Automatic big-M inference with manual override option
!!! note "Learn More" - Capacity Limits: Committable and Extendable Components - Complete mathematical formulation - Modular Committable Components - Discrete capacity blocks with unit commitment