Single Node Sector Coupling¶
import matplotlib.pyplot as plt
import pandas as pd
import pypsa
plt.style.use("bmh")
Previous Capacity Expansion Model¶
To explore sector-coupling options with PyPSA, let's load the capacity expansion model we built for the electricity system and add sector-coupling technologies and demands on top. This example has single node for Germany and 4-hourly temporal resolution for a year. It has wind and solar solar generation, an OCGT generator as well as battery and hydrogen storage to supply a fixed electricity demand.
Some sector-coupling technologies have multiple ouputs (e.g. CHP plants producing heat and power). PyPSA can automatically handle links have more than one input (bus0)
and/or output (i.e. bus1, bus2, bus3) with a given efficieny (efficiency, efficiency2, efficiency3).
Alternatively, such multi-port components can also be modelled with the Process component, which uses rate0, rate1, rate2, ... instead of efficiency, efficiency2, ... to determine the energy flow at each port. The sign convention is: negative rates consume, positive rates produce. Setting a rate to 1 or -1 defines the reference bus. See the Process documentation for details.
n = pypsa.Network(
"https://tubcloud.tu-berlin.de/s/pzytNg9gtkgPpXc/download/network-cem.nc"
)
n
INFO:pypsa.network.io:Retrieving network data from https://tubcloud.tu-berlin.de/s/pzytNg9gtkgPpXc/download/network-cem.nc.
WARNING:pypsa.network.io:Importing network from PyPSA version v0.27.1 while current version is v1.2.0. Read the release notes at `https://go.pypsa.org/release-notes` to prepare your network for import.
INFO:pypsa.network.io:Imported network '' has buses, carriers, generators, global_constraints, loads, storage_units
PyPSA Network '' ---------------- Components: - Bus: 1 - Carrier: 6 - Generator: 4 - GlobalConstraint: 1 - Load: 1 - StorageUnit: 2 Snapshots: 2190
Hydrogen Production¶
The following example shows how to model the components of hydrogen storage separately, i.e. electrolysis, fuel cell and storage.
First, let's remove the simplified hydrogen storage representation:
n.remove("StorageUnit", "hydrogen storage underground")
Add a separate Bus for the hydrogen energy carrier:
n.add("Bus", "hydrogen")
Add a Link for the hydrogen electrolysis (Process equivalent: rate0=-1, rate1=0.7):
n.add(
"Link",
"electrolysis",
bus0="electricity",
bus1="hydrogen",
carrier="electrolysis",
p_nom_extendable=True,
efficiency=0.7,
capital_cost=50e3, # €/MW/a
)
Add a Link for the fuel cell which reconverts hydrogen to electricity (Process equivalent: rate0=-1, rate1=0.5):
n.add(
"Link",
"fuel cell",
bus0="hydrogen",
bus1="electricity",
carrier="fuel cell",
p_nom_extendable=True,
efficiency=0.5,
capital_cost=120e3, # €/MW/a
)
Add a Store for the hydrogen storage:
n.add(
"Store",
"hydrogen storage",
bus="hydrogen",
carrier="hydrogen storage",
capital_cost=140, # €/MWh/a
e_nom_extendable=True,
e_cyclic=True, # cyclic state of charge
)
We can also add a hydrogen demand to the hydrogen bus.
In the example below, we add a constant hydrogen demand the size of the electricity demand.
p_set = n.loads_t.p_set["demand"].mean()
n.add("Load", "hydrogen demand", bus="hydrogen", carrier="hydrogen", p_set=p_set) # MW
p_set
np.float64(54671.88812785388)
Heat Demand¶
For the heat demand, we create another bus and connect a load with the heat demand time series to it:
n.add("Bus", "heat")
url = "https://tubcloud.tu-berlin.de/s/mSkHERH8fJCKNXx/download/heat-load-example.csv"
p_set = pd.read_csv(url, index_col=0, parse_dates=True).squeeze()
n.add("Load", "heat demand", carrier="heat", bus="heat", p_set=p_set)
n.loads_t.p_set.div(1e3).plot(figsize=(12, 4), ylabel="GW")
<Axes: xlabel='snapshot', ylabel='GW'>
Heat pumps¶
To model heat pumps, first we have to calculate the coefficient of performance (COP) profile based on the temperature profile of the heat source.
In the example below, we calculate the COP for an air-sourced heat pump with a sink temperature of 55° C and a population-weighted ambient temperature profile for Germany.
The heat pump performance is given by the following function:
$$ COP(\Delta T) = 6.81 - 0.121 \Delta T + 0.00063^\Delta T^2 $$ where $\Delta T = T_{sink} - T_{source}$.
def cop(t_source, t_sink=55):
delta_t = t_sink - t_source
return 6.81 - 0.121 * delta_t + 0.000630 * delta_t**2
url = "https://tubcloud.tu-berlin.de/s/S4jRAQMP5Te96jW/download/ninja_weather_country_DE_merra-2_population_weighted.csv"
temp = pd.read_csv(url, skiprows=2, index_col=0, parse_dates=True).loc[
"2015", "temperature"
][::4]
cop(temp).plot(figsize=(10, 2), ylabel="COP");
plt.scatter(temp, cop(temp))
plt.xlabel("temperature [°C]")
plt.ylabel("COP [-]")
Text(0, 0.5, 'COP [-]')
Once we have calculated the heat pump coefficient of performance, we can add the heat pump to the network as a Link. We use the parameter efficiency to incorporate the COP (Process equivalent: rate0=-1, rate1=cop(temp)).
n.add(
"Link",
"heat pump",
carrier="heat pump",
bus0="electricity",
bus1="heat",
efficiency=cop(temp),
p_nom_extendable=True,
capital_cost=3e5, # €/MWe/a
)
Let's also add a resistive heater as backup technology (Process equivalent: rate0=-1, rate1=0.9):
n.add(
"Link",
"resistive heater",
carrier="resistive heater",
bus0="electricity",
bus1="heat",
efficiency=0.9,
capital_cost=1e4, # €/MWe/a
p_nom_extendable=True,
)
Combined Heat-and-Power (CHP)¶
In the following, we are going to add gas-fired combined heat-and-power plants (CHPs). Today, these would use fossil gas, but in the example below we assume green methane with relatively high marginal costs. Since we have no other net emission technology, we can remove the CO$_2$ limit.
n.remove("GlobalConstraint", "CO2Limit")
Then, we explicitly represent the energy carrier gas:
n.add("Bus", "gas", carrier="gas")
And add a Store of gas, which can be depleted (up to 100 TWh) with fuel costs of 150 €/MWh.
n.add(
"Store",
"gas storage",
carrier="gas storage",
e_initial=100e6, # MWh
e_nom=100e6, # MWh
bus="gas",
marginal_cost=150, # €/MWh_th
)
When we do this, we have to model the OCGT power plant as link which converts gas to electricity, not as generator (Process equivalent: rate0=-1, rate1=0.4).
n.remove("Generator", "OCGT")
n.add(
"Link",
"OCGT",
bus0="gas",
bus1="electricity",
carrier="OCGT",
p_nom_extendable=True,
capital_cost=20000, # €/MW/a
efficiency=0.4,
)
Next, we are going to add a combined heat-and-power (CHP) plant with fixed heat-power ratio (i.e. backpressure operation).
n.add(
"Link",
"CHP",
bus0="gas",
bus1="electricity",
bus2="heat",
carrier="CHP",
p_nom_extendable=True,
capital_cost=40000,
efficiency=0.4,
efficiency2=0.4,
)
Electric Vehicles¶
To model electric vehicles, we first create another bus for the electric vehicles.
n.add("Bus", "EV", carrier="EV")
Then, we can attach the electricity consumption of electric vehicles to this bus:
url = "https://tubcloud.tu-berlin.de/s/9r5bMSbzzQiqG7H/download/electric-vehicle-profile-example.csv"
p_set = pd.read_csv(url, index_col=0, parse_dates=True).squeeze()
n.add("Load", "EVa demand", bus="EV", carrier="EV demand", p_set=p_set)
p_set.loc["2015-01-01"].div(1e3).plot(figsize=(4, 4), ylabel="GW")
<Axes: xlabel='snapshot', ylabel='GW'>
Let's have a quick look at how the heat, electricity, constant hydrogen and electric vehicle demands relate to each other:
n.loads_t.p_set.div(1e3).plot(figsize=(10, 3), ylabel="GW")
plt.axhline(
n.loads.loc["hydrogen demand", "p_set"] / 1e3, label="hydrogen demand", color="m"
)
plt.legend()
<matplotlib.legend.Legend at 0x73211c148910>
The electric vehicles can only be charged when they are plugged-in. Below we load an availability profile telling us what share of electric vehicles is plugged-in at home -- we only assume home charging in this example.
url = "https://tubcloud.tu-berlin.de/s/E3PBWPfYaWwCq7a/download/electric-vehicle-availability-example.csv"
availability_profile = pd.read_csv(url, index_col=0, parse_dates=True).squeeze()
availability_profile.loc["2015-01-01"].plot(ylim=(0, 1))
<Axes: xlabel='snapshot'>
Then, we can add a link for the electric vehicle charger using assumption about the number of EVs and their charging rates.
number_cars = 40e6 # number of EV cars
bev_charger_rate = 0.011 # 3-phase EV charger with 11 kW
p_nom = number_cars * bev_charger_rate
n.add(
"Link",
"EV charger",
bus0="electricity",
bus1="EV",
p_nom=p_nom,
carrier="EV charger",
p_max_pu=availability_profile,
efficiency=0.9,
)
We can also allow vehicle-to-grid operation (i.e. electric vehicles inject power into the grid):
n.add(
"Link",
"V2G",
bus0="EV",
bus1="electricity",
p_nom=p_nom,
carrier="V2G",
p_max_pu=availability_profile,
efficiency=0.9,
)
The demand-side management potential we model as a store. This is not unlike a battery storage, but we impose additional constraints on when the store needs to be charged to a certain level (e.g. 75% full every morning).
bev_energy = 0.05 # average battery size of EV in MWh
bev_dsm_participants = 0.5 # share of cars that do smart charging
e_nom = number_cars * bev_energy * bev_dsm_participants
url = "https://tubcloud.tu-berlin.de/s/K62yACBRTrxLTia/download/dsm-profile-example.csv"
dsm_profile = (
pd.read_csv(url, index_col=0, parse_dates=True).squeeze().shift(2, fill_value=0)
)
dsm_profile.loc["2015-01-01"].plot(figsize=(5, 2), ylim=(0, 1))
<Axes: xlabel='snapshot'>
n.add(
"Store",
"EV DSM",
bus="EV",
carrier="EV battery",
e_cyclic=True, # state of charge at beginning = state of charge at the end
e_nom=e_nom,
e_min_pu=dsm_profile.loc[n.snapshots],
)
Then, we can solve the fully sector-coupled model altogether including electricity, passenger transport, hydrogen and heating.
n.optimize()
/tmp/ipykernel_8111/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(['electricity', 'hydrogen', 'heat', 'gas', 'EV'], dtype='object', name='name')
WARNING:pypsa.consistency:The following loads have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['hydrogen demand', 'heat demand', 'EVa demand'], dtype='object', name='name')
WARNING:pypsa.consistency:The following links have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers:
Index(['electrolysis', 'fuel cell', 'heat pump', 'resistive heater', 'CHP',
'EV charger', 'V2G'],
dtype='object', name='name')
WARNING:pypsa.consistency:Encountered nan's in static data for columns ['efficiency2'] of component 'Link'.
WARNING:pypsa.consistency:The following stores have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['hydrogen storage', 'gas storage', 'EV DSM'], 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 objective.
Writing constraints.: 0%| | 0/23 [00:00<?, ?it/s]
Writing constraints.: 87%|████████▋ | 20/23 [00:00<00:00, 194.57it/s]
Writing constraints.: 100%|██████████| 23/23 [00:00<00:00, 161.53it/s]
Writing continuous variables.: 0%| | 0/11 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 11/11 [00:00<00:00, 380.29it/s]
INFO:linopy.io: Writing time: 0.19s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 43811 primals, 94181 duals Objective: 1.43e+11 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-ext-p-lower, Generator-ext-p-upper, Link-fix-p-lower, Link-fix-p-upper, Link-ext-p-lower, Link-ext-p-upper, Store-fix-e-lower, Store-fix-e-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')
n.statistics()
| Optimal Capacity | Installed Capacity | Supply | Withdrawal | Energy Balance | Transmission | Capacity Factor | Curtailment | Capital Expenditure | Operational Expenditure | Revenue | Market Value | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Generator | offwind | 2.815849e+05 | 0.0 | 8.833137e+08 | 0.000000e+00 | 8.833137e+08 | 0.000000e+00 | 0.358098 | 1.157736e+07 | 4.915240e+10 | 1.872625e+07 | 4.917112e+10 | 55.666663 |
| onwind | 1.027945e+05 | 0.0 | 1.417214e+08 | 0.000000e+00 | 1.417214e+08 | 0.000000e+00 | 0.157384 | 4.354569e+07 | 1.044846e+10 | 2.024632e+08 | 1.065092e+10 | 75.153954 | |
| solar | 4.778360e+05 | 0.0 | 5.198227e+08 | 0.000000e+00 | 5.198227e+08 | 0.000000e+00 | 0.124186 | 0.000000e+00 | 2.453537e+10 | 5.510121e+06 | 2.454088e+10 | 47.210086 | |
| Link | CHP | 2.070308e+05 | 0.0 | 0.000000e+00 | 1.000000e+08 | -1.000000e+08 | 1.000000e+08 | 0.055139 | 1.713590e+09 | 8.281231e+09 | 0.000000e+00 | 0.000000e+00 | NaN |
| EV charger | 4.400000e+05 | 440000.0 | 0.000000e+00 | 2.292063e+08 | -2.292063e+08 | 0.000000e+00 | 0.059466 | 2.849877e+09 | 0.000000e+00 | 0.000000e+00 | -1.503501e+10 | -65.595969 | |
| OCGT | 0.000000e+00 | 0.0 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN | |
| V2G | 4.400000e+05 | 440000.0 | 0.000000e+00 | 8.200809e+07 | -8.200809e+07 | 0.000000e+00 | 0.021276 | 2.997075e+09 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN | |
| electrolysis | 1.770623e+05 | 0.0 | 0.000000e+00 | 7.069242e+08 | -7.069242e+08 | 7.069242e+08 | 0.455767 | 8.441412e+08 | 8.853113e+09 | 0.000000e+00 | -1.701664e+10 | -24.071372 | |
| fuel cell | 1.372234e+04 | 0.0 | 0.000000e+00 | 1.592122e+07 | -1.592122e+07 | 1.592122e+07 | 0.132448 | 1.042864e+08 | 1.646680e+09 | 0.000000e+00 | 0.000000e+00 | NaN | |
| heat pump | 5.393521e+04 | 0.0 | 0.000000e+00 | 2.201725e+08 | -2.201725e+08 | 2.201725e+08 | 0.466001 | 2.522999e+08 | 1.618056e+10 | 0.000000e+00 | -3.124643e+10 | -141.917945 | |
| resistive heater | 7.600678e+04 | 0.0 | 0.000000e+00 | 3.139689e+07 | -3.139689e+07 | 3.139689e+07 | 0.047155 | 6.344225e+08 | 7.600678e+08 | 0.000000e+00 | -4.647582e+09 | -148.026822 | |
| Load | - | 0.000000e+00 | 0.0 | 0.000000e+00 | 4.789257e+08 | -4.789257e+08 | 0.000000e+00 | NaN | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | -4.851206e+10 | -101.293486 |
| EV demand | 0.000000e+00 | 0.0 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN | |
| heat | 0.000000e+00 | 0.0 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN | |
| hydrogen | 0.000000e+00 | 0.0 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN | |
| StorageUnit | battery storage | 0.000000e+00 | 0.0 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN |
| Store | EV battery | 1.000000e+06 | 1000000.0 | 1.452284e+08 | 1.452284e+08 | 0.000000e+00 | 0.000000e+00 | 0.533101 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | 0.000000e+00 | NaN |
| gas storage | 1.000000e+08 | 100000000.0 | 1.000000e+08 | 0.000000e+00 | 1.000000e+08 | 0.000000e+00 | 0.266132 | 0.000000e+00 | 0.000000e+00 | 1.500000e+10 | 0.000000e+00 | NaN | |
| hydrogen storage | 5.528145e+07 | 0.0 | 2.273220e+08 | 2.273220e+08 | 0.000000e+00 | 0.000000e+00 | 0.513088 | 0.000000e+00 | 7.739403e+09 | 0.000000e+00 | 0.000000e+00 | NaN |
n.statistics.capex().div(1e9).sort_values().dropna().plot.bar(
ylabel="bn€/a", cmap="tab20c", figsize=(7, 3)
)
<Axes: xlabel='component,carrier', ylabel='bn€/a'>