Problem 8.1#

Integrated Energy Grids

Problem 8.1. Join capacity and dispatch optimization.

Optimize the capacity and dispatch of solar PV, onshore wind, and Open Cycle Gas Turbine (OCGT) generators to supply the inelastic electricity demand throughout one year. To do this, take the time series for the wind and solar capacity factors for Portugal in 2015 obtained from https://zenodo.org/record/3253876#.XSiVOEdS8l0 and https://zenodo.org/record/2613651#.X0kbhDVS-uV (select the file ‘pvoptimal.csv’) and the electricity demand from martavp/integrated-energy-grids.

Consider the annualized capital costs and variable costs for the different technologies in the following table.

a) Calculate the total system cost, the optimal installed capacities, the annual generation per technology, and plot the hourly generation and demand during January.

b) Calculate the revenues collected by every technology throughout the year and show that their sum is equal to their costs.

Note

If you have not yet set up Python on your computer, you can execute this tutorial in your browser via Google Colab. Click on the rocket in the top right corner and launch “Colab”. If that doesn’t work download the .ipynb file and import it in Google Colab.

Then install pandas and numpy by executing the following command in a Jupyter cell at the top of the notebook.

!pip install pandas pypsa

Note

See also https://model.energy.

In this exercise, we want to 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. We deviate from model.energy by including electricity demand profiles rather than a constant electricity demand.

import matplotlib.pyplot as plt
import pandas as pd
import pypsa

Prerequisites: handling technology data and costs#

We maintain a database (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. This requires some pre-processing to load (e.g. converting units, setting defaults, re-arranging dimensions):

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.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"]

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

\[ a(r, n) = \frac{r}{1-(1+r)^{-n}} \]

where \(r\) is the discount rate and \(n\) is the lifetime.

def annuity(r, n):
    return r / (1.0 - 1.0 / (1.0 + r) ** n)
annuity(0.07, 20)
0.09439292574325567

Based on this, we can calculate the marginal generation costs (€/MWh):

costs["marginal_cost"] = costs["VOM"] + costs["fuel"] / costs["efficiency"]

and the annualised investment costs (capital_cost in PyPSA terms, €/MW/a):

annuity = costs.apply(lambda x: annuity(x["discount rate"], x["lifetime"]), axis=1)
costs["capital_cost"] = (annuity + costs["FOM"] / 100) * costs["investment"]

We can now read the capital and marginal cost of onshore wind, solar and OCGT

costs.at["onwind", "capital_cost"] #EUR/MW/a
np.float64(101644.12332388277)
costs.at["solar", "capital_cost"] #EUR/MW/a
np.float64(51346.82981964593)
costs.at["OCGT", "capital_cost"] #EUR/MW/a
np.float64(47718.67056370105)
costs.at["OCGT", "marginal_cost"] #EUR/MWh
np.float64(64.6839512195122)

Retrieving time series data#

In this example, wind data from https://zenodo.org/record/3253876#.XSiVOEdS8l0 and solar PV data from https://zenodo.org/record/2613651#.X0kbhDVS-uV is used. The data is downloaded in csv format and saved in the ‘data’ folder. The Pandas package is used as a convenient way of managing the datasets.

For convenience, the column including date information is converted into Datetime and set as index

data_solar = pd.read_csv('data/pv_optimal.csv',sep=';')
data_solar.index = pd.DatetimeIndex(data_solar['utc_time'])

data_wind = pd.read_csv('data/onshore_wind_1979-2017.csv',sep=';')
data_wind.index = pd.DatetimeIndex(data_wind['utc_time'])

data_el = pd.read_csv('data/electricity_demand.csv',sep=';')
data_el.index = pd.DatetimeIndex(data_el['utc_time'])

The data format can now be analyzed using the .head() function to show the first lines of the data set

data_solar.head()
utc_time AUT BEL BGR BIH CHE CYP CZE DEU DNK ... MLT NLD NOR POL PRT ROU SRB SVK SVN SWE
utc_time
1979-01-01 00:00:00+00:00 1979-01-01T00:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 01:00:00+00:00 1979-01-01T01:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 02:00:00+00:00 1979-01-01T02:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 03:00:00+00:00 1979-01-01T03:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1979-01-01 04:00:00+00:00 1979-01-01T04:00:00Z 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

5 rows × 33 columns

We will use timeseries for Portugal in this excercise

country = 'PRT'

Join capacity and dispatch optimization#

For building the model, we start again by initialising an empty network, adding the snapshots, and the electricity bus.

n = pypsa.Network()
hours_in_2015 = pd.date_range('2015-01-01 00:00Z',
                              '2015-12-31 23:00Z',
                              freq='h')

n.set_snapshots(hours_in_2015.values)

n.add("Bus",
            "electricity")

n.snapshots
DatetimeIndex(['2015-01-01 00:00:00', '2015-01-01 01:00:00',
               '2015-01-01 02:00:00', '2015-01-01 03:00:00',
               '2015-01-01 04:00:00', '2015-01-01 05:00:00',
               '2015-01-01 06:00:00', '2015-01-01 07:00:00',
               '2015-01-01 08:00:00', '2015-01-01 09:00:00',
               ...
               '2015-12-31 14:00:00', '2015-12-31 15:00:00',
               '2015-12-31 16:00:00', '2015-12-31 17:00:00',
               '2015-12-31 18:00:00', '2015-12-31 19:00:00',
               '2015-12-31 20:00:00', '2015-12-31 21:00:00',
               '2015-12-31 22:00:00', '2015-12-31 23:00:00'],
              dtype='datetime64[ns]', name='snapshot', length=8760, freq=None)

We add all the technologies we are going to include as carriers. Defining carriers is not mandatory but will ease plotting and assigning emissions of CO2 in future steps.

carriers = [
    "onwind",
    "solar",
    "OCGT",
    "CCGT",
    "battery storage",
]

n.add(
    "Carrier",
    carriers,
    color=["dodgerblue", "gold", "indianred","yellow-green", "brown"],
    co2_emissions=[costs.at[c, "CO2 intensity"] for c in carriers],
)
Index(['onwind', 'solar', 'OCGT', 'CCGT', 'battery storage'], dtype='object')

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

# add load to the bus
n.add("Load",
      "demand",
      bus="electricity",
      p_set=data_el[country].values)
Index(['demand'], dtype='object')

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

n.loads_t.p_set.plot(figsize=(6, 2), ylabel="MW")
<Axes: xlabel='snapshot', ylabel='MW'>
../_images/d40bdf91d7e9434172ab2ebd6dc441dc37a4d8cf3eac0000972359dac0943aa1.png

We add now the generators and set up their capacities to be extendable so that they can be optimized together with the dispatch time series. For the wind and solar generator, we need to indicate the capacity factor or maximum power per unit ‘p_max_pu’

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,
)

CF_wind = data_wind[country][[hour.strftime("%Y-%m-%dT%H:%M:%SZ") for hour in n.snapshots]]
n.add(
        "Generator",
        "onwind",
        bus="electricity",
        carrier="onwind",
        p_max_pu=CF_wind.values,
        capital_cost=costs.at["onwind", "capital_cost"],
        marginal_cost=costs.at["onwind", "marginal_cost"],
        efficiency=costs.at["onwind", "efficiency"],
        p_nom_extendable=True,
    )

CF_solar = data_solar[country][[hour.strftime("%Y-%m-%dT%H:%M:%SZ") for hour in n.snapshots]]
n.add(
        "Generator",
        "solar",
        bus="electricity",
        carrier="solar",
        p_max_pu= CF_solar.values,
        capital_cost=costs.at["solar", "capital_cost"],
        marginal_cost=costs.at["solar", "marginal_cost"],
        efficiency=costs.at["solar", "efficiency"],
        p_nom_extendable=True,
    )
Index(['solar'], dtype='object')

So let’s make sure the capacity factors are read-in correctly.

n.generators_t.p_max_pu.loc["2015-01"].plot(figsize=(6, 2), ylabel="CF")
<Axes: xlabel='snapshot', ylabel='CF'>
../_images/ed79caff22453fb39b57b258944527afb7c44d8d1041bd846450db5053dfabb6.png

Model Run#

We can already solved the model using the open-solver “highs” or the commercial solver “gurobi” with the academic license

n.optimize(solver_name="highs")
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['electricity'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.io:Writing objective.
Writing constraints.:   0%|          | 0/5 [00:00<?, ?it/s]
Writing constraints.:  60%|██████    | 3/5 [00:00<00:00, 18.79it/s]
Writing constraints.: 100%|██████████| 5/5 [00:00<00:00, 15.12it/s]
Writing constraints.: 100%|██████████| 5/5 [00:00<00:00, 15.63it/s]

Writing continuous variables.:   0%|          | 0/2 [00:00<?, ?it/s]
Writing continuous variables.: 100%|██████████| 2/2 [00:00<00:00, 45.29it/s]
INFO:linopy.io: Writing time: 0.41s
Running HiGHS 1.10.0 (git hash: fd86653): Copyright (c) 2025 HiGHS under MIT licence terms
LP   linopy-problem-17jm5lm5 has 61323 rows; 26283 cols; 100761 nonzeros
Coefficient ranges:
  Matrix [1e-03, 1e+00]
  Cost   [1e-02, 1e+05]
  Bound  [0e+00, 0e+00]
  RHS    [3e+03, 9e+03]
Presolving model
26316 rows, 17559 cols, 57030 nonzeros  0s
Dependent equations search running on 4398 equations with time limit of 1000.00s
Dependent equations search removed 0 rows and 0 nonzeros in 0.00s (limit = 1000.00s)
26316 rows, 17559 cols, 57030 nonzeros  0s
Presolve : Reductions: rows 26316(-35007); columns 17559(-8724); elements 57030(-43731)
Solving the presolved LP
Using EKK dual simplex solver - serial
  Iteration        Objective     Infeasibilities num(sum)
          0     0.0000000000e+00 Ph1: 0(0) 0s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 26283 primals, 61323 duals
Objective: 3.11e+09
Solver model: available
Solver message: Optimal
      16794     3.1120276519e+09 Pr: 0(0) 0s
Solving the original LP from the solution after postsolve
Model name          : linopy-problem-17jm5lm5
Model status        : Optimal
Simplex   iterations: 16794
Objective value     :  3.1120276519e+09
Relative P-D gap    :  3.6773747142e-15
HiGHS run time      :          0.47
Writing the solution to /tmp/linopy-solve-nds1n54x.sol
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-ext-p-lower, Generator-ext-p-upper were not assigned to the network.
('ok', 'optimal')

Now, we can look at the results and evaluate the total system cost (in billion Euros per year)

n.objective / 1e9
3.1120276518981047

The optimised capacities in GW:

n.generators.p_nom_opt.div(1e3)  # MW -> GW
Generator
OCGT      8.193448
onwind    5.241379
solar     8.371349
Name: p_nom_opt, dtype: float64

The total energy generation by technology in TWh:

n.generators_t.p.sum().div(1e6)  # MWh -> TWh
Generator
OCGT      26.974833
onwind     9.434456
solar     12.520371
dtype: float64

We can plot the dispatch of every generator thoughout January

n.generators_t.p.loc["2015-01"].plot.area(figsize=(6, 2), ylabel="dispatch")
<Axes: xlabel='snapshot', ylabel='dispatch'>
../_images/42df3522612e5ad1e54bf0a3b5e684c12670a8a2439d84c1bb3e13e9a7afc0b8.png
n.loads_t.p.loc["2015-01"].plot(figsize=(6, 2), ylabel="demand")
<Axes: xlabel='snapshot', ylabel='demand'>
../_images/2c7098f6e10fee5610ec10a38ec3aad781472baafa22c96c9b3581489bc2df0d.png

b) Calculate the revenues collected by every technology throughout the year and show that their sum is equal to their costs.

To calculate the revenues collected by every technology, we multiply the energy generated in every hour by the electricity price in that hour and sum for the entire year.

n.generators_t.p.multiply(n.buses_t.marginal_price.to_numpy()).sum().div(1e6) # EUR -> MEUR
Generator
OCGT      2135.819257
onwind     546.233469
solar      429.974926
dtype: float64

This corresponds to the total cost for every technology, which we can also read using the statistics module:

(n.statistics.capex() + n.statistics.opex()).div(1e6)
component  carrier
Generator  OCGT       2135.819257
           onwind      546.233469
           solar       429.974926
dtype: float64