1-Node Capacity Expansion¶
In this example, we build a replica of model.energy. This tool calculates the cost of meeting a constant electricity demand from a combination of wind power, solar power and storage for different regions of the world. It includes capacity investments and dispatch optimisation. We deviate from model.energy by including an electricity demand profiles rather than a constant electricity demand.
import pandas as pd
import pypsa
Techno-economic assumptions¶
We take techno-economic assumptions from the technology-data repository which collects assumptions on costs and efficiencies:
YEAR = 2030
url = f"https://raw.githubusercontent.com/PyPSA/technology-data/master/outputs/costs_{YEAR}.csv"
costs = pd.read_csv(url, index_col=[0, 1])
costs.loc[costs.unit.str.contains("/kW"), "value"] *= 1e3
costs = costs.value.unstack().fillna({"discount rate": 0.07, "lifetime": 20, "FOM": 0})
Based on this, we calculate the marginal costs (ā¬/MWh):
costs["marginal_cost"] = costs["VOM"] + costs["fuel"] / costs["efficiency"]
We also calculate the capital costs (i.e. annualised investment costs, ā¬/MW/a or ā¬/MWh/a for storage), using a small utility function to calculate the annuity factor to annualise investment costs based on is the discount rate $r$ and lifetime $n$.
from pypsa.costs import annuity
a = costs.apply(lambda x: annuity(x["discount rate"], x["lifetime"]), axis=1)
costs["capital_cost"] = (a + costs["FOM"] / 100) * costs["investment"]
Wind, solar and load time series¶
RESOLUTION = 3 # hours
url = "https://tubcloud.tu-berlin.de/s/9toBssWEdaLgHzq/download/time-series.csv"
ts = pd.read_csv(url, index_col=0, parse_dates=True)[::RESOLUTION]
ts.head(3)
| load_mw | pv_pu | wind_pu | |
|---|---|---|---|
| timestamp | |||
| 2019-01-01 00:00:00 | 5719.26 | 0.0 | 0.1846 |
| 2019-01-01 03:00:00 | 5474.74 | 0.0 | 0.3146 |
| 2019-01-01 06:00:00 | 5413.39 | 0.0 | 0.4957 |
Model initialisation¶
n = pypsa.Network()
n.add("Bus", "electricity", carrier="electricity")
n.set_snapshots(ts.index)
/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)
The weighting of the snapshots (e.g. how many hours they represent, see $w_t$ in problem formulation above) must be set in n.snapshot_weightings.
n.snapshot_weightings.loc[:, :] = RESOLUTION
Adding carriers (this is only necessary for convenience in plotting later):
carriers = [
"wind",
"solar",
"hydrogen storage",
"battery storage",
"load shedding",
"electrolysis",
"turbine",
"electricity",
"hydrogen",
]
colors = [
"dodgerblue",
"gold",
"black",
"yellowgreen",
"darkorange",
"magenta",
"red",
"grey",
"grey",
]
n.add("Carrier", carriers, color=colors)
Adding load:
n.add(
"Load",
"demand",
bus="electricity",
p_set=ts.load_mw,
)
Add a load shedding generator with high marginal cost of 2000 ā¬/MWh:
n.add(
"Generator",
"load shedding",
bus="electricity",
carrier="load shedding",
marginal_cost=2000,
p_nom=ts.load_mw.max(),
)
Adding the variable renewable generators works almost identically, but we also need to supply the capacity factors to the model via the attribute p_max_pu.
n.add(
"Generator",
"wind",
bus="electricity",
carrier="wind",
p_max_pu=ts.wind_pu,
capital_cost=costs.at["onwind", "capital_cost"],
marginal_cost=costs.at["onwind", "marginal_cost"],
p_nom_extendable=True,
)
n.add(
"Generator",
"solar",
bus="electricity",
carrier="solar",
p_max_pu=ts.pv_pu,
capital_cost=costs.at["solar", "capital_cost"],
marginal_cost=costs.at["solar", "marginal_cost"],
p_nom_extendable=True,
)
Adding a 3-hour battery:
n.add(
"StorageUnit",
"battery storage",
bus="electricity",
carrier="battery storage",
max_hours=3,
capital_cost=costs.at["battery inverter", "capital_cost"]
+ 3 * costs.at["battery storage", "capital_cost"],
efficiency_store=costs.at["battery inverter", "efficiency"],
efficiency_dispatch=costs.at["battery inverter", "efficiency"],
p_nom_extendable=True,
cyclic_state_of_charge=True,
)
Adding a hydrogen storage system consisting of electrolyser, hydrogen turbine and underground storage, which can all be flexibly optimised:
n.add("Bus", "hydrogen", carrier="hydrogen")
n.add(
"Link",
"electrolysis",
bus0="electricity",
bus1="hydrogen",
carrier="electrolysis",
p_nom_extendable=True,
efficiency=costs.at["electrolysis", "efficiency"],
capital_cost=costs.at["electrolysis", "capital_cost"],
)
n.add(
"Link",
"turbine",
bus0="hydrogen",
bus1="electricity",
carrier="turbine",
p_nom_extendable=True,
efficiency=costs.at["OCGT", "efficiency"],
capital_cost=costs.at["OCGT", "capital_cost"] / costs.at["OCGT", "efficiency"],
)
n.add(
"Store",
"hydrogen storage",
bus="hydrogen",
carrier="hydrogen storage",
capital_cost=costs.at["hydrogen storage underground", "capital_cost"],
e_nom_extendable=True,
e_cyclic=True,
)
Model run¶
n.optimize()
/tmp/ipykernel_2648/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()
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/21 [00:00<?, ?it/s]
Writing constraints.: 100%|āāāāāāāāāā| 21/21 [00:00<00:00, 209.83it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|āāāāāāāāāā| 11/11 [00:00<00:00, 428.75it/s]
INFO:linopy.io: Writing time: 0.14s
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-afx4rnoe.lp
Reading time = 0.07 seconds
obj: 64246 rows, 29206 columns, 124144 nonzeros
Set parameter LogToConsole to value 0
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 29206 primals, 64246 duals Objective: 1.01e+10 Solver: gurobi Runtime: 1.18s Dual bound: 1.01e+10 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, 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.
('ok', 'optimal')
Model evaluation¶
Total system cost by technology:
tsc = (
pd.concat([n.statistics.capex(), n.statistics.opex()], axis=1).sum(axis=1).div(1e9)
)
tsc
component carrier
Generator solar 1.684343
wind 4.180646
Link electrolysis 0.739954
turbine 1.424007
StorageUnit battery storage 1.124633
Store hydrogen storage 0.688179
Generator load shedding 0.290825
dtype: float64
tsc.sum()
np.float64(10.13258693337857)
The optimised capacities in GW (GWh for Store component):
n.statistics.optimal_capacity().div(1e3)
component carrier
Generator load shedding 10.901160
solar 26.089521
wind 32.583318
Link electrolysis 3.118498
turbine 9.692634
StorageUnit battery storage 14.047947
Store hydrogen storage 3672.190855
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.656979
StorageUnit battery storage electricity -0.791123
Generator load shedding electricity 0.145412
Link turbine electricity 3.990917
Generator solar electricity 30.569542
wind electricity 48.008319
dtype: float64
Energy balances plot as time series (in MW):
n.statistics.energy_balance.plot.area(linewidth=0, bus_carrier="electricity")
(<Figure size 759x300 with 1 Axes>, <Axes: xlabel='snapshot', ylabel='Energy Balance []'>, <seaborn.axisgrid.FacetGrid at 0x7f2b56d02f90>)
Price time series for electricity and hydrogen:
n.buses_t.marginal_price.plot(figsize=(7, 2))
<Axes: xlabel='snapshot'>