Modular Expansion with Unit Commitment¶
This tutorial demonstrates modular expansion combined with unit commitment in PyPSA. When both p_nom_mod > 0 and committable=True are set, the status variable represents the number of committed modules (integer) rather than a simple binary on/off.
This formulation is ideal for modeling technologies that come in standardized sizes: modular gas turbines, nuclear reactors, or HVDC interconnectors. The optimizer co-optimizes both how many modules to build AND how many to operate at each time step.
For the complete mathematical formulation, constraint names, and detailed explanation of the module-level commitment formulation, see Capacity Limits: Modular and Committable Components.
For continuous capacity expansion with unit commitment (big-M formulation), see the Committable + Extendable Components tutorial.
import matplotlib.pyplot as plt
import pandas as pd
import pypsa
Basic Example: Modular Gas Turbines¶
Let's model a fleet of gas turbines that:
- Come in 200 MW modules
- Can be committed/decommitted based on load
- Have a 10% minimum load when running
The optimizer will decide both how many modules to build AND how many to run in each hour.
n = pypsa.Network(snapshots=range(4))
n.add("Bus", "bus", carrier="electricity")
n.add("Carrier", "electricity")
# Variable load pattern - requires different numbers of modules
load_profile = [4000, 6000, 5000, 800]
n.add("Load", "load", bus="bus", p_set=load_profile)
# Add a modular, committable, extendable generator
# Capacity must be built in 200 MW modules
n.add(
"Generator",
"modular_gas",
bus="bus",
p_nom_extendable=True,
committable=True,
p_nom_mod=200, # Must build in 200 MW increments
p_nom_max=10000,
p_min_pu=0.1, # 10% minimum load per committed module
marginal_cost=1,
capital_cost=1,
stand_by_cost=1, # Penalize keeping modules online unnecessarily
)
n.optimize(log_to_console=False)
/tmp/ipykernel_5146/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.07s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 18 primals, 35 duals Objective: 2.19e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p_nom-variable-upper, Generator-start_up-p_nom-variable-upper, Generator-shut_down-p_nom-variable-upper, Generator-com-mod-p-lower, Generator-com-mod-p-upper, 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')
p_nom_opt = n.generators.p_nom_opt["modular_gas"]
p_nom_mod = n.generators.p_nom_mod["modular_gas"]
n_modules = p_nom_opt / p_nom_mod
print(f"Optimal capacity: {p_nom_opt:.0f} MW")
print(f"Module size: {p_nom_mod:.0f} MW")
print(f"Number of modules built: {n_modules:.0f}")
Optimal capacity: 6000 MW Module size: 200 MW Number of modules built: 30
# Verify the capacity is indeed a multiple of the module size
assert abs(n_modules - round(n_modules)) < 1e-6, (
"Capacity should be a multiple of module size!"
)
print("Capacity is correctly constrained to module size multiples.")
Capacity is correctly constrained to module size multiples.
# The status variable now represents number of committed modules
results = pd.DataFrame(
{
"Load": load_profile,
"Modules Committed": n.generators_t.status["modular_gas"].astype(int),
"Dispatch": n.generators_t.p["modular_gas"],
}
)
results
| Load | Modules Committed | Dispatch | |
|---|---|---|---|
| snapshot | |||
| 0 | 4000 | 20 | 4000.0 |
| 1 | 6000 | 30 | 6000.0 |
| 2 | 5000 | 25 | 5000.0 |
| 3 | 800 | 4 | 800.0 |
Notice how the number of committed modules changes with load - the optimizer only commits as many modules as needed to meet demand while respecting minimum part-load constraints.
Key insight: In period 3, demand drops to 800 MW, so only 4 modules (800 MW capacity) are needed. The other modules are kept offline to avoid stand-by costs.
# Visualize modules committed vs load
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
# Dispatch
ax1.bar(
results.index, results["Dispatch"], alpha=0.7, color="steelblue", label="Dispatch"
)
ax1.plot(results.index, results["Load"], "ro-", label="Load")
ax1.set_ylabel("Power [MW]")
ax1.set_title("Dispatch vs Load")
ax1.legend()
# Modules committed
ax2.bar(results.index, results["Modules Committed"], color="green", alpha=0.7)
ax2.axhline(
y=n_modules,
color="red",
linestyle="--",
label=f"Total modules built ({int(n_modules)})",
)
ax2.set_ylabel("Modules Committed")
ax2.set_xlabel("Hour")
ax2.set_title("Module Commitment Schedule")
ax2.legend()
plt.tight_layout()
Modular Interconnector with Commitment¶
The modular commitment feature also works for Links. This is useful for:
- HVDC interconnectors with discrete cable capacities
- Electrolyzers that come in standardized stack sizes
- Any conversion technology with modular capacity and operational constraints
n = pypsa.Network(snapshots=range(4))
n.add("Bus", "zone_A", carrier="electricity")
n.add("Bus", "zone_B", carrier="electricity")
n.add("Carrier", "electricity")
# Cheap generation in zone A
n.add("Generator", "gen_A", bus="zone_A", p_nom=1000, marginal_cost=20)
# Load in zone B
load_B = [300, 500, 400, 150]
n.add("Load", "load_B", bus="zone_B", p_set=load_B)
# Expensive backup in zone B
n.add("Generator", "gen_B", bus="zone_B", p_nom=600, marginal_cost=100)
# Committable + extendable + modular interconnector
n.add(
"Link",
"interconnector",
bus0="zone_A",
bus1="zone_B",
p_nom_extendable=True,
committable=True,
p_nom_mod=150, # 150 MW modules (e.g., HVDC cables)
p_nom_max=600,
p_min_pu=0.2, # Minimum flow when active
marginal_cost=5,
capital_cost=300,
start_up_cost=50,
)
n.optimize(log_to_console=False)
/tmp/ipykernel_5146/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.07s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 26 primals, 55 duals Objective: 1.35e+05 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Link-status-p_nom-variable-upper, Link-start_up-p_nom-variable-upper, Link-shut_down-p_nom-variable-upper, Generator-fix-p-lower, Generator-fix-p-upper, Link-com-mod-p-lower, Link-com-mod-p-upper, Link-com-transition-start-up, Link-com-transition-shut-down, Link-com-status-min_up_time_must_stay_up were not assigned to the network.
('ok', 'optimal')
p_nom_opt = n.links.p_nom_opt["interconnector"]
p_nom_mod = n.links.p_nom_mod["interconnector"]
print(f"Optimal interconnector capacity: {p_nom_opt:.0f} MW")
print(f"Number of {p_nom_mod:.0f} MW modules: {p_nom_opt / p_nom_mod:.0f}")
Optimal interconnector capacity: 150 MW Number of 150 MW modules: 1
# Verify n_mod variable exists for modular link
print(
f"n_mod variable value: {n.model.variables['Link-n_mod'].solution.loc['interconnector']}"
)
n_mod variable value: <xarray.DataArray 'solution' ()> Size: 8B
array(1.)
Coordinates:
name <U14 56B 'interconnector'
pd.DataFrame(
{
"Load_B": load_B,
"Link_Status": n.links_t.status["interconnector"],
"Link_Flow": n.links_t.p0["interconnector"],
"Gen_B_Dispatch": n.generators_t.p["gen_B"],
}
)
| Load_B | Link_Status | Link_Flow | Gen_B_Dispatch | |
|---|---|---|---|---|
| snapshot | ||||
| 0 | 300 | 1.0 | 150.0 | 150.0 |
| 1 | 500 | 1.0 | 150.0 | 350.0 |
| 2 | 400 | 1.0 | 150.0 | 250.0 |
| 3 | 150 | 1.0 | 150.0 | 0.0 |
Start-up and Shut-down Dynamics¶
With modular commitment, start-up and shut-down costs are incurred when the number of committed modules changes. Let's examine this behavior in detail.
n = pypsa.Network(snapshots=range(6))
n.add("Bus", "bus", carrier="electricity")
n.add("Carrier", "electricity")
# Load that varies significantly
load_profile = [500, 1000, 600, 200, 800, 400]
n.add("Load", "load", bus="bus", p_set=load_profile)
n.add(
"Generator",
"modular_plant",
bus="bus",
p_nom_extendable=True,
committable=True,
p_nom_mod=100, # 100 MW modules
p_nom_max=2000,
p_min_pu=0.4, # 20% minimum load per module
marginal_cost=30,
capital_cost=100,
start_up_cost=10, # Significant start-up cost per module
shut_down_cost=5, # Shut-down cost per module
)
n.optimize(log_to_console=False)
/tmp/ipykernel_5146/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: 26 primals, 51 duals Objective: 2.05e+05 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-status-p_nom-variable-upper, Generator-start_up-p_nom-variable-upper, Generator-shut_down-p_nom-variable-upper, Generator-com-mod-p-lower, Generator-com-mod-p-upper, 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')
p_nom_opt = n.generators.p_nom_opt["modular_plant"]
p_nom_mod = n.generators.p_nom_mod["modular_plant"]
n_modules_built = int(p_nom_opt / p_nom_mod)
print(f"Total capacity built: {p_nom_opt:.0f} MW ({n_modules_built} modules)")
Total capacity built: 1000 MW (10 modules)
# Examine commitment dynamics
results = pd.DataFrame(
{
"Load": load_profile,
"Modules_Committed": n.generators_t.status["modular_plant"].astype(int),
"Dispatch": n.generators_t.p["modular_plant"].round(1),
"Start_ups": n.generators_t.start_up["modular_plant"].astype(int),
"Shut_downs": n.generators_t.shut_down["modular_plant"].astype(int),
}
)
results
| Load | Modules_Committed | Dispatch | Start_ups | Shut_downs | |
|---|---|---|---|---|---|
| snapshot | |||||
| 0 | 500 | 10 | 500.0 | 9 | 0 |
| 1 | 1000 | 10 | 1000.0 | 0 | 0 |
| 2 | 600 | 6 | 600.0 | 0 | 4 |
| 3 | 200 | 5 | 200.0 | 0 | 1 |
| 4 | 800 | 8 | 800.0 | 3 | 0 |
| 5 | 400 | 8 | 400.0 | 0 | 0 |
# Calculate costs
gen = n.generators.loc["modular_plant"]
total_startup_cost = results["Start_ups"].sum() * gen.start_up_cost
total_shutdown_cost = results["Shut_downs"].sum() * gen.shut_down_cost
print(f"Total start-up cost: {total_startup_cost:.0f}")
print(f"Total shut-down cost: {total_shutdown_cost:.0f}")
Total start-up cost: 120 Total shut-down cost: 25
# Visualize commitment schedule with start-ups and shut-downs
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
# Dispatch vs Load
axes[0].bar(
results.index, results["Dispatch"], alpha=0.7, color="steelblue", label="Dispatch"
)
axes[0].plot(results.index, results["Load"], "ro-", label="Load")
axes[0].set_ylabel("Power [MW]")
axes[0].set_title("Dispatch vs Load")
axes[0].legend()
# Modules committed
axes[1].bar(results.index, results["Modules_Committed"], color="green", alpha=0.7)
axes[1].axhline(
y=n_modules_built,
color="red",
linestyle="--",
label=f"Total built ({n_modules_built})",
)
axes[1].set_ylabel("Modules Online")
axes[1].set_title("Module Commitment")
axes[1].legend()
# Start-ups and shut-downs
x = results.index
width = 0.35
axes[2].bar(
x - width / 2, results["Start_ups"], width, label="Start-ups", color="orange"
)
axes[2].bar(
x + width / 2, results["Shut_downs"], width, label="Shut-downs", color="purple"
)
axes[2].set_ylabel("Number of Modules")
axes[2].set_xlabel("Hour")
axes[2].set_title("Module Start-ups and Shut-downs")
axes[2].legend()
plt.tight_layout()
Summary¶
This tutorial demonstrated modular expansion with unit commitment in PyPSA:
- Modular Capacity: Set
p_nom_modto force capacity to be built in discrete blocks - Module-Level Commitment: When combined with
committable=True, status represents the number of committed modules (integer variable) - Generators and Links: The feature works for both component types
- Start-up/Shut-down Costs: Apply when the number of committed modules changes
- Minimum Load:
p_min_puis enforced per committed module
Related Material:¶
- Capacity Limits: Modular and Committable Components - Complete mathematical formulation
- Committable + Extendable Components - Continuous capacity with big-M formulation