Pathway Planning¶
In this example, we demonstrate how PyPSA can deal with optimisation problems spanning multiple investment periods, also known as pathway planning.
For models with multiple investment periods, the total set of snapshots is divided into investment periods, which translates into multi-indexed snapshots with investment periods as the first level time steps as the second level. In each investment period, new components may be added to the system. Additionally, any component may only operate as long as allowed by their lifetime.
In contrast to the models with a single investment period (overnight scenarios), the following concepts have to be taken into account.:
- The network attribute
n.investment_periods: This is the set of periods which specify when new components may be built. These have to be the same as the first level values in then.snapshotsindex. - The network attribute
n.investment_period_weightings: These specify the weighting of each period in the objective function and the global constraints. - The component attribute
build_year: Any one component may only be built when the build year is equal to the current investment period, or larger than the previous investment period. That means, components withbuild_year=2029are considered in the investment period2030, but not in the period2025. - The component attribute
lifetime: Any one component is only considered for dispatch in an investment period if it is still active at the beginning of an investment period. That means, components withbuild_year=2029andlifetime=30are considered in the investment period2055, but not in the period2060.
In the following, we set up a three bus network with generators, lines and storage units and run a optimisation for the investment periods 2020, 2030, 2040 and 2050.
import numpy as np
import pandas as pd
import pypsa
rng = np.random.default_rng() # Create a random number generator
We set up the network with investment periods and snapshots.
n = pypsa.Network()
years = [2020, 2030, 2040, 2050]
freq = 24
snapshots = pd.DatetimeIndex([])
for year in years:
period = pd.date_range(
start=f"{year}-01-01 00:00",
freq=f"{freq}h",
periods=8760 // freq,
)
snapshots = snapshots.append(period)
# convert to multiindex and assign to network
n.snapshots = pd.MultiIndex.from_arrays([snapshots.year, snapshots])
n.investment_periods = years
n.snapshot_weightings
/home/runner/work/PyPSA/PyPSA/pypsa/network/io.py:2082: FutureWarning: pandas infers the `str` dtype for string data since its version 3.0. PyPSA still converts it back to numpy object dtype on import, but will keep it from PyPSA 2.0 on. Set `pypsa.options.api.legacy_string_dtype` explicitly to suppress this warning. new_static = _coerce_string_dtypes(new_static)
| objective | stores | generators | ||
|---|---|---|---|---|
| period | timestep | |||
| 2020 | 2020-01-01 | 1.0 | 1.0 | 1.0 |
| 2020-01-02 | 1.0 | 1.0 | 1.0 | |
| 2020-01-03 | 1.0 | 1.0 | 1.0 | |
| 2020-01-04 | 1.0 | 1.0 | 1.0 | |
| 2020-01-05 | 1.0 | 1.0 | 1.0 | |
| ... | ... | ... | ... | ... |
| 2050 | 2050-12-27 | 1.0 | 1.0 | 1.0 |
| 2050-12-28 | 1.0 | 1.0 | 1.0 | |
| 2050-12-29 | 1.0 | 1.0 | 1.0 | |
| 2050-12-30 | 1.0 | 1.0 | 1.0 | |
| 2050-12-31 | 1.0 | 1.0 | 1.0 |
1460 rows Ć 3 columns
n.investment_periods
Index([2020, 2030, 2040, 2050], dtype='int32', name='period')
Set the years and objective weighting per investment period. For the objective weighting, we consider a discount rate defined by $$ D(t) = \dfrac{1}{(1+r)^t} $$
where $r$ is the discount rate. For each period we sum up all discounts rates of the corresponding years which gives us the effective objective weighting.
n.investment_period_weightings["years"] = list(np.diff(years)) + [10]
r = 0.01
T = 0
for period, nyears in n.investment_period_weightings.years.items():
discounts = [(1 / (1 + r) ** t) for t in range(T, T + nyears)]
n.investment_period_weightings.at[period, "objective"] = sum(discounts)
T += nyears
n.investment_period_weightings
| objective | years | |
|---|---|---|
| period | ||
| 2020 | 9.566018 | 10 |
| 2030 | 8.659991 | 10 |
| 2040 | 7.839777 | 10 |
| 2050 | 7.097248 | 10 |
Add the components
for i in range(3):
n.add("Bus", f"bus {i}")
# add three lines in a ring
n.add(
"Line",
"line 0->1",
bus0="bus 0",
bus1="bus 1",
)
n.add(
"Line",
"line 1->2",
bus0="bus 1",
bus1="bus 2",
capital_cost=10,
build_year=2030,
)
n.add(
"Line",
"line 2->0",
bus0="bus 2",
bus1="bus 0",
)
n.lines["x"] = 0.0001
n.lines["s_nom_extendable"] = True
n.lines
| bus0 | bus1 | type | x | r | g | b | s_nom | s_nom_mod | s_nom_extendable | ... | v_ang_max | sub_network | x_pu | r_pu | g_pu | b_pu | x_pu_eff | r_pu_eff | s_nom_opt | capital_cost_piecewise_opt | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||||||||||
| line 0->1 | bus 0 | bus 1 | 0.0001 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | True | ... | inf | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| line 1->2 | bus 1 | bus 2 | 0.0001 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | True | ... | inf | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ||
| line 2->0 | bus 2 | bus 0 | 0.0001 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | True | ... | inf | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
3 rows Ć 36 columns
# add some generators
p_nom_max = pd.Series(
(rng.uniform() for sn in range(len(n.snapshots))),
index=n.snapshots,
name="generator ext 2020",
)
# renewable (can operate 2020, 2030)
n.add(
"Generator",
"generator ext 0 2020",
bus="bus 0",
p_nom=50,
build_year=2020,
lifetime=20,
marginal_cost=2,
capital_cost=1,
p_max_pu=p_nom_max,
carrier="solar",
p_nom_extendable=True,
)
# can operate 2040, 2050
n.add(
"Generator",
"generator ext 0 2040",
bus="bus 0",
p_nom=50,
build_year=2040,
lifetime=11,
marginal_cost=25,
capital_cost=10,
carrier="OCGT",
p_nom_extendable=True,
)
# can operate in 2040
n.add(
"Generator",
"generator fix 1 2040",
bus="bus 1",
p_nom=50,
build_year=2040,
lifetime=10,
carrier="CCGT",
marginal_cost=20,
capital_cost=1,
)
n.generators
| bus | control | type | p_nom | p_nom_mod | p_nom_extendable | p_nom_min | p_nom_max | p_nom_set | p_min_pu | ... | min_down_time | up_time_before | down_time_before | ramp_limit_up | ramp_limit_down | ramp_limit_start_up | ramp_limit_shut_down | weight | p_nom_opt | capital_cost_piecewise_opt | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||||||||||
| generator ext 0 2020 | bus 0 | PQ | 50.0 | 0.0 | True | 0.0 | inf | NaN | 0.0 | ... | 0 | 1 | 0 | NaN | NaN | NaN | NaN | 1.0 | 0.0 | 0.0 | |
| generator ext 0 2040 | bus 0 | PQ | 50.0 | 0.0 | True | 0.0 | inf | NaN | 0.0 | ... | 0 | 1 | 0 | NaN | NaN | NaN | NaN | 1.0 | 0.0 | 0.0 | |
| generator fix 1 2040 | bus 1 | PQ | 50.0 | 0.0 | False | 0.0 | inf | NaN | 0.0 | ... | 0 | 1 | 0 | NaN | NaN | NaN | NaN | 1.0 | 0.0 | 0.0 |
3 rows Ć 47 columns
n.add(
"StorageUnit",
"storageunit non-cyclic 2030",
bus="bus 2",
p_nom=0,
capital_cost=2,
build_year=2030,
lifetime=21,
cyclic_state_of_charge=False,
p_nom_extendable=False,
)
n.add(
"StorageUnit",
"storageunit periodic 2020",
bus="bus 2",
p_nom=0,
capital_cost=1,
build_year=2020,
lifetime=21,
cyclic_state_of_charge=True,
cyclic_state_of_charge_per_period=True,
p_nom_extendable=True,
)
n.storage_units
| bus | control | type | p_nom | p_nom_mod | p_nom_extendable | p_nom_min | p_nom_max | p_nom_set | p_min_pu | ... | state_of_charge_set | cyclic_state_of_charge | cyclic_state_of_charge_per_period | max_hours | efficiency_store | efficiency_dispatch | standing_loss | inflow | p_nom_opt | capital_cost_piecewise_opt | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||||||||||
| storageunit non-cyclic 2030 | bus 2 | PQ | 0.0 | 0.0 | False | 0.0 | inf | NaN | -1.0 | ... | NaN | False | False | 1.0 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | |
| storageunit periodic 2020 | bus 2 | PQ | 0.0 | 0.0 | True | 0.0 | inf | NaN | -1.0 | ... | NaN | True | True | 1.0 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 |
2 rows Ć 40 columns
Add the load
load_var = pd.Series(
100 * rng.random(size=len(n.snapshots)), index=n.snapshots, name="load"
)
n.add("Load", "load 2", bus="bus 2", p_set=load_var)
load_fix = pd.Series(75, index=n.snapshots, name="load")
n.add("Load", "load 1", bus="bus 1", p_set=load_fix)
n.loads_t.p_set.head()
| name | load 2 | load 1 | |
|---|---|---|---|
| period | timestep | ||
| 2020 | 2020-01-01 | 68.062275 | 75.0 |
| 2020-01-02 | 29.511343 | 75.0 | |
| 2020-01-03 | 72.051173 | 75.0 | |
| 2020-01-04 | 36.768309 | 75.0 | |
| 2020-01-05 | 38.475968 | 75.0 |
Run the optimization
n.optimize(multi_investment_periods=True)
/tmp/ipykernel_3649/264390566.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(multi_investment_periods=True) WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['bus 0', 'bus 1', 'bus 2'], dtype='object', name='name')
WARNING:pypsa.consistency:The following generators have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['generator ext 0 2020', 'generator ext 0 2040', 'generator fix 1 2040'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['line 0->1', 'line 1->2', 'line 2->0'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow: Index(['line 0->1', 'line 1->2', 'line 2->0'], dtype='object', name='name')
WARNING:pypsa.optimization.constraints:StorageUnits ['storageunit periodic 2020']: Per-period cyclic (cyclic_state_of_charge_per_period=True) overrides global cyclic (cyclic_state_of_charge=True). Storage will cycle within each investment period, not across the entire horizon.
INFO:linopy.model: Solve problem using Gurobi solver
INFO:linopy.model:Solver options: - log_to_console: False
INFO:linopy.io:Writing objective.
Writing constraints.: 0%| | 0/24 [00:00<?, ?it/s]
Writing constraints.: 92%|āāāāāāāāāā| 22/24 [00:00<00:00, 208.61it/s]
Writing constraints.: 100%|āāāāāāāāāā| 24/24 [00:00<00:00, 203.36it/s]
Writing continuous variables.: 0%| | 0/8 [00:00<?, ?it/s]
Writing continuous variables.: 100%|āāāāāāāāāā| 8/8 [00:00<00:00, 349.96it/s]
INFO:linopy.io: Writing time: 0.16s
Set parameter WLSAccessID
Set parameter WLSSecret
Set parameter LicenseID to value 2537914
Academic license 2537914 - for non-commercial use only - registered to l.___@tu-berlin.de
Read LP format model from file /tmp/linopy-problem-xmaun27h.lp
Reading time = 0.04 seconds
obj: 32491 rows, 12416 columns, 63880 nonzeros
Set parameter LogToConsole to value 0
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 17526 primals, 43441 duals Objective: 1.81e+07 Solver: gurobi Runtime: 0.09s Dual bound: 1.81e+07 Solver model: available Solver message: 2
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, Line-ext-s-lower, Line-ext-s-upper, StorageUnit-fix-p_dispatch-lower, StorageUnit-fix-p_dispatch-upper, StorageUnit-ext-p_dispatch-lower, StorageUnit-ext-p_dispatch-upper, StorageUnit-fix-p_store-lower, StorageUnit-fix-p_store-upper, StorageUnit-ext-p_store-lower, StorageUnit-ext-p_store-upper, StorageUnit-fix-state_of_charge-lower, StorageUnit-fix-state_of_charge-upper, StorageUnit-ext-state_of_charge-lower, StorageUnit-ext-state_of_charge-upper, Kirchhoff-Voltage-Law, StorageUnit-energy_balance were not assigned to the network.
('ok', 'optimal')
c = "Generator"
df = pd.concat(
{
period: n.components[c].get_active_assets(period)
* n.components[c].static.p_nom_opt
for period in n.investment_periods
},
axis=1,
)
df.T.plot.bar(
stacked=True,
edgecolor="white",
width=1,
ylabel="Capacity (MW)",
xlabel="Investment Period",
rot=0,
)
<Axes: xlabel='Investment Period', ylabel='Capacity (MW)'>
df = n.generators_t.p.sum(axis=0).T.div(1e3)
df.T.plot.bar(
stacked=True,
edgecolor="white",
width=1,
ylabel="Generation (GWh)",
xlabel="Investment Period",
rot=0,
)
<Axes: xlabel='Investment Period', ylabel='Generation (GWh)'>