Problem description¶
To explore the capacity expansion problem with sector-coupling options, let’s model a “greenfield” energy system with:
electricity demand (here: Germany 2015)
several technologies for electricity generation (wind, solar, and gas for peak load)
hydrogen consumption (e.g., an offtaker in the industrial sector)
hydrogen production from electrolysis
hydrogen storage
hydrogen fuel cell
heat demand
heat pumps and resistive heaters
import pandas as pd
import pypsa
pypsa.options.params.optimize.include_objective_constant = False
pd.options.plotting.backend = "plotly"Prepare technology data¶
The PyPSA project maintains a database (https://pandas.DataFrame.
Reading this data into a usable pandas.DataFrame requires some pre-processing (e.g. converting units, setting defaults, re-arranging dimensions):
YEAR = 2030
# technology-data pinned to release v0.13.4 (October 2025)
# so that the numbers in this notebook are reproducible across builds
VERSION = "v0.13.4"
url = f"https://raw.githubusercontent.com/PyPSA/technology-data/{VERSION}/outputs/costs_{YEAR}.csv"
costs = pd.read_csv(url, index_col=[0, 1])costs.loc[costs.unit.str.contains("/kW"), "value"] *= 1e3
costs.unit = costs.unit.str.replace("/kW", "/MW")
defaults = {
"FOM": 0,
"VOM": 0,
"efficiency": 1,
"fuel": 0,
"investment": 0,
"lifetime": 25,
"CO2 intensity": 0,
"discount rate": 0.07,
}
costs = costs.value.unstack().fillna(defaults)
costs.at["OCGT", "fuel"] = costs.at["gas", "fuel"]
costs.at["OCGT", "CO2 intensity"] = costs.at["gas", "CO2 intensity"]costs.head()Let’s also write a small utility function that calculates the annuity to annualise investment costs. The formula is
where is the discount rate and is the lifetime. If , the annuity simplifies to . If , the annuity simplifies to .
def annuity_factor(r, n):
return 1 / n if r == 0 else r / (1 - (1 + r) ** -n)The resulting annuity_factor (often called capital recovery factor) represents the factor that, when multiplied by the initial capital cost, yields the equivalent annual cost accounting for the time value of money:
annuity_factor(0.07, 20)0.09439292574325567Based on this, we can calculate the short-term marginal generation costs (€/MWh), named marginal_cost in PyPSA:
costs["marginal_cost"] = costs["VOM"] + costs["fuel"] / costs["efficiency"]and the annualised investment costs (capital_cost in PyPSA terms, €/MW/a). The FOM cost is expressed as a percentage of the overnight investment cost per year, and thus can be added to the annuity factor when calculating the annualised capital cost:
annuity = costs.apply(lambda x: annuity_factor(0.07, x["lifetime"]), axis=1)costs["capital_cost"] = (annuity + costs["FOM"] / 100) * costs["investment"]Prepare load and renewable generation time-series¶
We are also going to need some time series for wind, solar and load. For this example, we use time series for Germany in the year 2015 — the same ones we used in the pandas tutorial in the annex.
# Backup: https://cloud.pypsalabs.org/s/FSJ3qwQjqCxcWXQ/download/time-series-sample.csv
url = "https://raw.githubusercontent.com/pypsalabs/workshop-202610/main/berlin/data/time-series-sample.csv"
ts = pd.read_csv(url, index_col=0, parse_dates=True)ts.head()Let’s also convert the load time series from GW to MW, the base unit of PyPSA:
ts.load *= 1e3Optionally, we can downscale temporal resolution of the time series to save some computation time:
# here we sample only every fourth hour:
resolution = 4
ts = ts.resample(f"{resolution}h").first()Build our energy system to simulate¶
As always, let’s initialize an empty network:
n = pypsa.Network()Then, we add a single electricity bus...
n.add("Bus", "electricity")...and tell the pypsa.Network object n what the snapshots of the model will be using the utility function n.set_snapshots().
n.set_snapshots(ts.index)n.snapshotsDatetimeIndex(['2015-01-01 00:00:00', '2015-01-01 04:00:00',
'2015-01-01 08:00:00', '2015-01-01 12:00:00',
'2015-01-01 16:00:00', '2015-01-01 20:00:00',
'2015-01-02 00:00:00', '2015-01-02 04:00:00',
'2015-01-02 08:00:00', '2015-01-02 12:00:00',
...
'2015-12-30 08:00:00', '2015-12-30 12:00:00',
'2015-12-30 16:00:00', '2015-12-30 20:00:00',
'2015-12-31 00:00:00', '2015-12-31 04:00:00',
'2015-12-31 08:00:00', '2015-12-31 12:00:00',
'2015-12-31 16:00:00', '2015-12-31 20:00:00'],
dtype='datetime64[ns]', name='snapshot', length=2190, freq='4h')If we resampled the time series above, we need to adjust the weighting of the snapshots (i.e. how many hours they represent). We can do that with n.snapshot_weightings:
n.snapshot_weightings.head(3)n.snapshot_weightings.loc[:, :] = resolutionn.snapshot_weightings.head(3)Adding components for electricity part¶
Then, we add all the technologies we are going to include as carriers.
carriers = [
"onwind",
"offwind",
"solar",
"OCGT",
"hydrogen storage underground",
]
n.add(
"Carrier",
carriers,
color=["dodgerblue", "aquamarine", "gold", "indianred", "magenta"],
co2_emissions=[costs.at[c, "CO2 intensity"] for c in carriers],
)Next, we add the demand time series to the model.
n.add(
"Load",
"demand",
bus="electricity",
p_set=ts.load,
)Let’s have a check whether the data was read-in correctly.
n.loads_t.p_set.plot(labels={"value": "Load [MW]"})We are going to add one dispatchable generation technology to the model. This is an open-cycle gas turbine (OCGT) with CO emissions of 0.2 t/MWh.
n.add(
"Generator",
"OCGT",
bus="electricity",
carrier="OCGT",
capital_cost=costs.at["OCGT", "capital_cost"],
marginal_cost=costs.at["OCGT", "marginal_cost"],
efficiency=costs.at["OCGT", "efficiency"],
p_nom_extendable=True,
)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.
for tech in ["onwind", "offwind", "solar"]:
n.add(
"Generator",
tech,
bus="electricity",
carrier=tech,
p_max_pu=ts[tech],
capital_cost=costs.at[tech, "capital_cost"],
marginal_cost=costs.at[tech, "marginal_cost"],
efficiency=costs.at[tech, "efficiency"],
p_nom_extendable=True,
)# Making sure the capacity factors are read-in correctly
n.generators_t.p_max_pu.loc["2015-03"].plot(labels={"value": "Capacity Factor [p.u.]"})Adding components for hydrogen part¶
Add a dedicated Bus for the hydrogen energy carrier:
n.add("Bus", "hydrogen", carrier="hydrogen")Add a Link for the hydrogen electrolysis:
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:
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
)To model an industrial hydrogen offtaker, we add also a hydrogen demand to the hydrogen bus.
In the example below, we add a hydrogen demand such that it equals ~25% of the electricity demand (in MWh):
n.add(
"Load", "hydrogen demand", bus="hydrogen", carrier="hydrogen", p_set=19500
) # MWh_H2/hWe are now ready to solve the model¶
n.optimize(solver_name="highs", log_to_console=False)
# ~66 seconds for 1H temporal resolution (8760 snapshots)
# ~7 seconds for 4H temporal resolution (2190 snapshots)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'], 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'], 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'], dtype='object', name='name')
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'], 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/11 [00:00<?, ?it/s]Writing constraints.: 100%|██████████| 11/11 [00:00<00:00, 171.08it/s]
Writing continuous variables.: 0%| | 0/7 [00:00<?, ?it/s]Writing continuous variables.: 100%|██████████| 7/7 [00:00<00:00, 392.96it/s]
INFO:linopy.io: Writing time: 0.1s
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 17527 primals, 37237 duals
Objective: 4.75e+10
Solver: highs
Runtime: 3.71s
MIP gap: inf
Dual bound: 0.00e+00
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-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, Store-energy_balance were not assigned to the network.
('ok', 'optimal')Exploring model results¶
The total system cost in billion Euros per year:
n.objective / 1e947.52193300730432n.statistics() provides an informative overview of the model results.
See documentation: https://
n.statistics()We can use n.statistics() to get a quick overview of optimised capacities across all components:
n.statistics.expanded_capacity().div(1e3).round(1) # GWcomponent carrier
Generator OCGT 69.5
offwind 97.7
solar 147.6
Link electrolysis 46.7
Store hydrogen storage 938.8
dtype: float64You can also use n.statistics() to promptly get an energy balance for the complete system or even any specific bus:
n.statistics.energy_balance(aggregate_time=False)n.statistics.energy_balance(aggregate_time=False, bus_carrier="hydrogen").div(
1e3
).groupby("carrier").sum().T.plot()Possibly, we are also interested in the total emissions:
emissions = (
n.generators_t.p
/ n.generators.efficiency
* n.generators.carrier.map(n.carriers.co2_emissions)
) # t/hn.snapshot_weightings.generators @ emissions.sum(axis=1).div(1e6) # Mtnp.float64(127.59537376038034)Adding emission limits¶
The gas power plant offers sufficient and cheap enough backup capacity to run in periods of low wind and solar generation. But what happens if this source of flexibility disappears? Let’s model a 100% renewable electricity system by adding a CO emission limit as global constraint:
n.add(
"GlobalConstraint",
"CO2Limit",
carrier_attribute="co2_emissions",
sense="<=",
constant=0,
)When we run the model now...
n.optimize(solver_name="highs", log_to_console=False)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'], 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'], 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'], dtype='object', name='name')
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'], dtype='object', name='name')
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers:
Index(['0', '1'], 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/12 [00:00<?, ?it/s]Writing constraints.: 100%|██████████| 12/12 [00:00<00:00, 179.01it/s]
Writing continuous variables.: 0%| | 0/7 [00:00<?, ?it/s]Writing continuous variables.: 100%|██████████| 7/7 [00:00<00:00, 291.14it/s]
INFO:linopy.io: Writing time: 0.11s
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 17527 primals, 37238 duals
Objective: 7.53e+10
Solver: highs
Runtime: 4.63s
MIP gap: inf
Dual bound: 0.00e+00
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-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, Store-energy_balance were not assigned to the network.
('ok', 'optimal')The total system cost (billion Euros per year) is now higher than before:
n.objective / 1e975.33833622429285The optimal capacity mix now does not include any gas power plants and includes onshore wind and fuel cell technologies:
n.statistics.expanded_capacity().div(1e3).round(1) # GWcomponent carrier
Generator offwind 101.8
onwind 111.9
solar 332.5
Link electrolysis 145.0
fuel cell 135.2
Store hydrogen storage 38417.0
dtype: float64Fuel cell technology steps in hours with low wind and solar generation:
n.statistics.energy_balance(aggregate_time=False, bus_carrier="AC").div(1e3).groupby(
"carrier"
).sum().T.plot()n.statistics.energy_balance(aggregate_time=False, bus_carrier="hydrogen").div(
1e3
).groupby("carrier").sum().T.plot()n.stores_t.e.plot()Total emissions are now zero:
emissions = (
n.generators_t.p
/ n.generators.efficiency
* n.generators.carrier.map(n.carriers.co2_emissions)
) # t/hn.snapshot_weightings.generators @ emissions.sum(axis=1).div(1e6) # Mtnp.float64(0.0)Adding the heating sector¶
So far, our model covers electricity and hydrogen. Let’s now couple the heating sector: we add a heat demand and two technologies that convert electricity to heat, heat pumps and resistive heaters. The emissions limit from above stays in place, so all heat must come from renewable electricity.
For modelling simple heating systems, we create another bus and connect a load with the heat demand time series to it.
n.add("Bus", "heat", carrier="heat")The heat demand time series (in MW) comes at 4-hourly resolution, matching our snapshots:
# Backup: https://tubcloud.tu-berlin.de/s/mSkHERH8fJCKNXx/download/heat-load-example.csv
url = "https://raw.githubusercontent.com/pypsalabs/workshop-202610/main/berlin/data/heat-load-example.csv"
heat_load = pd.read_csv(url, index_col=0, parse_dates=True).squeeze()heat_load.head()snapshot
2015-01-01 00:00:00 61726.043437
2015-01-01 04:00:00 108787.133591
2015-01-01 08:00:00 101508.988082
2015-01-01 12:00:00 90475.260586
2015-01-01 16:00:00 96307.755312
Name: 0, dtype: float64n.add("Load", "heat demand", bus="heat", carrier="heat", p_set=heat_load)Let’s compare the electricity and heat demand:
n.loads_t.p_set.plot(labels={"value": "Load [MW]"})What is missing now are the components to supply heat to the "heat" bus, such as heat pumps, resistive heaters or combined heat and power plants (CHPs). Here, we focus on heat pumps and resistive heaters.
Heat pumps¶
To model heat pumps, we first have to calculate the coefficient of performance (COP) profile based on the temperature profile of the heat source. The COP is the amount of heat a heat pump delivers per unit of electricity.
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:
where .
def cop(t_source, t_sink=55):
delta_t = t_sink - t_source
return 6.81 - 0.121 * delta_t + 0.000630 * delta_t**2We take the ambient temperature for 2015 from Renewables.ninja and downsample it like the other time series:
# Backup: https://tubcloud.tu-berlin.de/s/S4jRAQMP5Te96jW/download/ninja_weather_country_DE_merra-2_population_weighted.csv
url = "https://raw.githubusercontent.com/pypsalabs/workshop-202610/main/berlin/data/ninja_weather_country_DE_merra-2_population_weighted.csv"
weather = pd.read_csv(url, skiprows=2, index_col=0, parse_dates=True)
temp = weather.loc["2015", "temperature"].resample(f"{resolution}h").first()The COP drops as the ambient temperature falls:
pd.DataFrame({"temperature": temp, "COP": cop(temp)}).plot.scatter(
x="temperature", y="COP", labels={"temperature": "Temperature [°C]"}
)So the COP is lowest in winter, exactly when heat demand peaks:
cop(temp).plot(labels={"value": "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 time-varying COP. Note that the capacity of a Link refers to its input (bus0), here MW of electricity. The technology data gives costs per MW of heat output, so we multiply them by the nominal COP to get costs per MW of electricity input.
n.add(
"Link",
"heat pump",
bus0="electricity",
bus1="heat",
carrier="heat pump",
p_nom_extendable=True,
efficiency=cop(temp),
capital_cost=costs.at["decentral air-sourced heat pump", "capital_cost"]
* costs.at["decentral air-sourced heat pump", "efficiency"], # €/MW_el/a
)Resistive heaters¶
Let’s also add a resistive heater as backup technology. Its efficiency is constant and well below the COP of a heat pump, but it is much cheaper to build:
n.add(
"Link",
"resistive heater",
bus0="electricity",
bus1="heat",
carrier="resistive heater",
p_nom_extendable=True,
efficiency=costs.at["decentral resistive heater", "efficiency"],
capital_cost=costs.at["decentral resistive heater", "capital_cost"]
* costs.at["decentral resistive heater", "efficiency"], # €/MW_el/a
)Solving the model with heating¶
n.optimize(solver_name="highs", log_to_console=False)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'], 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'], 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'], dtype='object', name='name')
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'], dtype='object', name='name')
WARNING:pypsa.consistency:The following sub_networks have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers:
Index(['0', '1'], 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/12 [00:00<?, ?it/s]Writing constraints.: 100%|██████████| 12/12 [00:00<00:00, 129.32it/s]
Writing continuous variables.: 0%| | 0/7 [00:00<?, ?it/s]Writing continuous variables.: 100%|██████████| 7/7 [00:00<00:00, 362.20it/s]
INFO:linopy.io: Writing time: 0.13s
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 21909 primals, 48190 duals
Objective: 1.67e+11
Solver: highs
Runtime: 6.04s
MIP gap: inf
Dual bound: 0.00e+00
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-ext-p-lower, Link-ext-p-upper, Store-ext-e-lower, Store-ext-e-upper, Store-energy_balance were not assigned to the network.
('ok', 'optimal')The total system cost (billion Euros per year) increases, since we now also supply heat:
n.objective / 1e9166.87894339122477The optimal capacity mix now includes heat pumps and resistive heaters (link capacities in GW of electricity input). Fuel cell capacity more than doubles, since heating adds electricity demand in winter, when solar output is low:
n.statistics.expanded_capacity().div(1e3).round(1) # GWcomponent carrier
Generator offwind 162.2
onwind 254.7
solar 264.7
Link electrolysis 169.1
fuel cell 349.2
heat pump 78.1
resistive heater 57.8
Store hydrogen storage 105682.1
dtype: float64The annual heat balance (positive is supply, negative is consumption) shows that heat pumps supply nearly all heat:
n.statistics.energy_balance(bus_carrier="heat").div(1e6).round(1) # TWhcomponent carrier bus_carrier
Link heat pump heat 587.6
resistive heater heat 6.0
Load heat heat -593.7
dtype: float64Heat pumps are expensive to build, so the model does not size them for the coldest hours. Cheap resistive heaters cover these winter peaks, while heat pumps run at full capacity:
n.statistics.energy_balance(aggregate_time=False, bus_carrier="heat").div(1e3).groupby(
"carrier"
).sum().T.plot()On the electricity bus, heat pumps now appear as a large additional consumer, mostly in winter. Fuel cells, fed by stored hydrogen, run more in winter to cover this extra demand:
n.statistics.energy_balance(aggregate_time=False, bus_carrier="AC").div(1e3).groupby(
"carrier"
).sum().T.plot()Exercises¶
Explore the model by changing the assumptions and available technologies. Here are a few inspirations, which you do not have to follow in order:
Task 1: Optimistic Costs Rerun the model with cost assumptions for 2050. You can change the year when loading the technology data.
Task 2: Storage Constraints What if hydrogen storage cannot be expanded? You can remove components with n.remove("Store", "hydrogen storage"). How does the system compensate?
Task 3: Renewable Constraints What if you can either only build solar or only build wind? You can remove components with n.remove("Generator", "ComponentName").
Task 4: Nuclear Add nuclear as another dispatchable low-emission generator (modelled similarly to the OCGT generator). Perform a sensitivity analysis trying to answer how low the capital cost of a nuclear plant would need to be to be chosen in the cost-optimal mix.
Task 5: Fossil Fuel Crisis Observe how the total system cost and composition of technologies changes with increasing gas prices (which could be a result of an energy crisis or carbon pricing). You can change the gas price by adjusting the marginal_cost of the OCGT generator.