Note
You can download this example as a Jupyter notebook or start it in interactive mode.
Multi Investment Optimization
In the following, we show how PyPSA can deal with multi-investment optimization, also known as multi-horizon optimization.
Here, the total set of snapshots is divided into investment periods. For the model, this translates into multi-indexed snapshots with the first level being the investment period and the second level the according time steps. In each investment period new asset may be added to the system. On the other hand assets may only operate as long as allowed by their lifetime.
In contrast to the ordinary optimisation, the following concepts have to be taken into account.
investment_periods-pypsa.Networkattribute. This is the set of periods which specify when new assets may be built. In the current implementation, these have to be the same as the first level values in thesnapshotsattribute.investment_period_weightings-pypsa.Networkattribute. These specify the weighting of each period in the objective function.build_year- general component attribute. A single asset may only be built when the build year is smaller or equal to the current investment period. For example, assets with a build year2029are considered in the investment period2030, but not in the period2025.lifetime- general component attribute. An asset is only considered in an investment period if present at the beginning of an investment period. For example, an asset with build year2029and lifetime30is considered in the investment period2055but not in the period2060.
In the following, we set up a three node network with generators, lines and storages and run a optimisation covering the time span from 2020 to 2050 and each decade is one investment period.
[1]:
import pypsa
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
We set up the network with investment periods and snapshots.
[2]:
n = pypsa.Network()
years = [2020, 2030, 2040, 2050]
freq = "24"
snapshots = pd.DatetimeIndex([])
for year in years:
period = pd.date_range(start ='{}-01-01 00:00'.format(year),
freq ='{}H'.format(freq),
periods=8760/float(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
[2]:
| objective | stores | generators | ||
|---|---|---|---|---|
| period | snapshot | |||
| 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
[3]:
n.investment_periods
[3]:
Int64Index([2020, 2030, 2040, 2050], dtype='int64')
Set the years and objective weighting per investment period. For the objective weighting, we consider a discount rate defined by
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.
[4]:
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
[4]:
| objective | years | |
|---|---|---|
| 2020 | 9.566018 | 10 |
| 2030 | 8.659991 | 10 |
| 2040 | 7.839777 | 10 |
| 2050 | 7.097248 | 10 |
Add the components
[5]:
for i in range(3):
n.add("Bus",
"bus {}".format(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
[6]:
n.lines
[6]:
| attribute | bus0 | bus1 | type | x | r | g | b | s_nom | s_nom_extendable | s_nom_min | ... | v_ang_min | v_ang_max | sub_network | x_pu | r_pu | g_pu | b_pu | x_pu_eff | r_pu_eff | s_nom_opt |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| line 0->1 | bus 0 | bus 1 | 0.0001 | 0.0 | 0.0 | 0.0 | 0.0 | True | 0.0 | ... | -inf | inf | 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 | True | 0.0 | ... | -inf | inf | 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 | True | 0.0 | ... | -inf | inf | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
3 rows × 29 columns
[7]:
# add some generators
p_nom_max = pd.Series((np.random.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
[7]:
| attribute | bus | control | type | p_nom | p_nom_extendable | p_nom_min | p_nom_max | p_min_pu | p_max_pu | p_set | ... | shut_down_cost | min_up_time | min_down_time | up_time_before | down_time_before | ramp_limit_up | ramp_limit_down | ramp_limit_start_up | ramp_limit_shut_down | p_nom_opt |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| generator ext 0 2020 | bus 0 | PQ | 50.0 | True | 0.0 | inf | 0.0 | 1.0 | 0.0 | ... | 0.0 | 0 | 0 | 1 | 0 | NaN | NaN | 1.0 | 1.0 | 0.0 | |
| generator ext 0 2040 | bus 0 | PQ | 50.0 | True | 0.0 | inf | 0.0 | 1.0 | 0.0 | ... | 0.0 | 0 | 0 | 1 | 0 | NaN | NaN | 1.0 | 1.0 | 0.0 | |
| generator fix 1 2040 | bus 1 | PQ | 50.0 | False | 0.0 | inf | 0.0 | 1.0 | 0.0 | ... | 0.0 | 0 | 0 | 1 | 0 | NaN | NaN | 1.0 | 1.0 | 0.0 |
3 rows × 30 columns
[8]:
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
[8]:
| attribute | bus | control | type | p_nom | p_nom_extendable | p_nom_min | p_nom_max | p_min_pu | p_max_pu | p_set | ... | state_of_charge_initial_per_period | 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| storageunit non-cyclic 2030 | bus 2 | PQ | 0.0 | False | 0.0 | inf | -1.0 | 1.0 | 0.0 | ... | False | NaN | False | True | 1.0 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 | |
| storageunit periodic 2020 | bus 2 | PQ | 0.0 | True | 0.0 | inf | -1.0 | 1.0 | 0.0 | ... | False | NaN | True | True | 1.0 | 1.0 | 1.0 | 0.0 | 0.0 | 0.0 |
2 rows × 28 columns
Add the load
[9]:
load_var = pd.Series(100*np.random.rand(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)
Run the optimization
[10]:
n.loads_t.p_set
[10]:
| load 2 | load 1 | ||
|---|---|---|---|
| period | snapshot | ||
| 2020 | 2020-01-01 | 58.357542 | 75.0 |
| 2020-01-02 | 49.213201 | 75.0 | |
| 2020-01-03 | 53.295739 | 75.0 | |
| 2020-01-04 | 13.042264 | 75.0 | |
| 2020-01-05 | 83.846414 | 75.0 | |
| ... | ... | ... | ... |
| 2050 | 2050-12-27 | 31.007075 | 75.0 |
| 2050-12-28 | 46.915859 | 75.0 | |
| 2050-12-29 | 23.910070 | 75.0 | |
| 2050-12-30 | 86.262195 | 75.0 | |
| 2050-12-31 | 19.285209 | 75.0 |
1460 rows × 2 columns
[11]:
n.lopf(pyomo=False, multi_investment_periods=True)
INFO:pypsa.linopf:Perform multi-investment optimization.
INFO:pypsa.linopf:Prepare linear problem
INFO:pypsa.linopf:Total preparation time: 1.24s
INFO:pypsa.linopf:Solve linear problem using Glpk solver
---------------------------------------------------------------------------
FileNotFoundError Traceback (most recent call last)
/tmp/ipykernel_597/3614973472.py in <module>
----> 1 n.lopf(pyomo=False, multi_investment_periods=True)
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pypsa/components.py in lopf(self, snapshots, pyomo, solver_name, solver_options, solver_logfile, formulation, keep_files, extra_functionality, multi_investment_periods, **kwargs)
648 return network_lopf(self, **args)
649 else:
--> 650 return network_lopf_lowmem(self, **args)
651
652
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pypsa/linopf.py in network_lopf(n, snapshots, solver_name, solver_logfile, extra_functionality, multi_investment_periods, skip_objective, skip_pre, extra_postprocessing, formulation, keep_references, keep_files, keep_shadowprices, solver_options, warmstart, store_basis, solver_dir)
1209 solve = eval(f'run_and_read_{solver_name}')
1210 res = solve(n, problem_fn, solution_fn, solver_logfile,
-> 1211 solver_options, warmstart, store_basis)
1212
1213 status, termination_condition, variables_sol, constraints_dual, obj = res
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pypsa/linopt.py in run_and_read_glpk(n, problem_fn, solution_fn, solver_logfile, solver_options, warmstart, store_basis)
682 command += solver_options
683
--> 684 result = subprocess.Popen(command.split(' '), stdout=subprocess.PIPE)
685 result.wait()
686
~/.pyenv/versions/3.7.9/lib/python3.7/subprocess.py in __init__(self, args, bufsize, executable, stdin, stdout, stderr, preexec_fn, close_fds, shell, cwd, env, universal_newlines, startupinfo, creationflags, restore_signals, start_new_session, pass_fds, encoding, errors, text)
798 c2pread, c2pwrite,
799 errread, errwrite,
--> 800 restore_signals, start_new_session)
801 except:
802 # Cleanup if the child failed starting.
~/.pyenv/versions/3.7.9/lib/python3.7/subprocess.py in _execute_child(self, args, executable, preexec_fn, close_fds, pass_fds, cwd, env, startupinfo, creationflags, shell, p2cread, p2cwrite, c2pread, c2pwrite, errread, errwrite, restore_signals, start_new_session)
1549 if errno_num == errno.ENOENT:
1550 err_msg += ': ' + repr(err_filename)
-> 1551 raise child_exception_type(errno_num, err_msg, err_filename)
1552 raise child_exception_type(err_msg)
1553
FileNotFoundError: [Errno 2] No such file or directory: 'glpsol': 'glpsol'
[12]:
c = 'Generator'
df = pd.concat({period: n.get_active_assets(c, period) * n.df(c).p_nom_opt
for period in n.investment_periods}, axis=1)
df.T.plot.bar(stacked=True, edgecolor='white', width=1,
ylabel='Capacity', xlabel='Investment Period',
rot=0, figsize=(10,5))
plt.tight_layout()
[13]:
df = n.generators_t.p.sum(level=0).T
df.T.plot.bar(stacked=True, edgecolor='white', width=1,
ylabel='Generation', xlabel='Investment Period',
rot=0, figsize=(10,5))
/home/docs/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/ipykernel_launcher.py:1: FutureWarning: Using the level keyword in DataFrame and Series aggregations is deprecated and will be removed in a future version. Use groupby instead. df.sum(level=1) should use df.groupby(level=1).sum().
"""Entry point for launching an IPython kernel.
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
/tmp/ipykernel_597/2508802718.py in <module>
2 df.T.plot.bar(stacked=True, edgecolor='white', width=1,
3 ylabel='Generation', xlabel='Investment Period',
----> 4 rot=0, figsize=(10,5))
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pandas/plotting/_core.py in bar(self, x, y, **kwargs)
1128 other axis represents a measured value.
1129 """
-> 1130 return self(kind="bar", x=x, y=y, **kwargs)
1131
1132 @Appender(
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pandas/plotting/_core.py in __call__(self, *args, **kwargs)
970 data.columns = label_name
971
--> 972 return plot_backend.plot(data, kind=kind, **kwargs)
973
974 __call__.__doc__ = __doc__
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pandas/plotting/_matplotlib/__init__.py in plot(data, kind, **kwargs)
69 kwargs["ax"] = getattr(ax, "left_ax", ax)
70 plot_obj = PLOT_CLASSES[kind](data, **kwargs)
---> 71 plot_obj.generate()
72 plot_obj.draw()
73 return plot_obj.result
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pandas/plotting/_matplotlib/core.py in generate(self)
284 def generate(self):
285 self._args_adjust()
--> 286 self._compute_plot_data()
287 self._setup_subplots()
288 self._make_plot()
~/checkouts/readthedocs.org/user_builds/pypsa-docs-staging/envs/latest/lib/python3.7/site-packages/pandas/plotting/_matplotlib/core.py in _compute_plot_data(self)
451 # no non-numeric frames or series allowed
452 if is_empty:
--> 453 raise TypeError("no numeric data to plot")
454
455 self.data = numeric_data.apply(self._convert_to_ndarray)
TypeError: no numeric data to plot
[ ]: