Maintenance Scheduling¶
This tutorial demonstrates maintenance scheduling for generators. Setting maintainable=True introduces binary variables that schedule contiguous maintenance blocks, reducing or eliminating the component's available capacity during those periods.
To enable maintenance scheduling on a component (Generator, Link or Process), set its attribute maintainable=True and specify maintenance_duration, the elapsed time per maintenance event in units of the snapshot weightings (hours by default).
import matplotlib.pyplot as plt
Basic Maintenance Scheduling¶
A cheap generator must undergo 3 hours of maintenance (here, 3 snapshots). The optimizer places the maintenance block where the backup generator can cover the load at minimum cost.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom=100,
marginal_cost=10,
maintainable=True,
maintenance_duration=3,
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
load = [80] * 10
load[0] = 10
load[1] = 10
load[2] = 10
n.add("Load", "load", bus="bus", p_set=load)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.04s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 40 primals, 71 duals Objective: 8.60e+03 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 8.60e+03 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
The maintenance is scheduled during the low-demand period (snapshots 0-2), minimizing the cost of dispatching the expensive backup generator:
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Partial Maintenance¶
With maintenance_pu=0.5, only 50% of capacity is unavailable during maintenance. The generator can still dispatch at reduced capacity.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom=100,
marginal_cost=10,
maintainable=True,
maintenance_duration=2,
maintenance_pu=0.5,
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
n.add("Load", "load", bus="bus", p_set=80)
n.optimize(output_flag=False)
/tmp/ipykernel_4063/1308083766.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(output_flag=False) WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - output_flag: False - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 40 primals, 71 duals Objective: 1.34e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 1.34e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
During maintenance, the cheap generator is limited to 50 MW (50% of 100 MW). The backup covers the remaining 30 MW of the 80 MW load:
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Partial Maintenance)")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Interaction with p_min_pu and p_max_pu¶
The maintenance reduction scales the entire available dispatch band, not just the ceiling. During maintenance the feasible dispatch of a generator is
$$\underline{g}\,\hat{g}\,(1 - \alpha\, m) \;\le\; g \;\le\; \bar{g}\,\hat{g}\,(1 - \alpha\, m)$$
with $\alpha$ = maintenance_pu and $m$ the maintenance status. Two consequences:
- The upper bound
p_max_puis derated, as expected. A time-varyingp_max_pu(e.g. a wind profile) is preserved — the effective ceiling is $\bar{g}(t)\,\hat{g}\,(1 - \alpha\, m)$. - The lower bound
p_min_puis derated by the same factor. A must-run generator (p_min_pu > 0) is therefore not held at its full minimum during partial maintenance; its floor shrinks proportionally. At full maintenance ($\alpha = 1$, the default) both bounds collapse to 0 and the unit is switched fully off.
Below, a must-run generator (p_min_pu=0.5) is more expensive than the cheap generator, so it sits on its floor. During the maintenance window (maintenance_pu=0.5) the floor drops from 50 MW to 25 MW, and the cheap generator covers the difference.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"mustrun",
bus="bus",
p_nom=100,
marginal_cost=100,
p_min_pu=0.5, # must-run floor at 50 MW
maintainable=True,
maintenance_duration=3,
maintenance_pu=0.5,
)
n.add("Generator", "cheap", bus="bus", p_nom=100, marginal_cost=10)
n.add("Load", "load", bus="bus", p_set=100)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 40 primals, 71 duals Objective: 4.82e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 4.82e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
The shaded region is the maintenance-derated band $[\underline{g}, \bar{g}]\,\hat{g}\,(1 - \alpha m)$; the must-run dispatch tracks its lower edge, which halves during the event:
g = n.generators.loc["mustrun"]
m = n.generators_t.maintenance["mustrun"]
factor = 1 - g.maintenance_pu * m
eff_min = g.p_min_pu * g.p_nom * factor
eff_max = g.p_max_pu * g.p_nom * factor
x = range(len(n.snapshots))
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
axes[0].fill_between(
x, eff_min, eff_max, step="mid", alpha=0.3, color="C0", label="available band"
)
axes[0].step(x, n.generators_t.p["mustrun"], where="mid", color="C1", label="dispatch")
axes[0].set_ylabel("Power [MW]")
axes[0].set_title("Must-run band and dispatch")
axes[0].legend()
n.generators_t.maintenance["mustrun"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
For committable units the band is additionally multiplied by the commitment status (or number of committed modules), and full maintenance lets the unit shut down entirely — see the Unit Commitment and Modular Capacity Expansion sections below.
Multiple Events¶
With maintenance_events=2, the generator must undergo two separate maintenance blocks of maintenance_duration=2 hours each.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom=100,
marginal_cost=10,
maintainable=True,
maintenance_duration=2,
maintenance_events=2,
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
n.add("Load", "load", bus="bus", p_set=50)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 40 primals, 71 duals Objective: 2.30e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 2.30e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
Both maintenance blocks are contiguous, and the total maintenance duration is 4 hours (2 events x 2 hours):
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Multiple Events)")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Staggered Maintenance for Multiple Generators¶
When multiple generators require maintenance, the optimizer will stagger their maintenance windows to maintain system adequacy. Here, two generators each need 2-snapshot maintenance, but the load requires at least one to be online at all times.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
["gen0", "gen1"],
bus="bus",
p_nom=100,
marginal_cost=10,
maintainable=True,
maintenance_duration=2,
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
n.add("Load", "load", bus="bus", p_set=150)
n.optimize(output_flag=False, include_objective_constant=False)
WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - output_flag: False - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 70 primals, 112 duals Objective: 3.30e+04 Solver: highs Runtime: 0.08s MIP gap: 0.00e+00 Dual bound: 3.30e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Staggered Maintenance)")
n.generators_t.maintenance[["gen0", "gen1"]].plot.bar(ax=axes[1])
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Weighted-Time Duration¶
maintenance_duration is an elapsed time, not a snapshot count. Each event covers the minimal run of consecutive snapshots whose snapshot_weightings sum to at least the requested duration (rounding up). With non-uniform or coarse snapshots, the number of covered snapshots therefore differs from the duration value.
Here the snapshots represent 3-hour intervals, so a maintenance_duration=6 hours covers just two snapshots.
n = pypsa.Network(snapshots=range(8))
n.snapshot_weightings.loc[:, :] = 3 # each snapshot represents 3 elapsed hours
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom=100,
marginal_cost=10,
maintainable=True,
maintenance_duration=6, # 6 elapsed hours -> 2 snapshots of 3 h each
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
load = [80] * 8
load[0] = 10
load[1] = 10
n.add("Load", "load", bus="bus", p_set=load)
n.sanitize()
INFO:pypsa.consistency:Sanitizing network...
INFO:pypsa.components._types.carriers:Adding 1 missing carriers: ['AC']
INFO:pypsa.components._types.carriers:Assigned colors to 1 carriers using 'tab10' palette.
INFO:pypsa.consistency:Network sanitization complete.
n.optimize(output_flag=False, include_objective_constant=False)
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - output_flag: False - log_to_console: False
INFO:linopy.io: Writing time: 0.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 32 primals, 57 duals Objective: 2.04e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 2.04e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper were not assigned to the network.
('ok', 'optimal')
The maintenance status is 1 for two snapshots (6 h / 3 h), even though maintenance_duration=6:
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Weighted Duration)")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Capacity Expansion¶
Maintenance scheduling combines with capacity expansion (p_nom_extendable=True). The unavailable capacity during maintenance is the bilinear product of the optimised capacity and the maintenance status, which PyPSA linearises exactly with a McCormick envelope (an internal auxiliary variable $z = \hat{p} \cdot m$). This requires a finite p_nom_max; an infinite one raises a consistency error.
The optimiser co-optimises how much capacity to build and when to maintain it.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom_extendable=True,
p_nom_max=150, # must be finite for the McCormick linearisation
capital_cost=50,
marginal_cost=10,
maintainable=True,
maintenance_duration=3,
)
n.add("Generator", "backup", bus="bus", p_nom=200, marginal_cost=100)
load = [100] * 10
load[0] = 20
load[1] = 20
load[2] = 20
n.add("Load", "load", bus="bus", p_set=load)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.05s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 51 primals, 103 duals Objective: 1.80e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 1.80e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-maint-window, Generator-maint-start-horizon, Generator-maintcap_upper, Generator-maintcap_upper_nommax, Generator-maintcap_lower_nommax, Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper were not assigned to the network.
('ok', 'optimal')
The cheap generator is built at 100 MW and its maintenance is placed in the low-demand window, where it is fully unavailable and the backup covers the residual load:
print(f"Optimised capacity: {n.generators.p_nom_opt['cheap']:.0f} MW")
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Capacity Expansion)")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Optimised capacity: 100 MW
Unit Commitment¶
Maintenance scheduling also combines with unit commitment (committable=True). The commitment status $u$ and the maintenance status $m$ are decoupled exactly (via the product $v = u \cdot m$), so a committed unit may be shut down while in maintenance rather than being forced to stay online.
Here the generator has a stand-by cost, so it economically prefers to switch off ($u=0$) during its full-outage maintenance window.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom=100,
marginal_cost=10,
committable=True,
p_min_pu=0.3,
stand_by_cost=20,
maintainable=True,
maintenance_duration=3,
)
n.add("Generator", "backup", bus="bus", p_nom=100, marginal_cost=100)
load = [80] * 10
load[0] = 10
load[1] = 10
load[2] = 10
n.add("Load", "load", bus="bus", p_set=load)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
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: 80 primals, 161 duals Objective: 8.74e+03 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 8.74e+03 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-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper, Generator-maint-status-le-status, Generator-maint-status-le-maint, Generator-maint-status-lb, Generator-com-p-lower, Generator-com-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')
The commitment status drops to 0 exactly during the maintenance block, i.e. the unit is shut down while under maintenance instead of idling online:
fig, axes = plt.subplots(2, 1, figsize=(8, 5), sharex=True)
n.generators_t.status["cheap"].plot.bar(ax=axes[0], color="C0")
axes[0].set_ylabel("Commitment status")
axes[0].set_title("Commitment Status")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[1], color="C3")
axes[1].set_ylabel("Maintenance")
axes[1].set_title("Maintenance Status")
axes[1].set_xlabel("Snapshot")
plt.tight_layout()
Note: When a minimum up time or start-up cost forces the unit to stay committed, start-up/shut-down dynamics interact with the maintenance events. See the caveats in the maintenance scheduling user guide.
Modular Capacity Expansion¶
Maintenance also combines with modular capacity expansion, where capacity is built in integer multiples of a fixed module size p_nom_mod and the commitment status is the integer number of committed modules. The maintenance reduction scales the committed capacity through the product $v = u^{\mathrm{mod}} \cdot m$ of module count and maintenance status.
Here a plant is expanded in 50 MW modules (up to p_nom_max=150) and given a stand-by cost, so the optimizer commits only as many modules as the load requires. With partial maintenance (maintenance_pu=0.5) each committed module delivers only 25 MW during the event, so the plant must keep more modules committed to serve the same load, while outside maintenance it follows demand with fewer modules.
n = pypsa.Network(snapshots=range(10))
n.add("Bus", "bus")
n.add(
"Generator",
"cheap",
bus="bus",
p_nom_extendable=True,
p_nom_mod=50, # capacity built in 50 MW modules
p_nom_max=150,
committable=True,
capital_cost=50,
marginal_cost=10,
stand_by_cost=50, # commit only as many modules as needed
maintainable=True,
maintenance_duration=3,
maintenance_pu=0.5,
)
n.add("Generator", "backup", bus="bus", p_nom=200, marginal_cost=100)
load = [60, 60, 150, 150, 150, 150, 150, 150, 100, 100]
n.add("Load", "load", bus="bus", p_set=load)
n.optimize()
/tmp/ipykernel_4063/1261279110.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() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus'], dtype='object', name='name')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io: Writing time: 0.09s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 82 primals, 164 duals Objective: 2.78e+04 Solver: highs Runtime: 0.01s MIP gap: 0.00e+00 Dual bound: 2.78e+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-maint-window, Generator-maint-start-horizon, Generator-fix-p-lower, Generator-fix-p-upper, Generator-maint-modstatus-le-status, Generator-maint-modstatus-le-maint, Generator-maint-modstatus-lb, 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')
Three 50 MW modules are built. The committed module count follows the load — only two modules are committed for the 100 MW period. During the partial-maintenance window all three modules stay committed, because each delivers just 25 MW while under maintenance:
print(
f"Optimised capacity: {n.generators.p_nom_opt['cheap']:.0f} MW "
f"({n.generators.p_nom_opt['cheap'] / 50:.0f} modules of 50 MW)"
)
fig, axes = plt.subplots(3, 1, figsize=(8, 7), sharex=True)
n.generators_t.p.plot.bar(stacked=True, ax=axes[0], legend=True)
axes[0].set_ylabel("Dispatch [MW]")
axes[0].set_title("Generator Dispatch (Modular Capacity Expansion)")
n.generators_t.status["cheap"].plot.bar(ax=axes[1], color="C0")
axes[1].set_ylabel("Committed modules")
axes[1].set_title("Committed Modules")
n.generators_t.maintenance["cheap"].plot.bar(ax=axes[2], color="C3")
axes[2].set_ylabel("Maintenance")
axes[2].set_title("Maintenance Status")
axes[2].set_xlabel("Snapshot")
plt.tight_layout()
Optimised capacity: 150 MW (3 modules of 50 MW)
Links and Processes¶
The same attributes (maintainable, maintenance_duration, maintenance_pu, maintenance_events) apply to Link and Process components, where the identical formulation constrains their power flow $p$. The Process component follows the same formulation as the Link. See the user guide for the full mathematical description.