Modelling to Generate Alternatives¶
In this example, we apply MGA ('modelling to generate alternatives') to a single node capacity expansion model in the style of model.energy.
The MGA algorithm, which can be called with n.optimize.optimize_mga(), tries to minimize or maximize investment or dispatch in (groups of) technologies within a set cost budget.
For instance, it can be used to minimize the amount of wind capacity while keeping costs within 5% of the cost-optimal solution in terms of system costs.
See also model.energy and Neumann and Brown (2021) which uses PyPSA for MGA-type analysis.
import pandas as pd
import pypsa
Find cost-optimal solution¶
Running MGA requires knowledge of what the total system costs are in the optimum. So first, we need to solve for the cost-optimal solution.
n = pypsa.examples.model_energy()
n.optimize(solver_name="highs", log_to_console=False)
n.statistics.capex().sum() + n.statistics.opex().sum()
INFO:pypsa.network.io:Retrieving network data from https://github.com/PyPSA/PyPSA/raw/v1.0.5/examples/networks/model-energy/model-energy.nc.
INFO:pypsa.network.io:Imported network 'Model-Energy' has buses, carriers, generators, links, loads, storage_units, stores
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io:Writing objective.
Writing constraints.: 0%| | 0/21 [00:00<?, ?it/s]
Writing constraints.: 48%|████▊ | 10/21 [00:00<00:00, 94.14it/s]
Writing constraints.: 95%|█████████▌| 20/21 [00:00<00:00, 69.15it/s]
Writing constraints.: 100%|██████████| 21/21 [00:00<00:00, 70.56it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 11/11 [00:00<00:00, 202.89it/s]
INFO:linopy.io: Writing time: 0.37s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 29206 primals, 64246 duals Objective: 8.08e+09 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-energy_balance, Store-energy_balance were not assigned to the network.
np.float64(8078135675.451248)
Extract cost-optimal results¶
Optimal total system cost by technology:
tsc = (
pd.concat([n.statistics.capex(), n.statistics.opex()], axis=1).sum(axis=1).div(1e9)
)
optimal_cost = tsc.sum()
tsc
component carrier
Generator solar 1.341015
wind 3.300830
Link electrolysis 0.570894
turbine 1.172438
StorageUnit battery storage 0.941196
Store hydrogen storage 0.561618
Generator load shedding 0.190144
dtype: float64
The optimised capacities in GW (GWh for Store component):
n.statistics.optimal_capacity().div(1e3)
component carrier
Generator load shedding 10.901160
solar 26.116801
wind 32.474381
Link electrolysis 3.025153
turbine 10.073615
StorageUnit battery storage 14.854330
Store hydrogen storage 3786.558312
dtype: float64
Energy balances on electricity side (in TWh):
n.statistics.energy_balance(bus_carrier="electricity").sort_values().div(1e6)
component carrier bus_carrier
Load - electricity -66.266089
Link electrolysis electricity -15.295140
StorageUnit battery storage electricity -0.750055
Generator load shedding electricity 0.095072
Link turbine electricity 3.898685
Generator solar electricity 25.947720
wind electricity 52.369807
dtype: float64
Energy balances plot as time series (in MW):
n.statistics.energy_balance.plot.area(linewidth=0, bus_carrier="electricity")
(<Figure size 758.25x300 with 1 Axes>, <Axes: xlabel='snapshot', ylabel='Energy Balance []'>, <seaborn.axisgrid.FacetGrid at 0x79ba2775bcb0>)
Find lowest wind capacity within 5% cost slack¶
The function n.optimize.optimize_mga takes three main arguments:
- The
slackfor the allowed relative cost deviation from the cost-optimum (0.05 corresponds to 5%). - A dictionary of weights for defining the new objective function. The first level defines the component (e.g. "Generator"), the second level defines the optimisation variable (e.g.
p_nomfor investment), and the third level defines the component name (e.g. fromn.generators.index). - The
sense, noting whether to minimizes ("min") or maximize ("max") the new objective.
weights = {"Generator": {"p_nom": {"wind": 1}}}
n.optimize.optimize_mga(
slack=0.05, weights=weights, sense="min", solver_name="highs", 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 objective.
Writing constraints.: 0%| | 0/22 [00:00<?, ?it/s]
Writing constraints.: 45%|████▌ | 10/22 [00:00<00:00, 96.23it/s]
Writing constraints.: 91%|█████████ | 20/22 [00:00<00:00, 71.34it/s]
Writing constraints.: 100%|██████████| 22/22 [00:00<00:00, 74.57it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 11/11 [00:00<00:00, 202.63it/s]
INFO:linopy.io: Writing time: 0.36s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 29206 primals, 64247 duals Objective: 2.13e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Generator-ext-p-lower, Generator-ext-p-upper, Link-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, StorageUnit-energy_balance, Store-energy_balance, budget were not assigned to the network.
('ok', 'optimal')
The breakdown of total system costs shifts from wind towards more solar.
tsc = (
pd.concat([n.statistics.capex(), n.statistics.opex()], axis=1).sum(axis=1).div(1e9)
)
tsc
component carrier
Generator solar 2.133254
wind 2.168509
Link electrolysis 0.539322
turbine 1.191285
StorageUnit battery storage 1.402346
Store hydrogen storage 0.903488
Generator load shedding 0.143837
dtype: float64
Up to numeric differences, it is 5% more expensive overall:
optimal_cost * 1.05
np.float64(8.482042459223813)
tsc.sum()
np.float64(8.48204245921775)
Overall, the wind capacity is cut by roughly a third:
n.statistics.optimal_capacity().div(1e3)
component carrier
Generator load shedding 10.901160
solar 41.545979
wind 21.334328
Link electrolysis 2.857854
turbine 10.235553
StorageUnit battery storage 22.132377
Store hydrogen storage 6091.522243
dtype: float64
This is also evident in the energy balance:
n.statistics.energy_balance(bus_carrier="electricity").sort_values().div(1e6)
component carrier bus_carrier
Load - electricity -66.266089
Link electrolysis electricity -14.552445
StorageUnit battery storage electricity -1.335461
Generator load shedding electricity 0.071919
Link turbine electricity 3.709375
Generator wind electricity 37.565242
solar electricity 40.807460
dtype: float64
And also recognizable in the energy balance time series plots:
n.statistics.energy_balance.plot.area(linewidth=0, bus_carrier="electricity")
(<Figure size 758.25x300 with 1 Axes>, <Axes: xlabel='snapshot', ylabel='Energy Balance []'>, <seaborn.axisgrid.FacetGrid at 0x79ba219fe490>)