Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

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

Prepare technology data

The PyPSA project maintains a database (https://github.com/PyPSA/technology-data) which collects assumptions and projections for energy system technologies (such as costs, efficiencies, lifetimes, etc.) for given years, which we can load into a pandas.DataFrame.

Reading this data into a usable pandas.DataFrame requires some pre-processing (e.g. converting units, setting defaults, re-arranging dimensions):

Loading...

Let’s also write a small utility function that calculates the annuity to annualise investment costs. The formula is

a(r,n)=r1−(1+r)−na(r, n) = \frac{r}{1-(1+r)^{-n}}

where rr is the discount rate and nn is the lifetime. If r=0r=0, the annuity simplifies to a(0,n)=1na(0,n) = \frac{1}{n}. If n=∞n=\infty, the annuity simplifies to a(r,∞)=ra(r, \infty) = r.

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:

0.09439292574325567

Based on this, we can calculate the short-term marginal generation costs (€/MWh), named marginal_cost in PyPSA:

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:

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.

Loading...

Let’s also convert the load time series from GW to MW, the base unit of PyPSA:

Optionally, we can downscale temporal resolution of the time series to save some computation time:

Build our energy system to simulate

As always, let’s initialize an empty network:

Then, we add a single electricity bus...

...and tell the pypsa.Network object n what the snapshots of the model will be using the utility function n.set_snapshots().

DatetimeIndex(['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:

Loading...
Loading...

Adding components for electricity part

Then, we add all the technologies we are going to include as carriers.

Next, we add the demand time series to the model.

Let’s have a check whether the data was read-in correctly.

Loading...
Loading...

We are going to add one dispatchable generation technology to the model. This is an open-cycle gas turbine (OCGT) with CO2_2 emissions of 0.2 t/MWhth_{th}.

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.

Loading...

Adding components for hydrogen part

Add a dedicated Bus for the hydrogen energy carrier:

Add a Link for the hydrogen electrolysis:

Add a Link for the fuel cell which reconverts hydrogen to electricity:

Add a Store for the hydrogen storage:

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 MWhH2_{H_2}):

We are now ready to solve the model

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:

47.52193300730432

n.statistics() provides an informative overview of the model results.

See documentation: https://docs.pypsa.org/latest/user-guide/statistics/

Loading...

We can use n.statistics() to get a quick overview of optimised capacities across all components:

component carrier Generator OCGT 69.5 offwind 97.7 solar 147.6 Link electrolysis 46.7 Store hydrogen storage 938.8 dtype: float64

You can also use n.statistics() to promptly get an energy balance for the complete system or even any specific bus:

Loading...
Loading...

Possibly, we are also interested in the total emissions:

np.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 CO2_2 emission limit as global constraint:

When we run the model now...

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:

75.33833622429285

The optimal capacity mix now does not include any gas power plants and includes onshore wind and fuel cell technologies:

component 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: float64

Fuel cell technology steps in hours with low wind and solar generation:

Loading...
Loading...
Loading...

Total emissions are now zero:

np.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.

The heat demand time series (in MW) comes at 4-hourly resolution, matching our snapshots:

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: float64

Let’s compare the electricity and heat demand:

Loading...

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:

COP(ΔT)=6.81−0.121ΔT+0.00063ΔT2COP(\Delta T) = 6.81 - 0.121 \Delta T + 0.00063 \Delta T^2

where ΔT=Tsink−Tsource\Delta T = T_{sink} - T_{source}.

We take the ambient temperature for 2015 from Renewables.ninja and downsample it like the other time series:

The COP drops as the ambient temperature falls:

Loading...

So the COP is lowest in winter, exactly when heat demand peaks:

Loading...

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.

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:

Solving the model with heating

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:

166.87894339122477

The 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:

component 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: float64

The annual heat balance (positive is supply, negative is consumption) shows that heat pumps supply nearly all heat:

component carrier bus_carrier Link heat pump heat 587.6 resistive heater heat 6.0 Load heat heat -593.7 dtype: float64

Heat 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:

Loading...

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:

Loading...

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.