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
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-202609/main/bham/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, 208.44it/s]
Writing continuous variables.: 0%| | 0/7 [00:00<?, ?it/s]Writing continuous variables.: 100%|██████████| 7/7 [00:00<00:00, 461.48it/s]
INFO:linopy.io: Writing time: 0.09s
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 17527 primals, 37237 duals
Objective: 4.75e+10
Solver: highs
Runtime: 3.90s
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, 173.11it/s]
Writing continuous variables.: 0%| | 0/7 [00:00<?, ?it/s]Writing continuous variables.: 100%|██████████| 7/7 [00:00<00:00, 389.43it/s]
INFO:linopy.io: Writing time: 0.1s
INFO:linopy.constants: Optimization successful:
Status: ok
Termination condition: optimal
Solution: 17527 primals, 37238 duals
Objective: 7.53e+10
Solver: highs
Runtime: 5.65s
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)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.