Electricity Markets#

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 the following packages by executing the following command in a Jupyter cell at the top of the notebook.

!pip install -q pypsa highspy

What is PyPSA?#

PyPSA stands for Python for Power System Analysis. The documentation describes it as follows:

PyPSA is an open-source Python framework for optimising and simulating modern power and energy systems that include features such as conventional generators with unit commitment, variable wind and solar generation, hydro-electricity, inter-temporal storage, coupling to other energy sectors, elastic demands, and linearised power flow with loss approximations in DC and AC networks. PyPSA is designed to scale well with large networks and long time series. It is made for researchers, planners and utilities with basic coding aptitude who need a fast, easy-to-use and transparent tool for power and energy system analysis.

For us, it’s a tool to model electricity markets and investment planning as optimisation problems as discussed in the slides.

PyPSA is a way to describe energy systems in Python code, interface with solver software, and analyse and visualise the results.

Note

For a full reference to the optimisation problem description, see the PyPSA User Guide

Input Data#

Let’s dive straight in and get acquainted with PyPSA by building simple electricity market models.

We have the following data:

  • fuel costs in € / MWh\(_{th}\)

fuel_cost = dict(
    coal=8,
    gas=100,
    oil=48,
    hydro=1,
    wind=0,
)
  • efficiencies of thermal power plants in MWh\(_{el}\) / MWh\(_{th}\)

efficiency = dict(
    coal=0.33,
    gas=0.58,
    oil=0.35,
    hydro=1,
    wind=1,
)
  • specific emissions in t\(_{CO_2}\) / MWh\(_{th}\)

# t/MWh thermal
emissions = dict(
    coal=0.34,
    gas=0.2,
    oil=0.26,
    hydro=0,
    wind=0,
)
  • power plant capacities in MW

power_plants = {
    "A": {"coal": 35000, "wind": 3000, "gas": 8000, "oil": 2000},
    "B": {"hydro": 1200},
}
  • fixed electrical load in MW

loads = {
    "A": 42000,
    "B": 650,
}

Building the Network#

By convention, PyPSA is imported without an alias:

import pypsa

First, we create a new network object which serves as the overall container for all components.

n = pypsa.Network()

The second component we need are buses. Buses are the fundamental nodes of the network, to which all other components like loads, generators and transmission lines attach. They enforce energy conservation for all elements feeding in and out of it.

Components can be added to the network n using the n.add() function. It takes the component name as a first argument, the name of the component as a second argument and possibly further parameters as keyword arguments. Let’s use this function, to add buses for each country to our network:

n.add("Bus", "A", v_nom=380, carrier="AC")
n.add("Bus", "B", v_nom=380, carrier="AC");

For each class of components, the data describing the components is stored in a pandas.DataFrame. For example, all static data for buses is stored in n.buses

n.buses
v_nom type x y carrier unit location v_mag_pu_set v_mag_pu_min v_mag_pu_max control generator sub_network
Bus
A 380.0 0.0 0.0 AC 1.0 0.0 inf PQ
B 380.0 0.0 0.0 AC 1.0 0.0 inf PQ

You see there are many more attributes than we specified while adding the buses; many of them are filled with default parameters which were added. You can look up the field description, defaults and status (required input, optional input, output) for buses here https://pypsa–1250.org.readthedocs.build/en/1250/user-guide/components/buses/, and analogous for all other components.

The method n.add() also allows you to add multiple components at once. For instance, multiple carriers for the fuels with information on specific carbon dioxide emissions, a nice name, and colors for plotting. For this, the function takes the component name as the first argument and then a list of component names and then optional arguments for the parameters. Here, scalar values, lists, dictionary or pandas.Series are allowed. The latter two needs keys or indices with the component names.

n.add(
    "Carrier",
    ["coal", "gas", "oil", "hydro", "wind"],
    co2_emissions=emissions,
    nice_name=["Coal", "Gas", "Oil", "Hydro", "Onshore Wind"],
    color=["grey", "indianred", "black", "aquamarine", "dodgerblue"],
);

The n.add() function is very general. It lets you add any component to the network object n. For instance, in the next step we add generators for all the different power plants.

In Region B:

n.add(
    "Generator",
    "B hydro",
    bus="B",
    carrier="hydro",
    p_nom=1200,  # MW
);

In Region A (in a loop):

for tech, p_nom in power_plants["A"].items():
    n.add(
        "Generator",
        f"A {tech}",
        bus="A",
        carrier=tech,
        efficiency=efficiency.get(tech, 1),
        p_nom=p_nom,
        marginal_cost=fuel_cost.get(tech, 0) / efficiency.get(tech, 1),
    )

As a result, the n.generators DataFrame looks like this:

n.generators["marginal_cost"]
Generator
B hydro      0.000000
A coal      24.242424
A wind       0.000000
A gas      172.413793
A oil      137.142857
Name: marginal_cost, dtype: float64

Next, we’re going to add the electricity demand.

A positive value for p_set means consumption of power from the bus.

n.add(
    "Load",
    "A electricity demand",
    bus="A",
    p_set=loads["A"],
);
n.add(
    "Load",
    "B electricity demand",
    bus="B",
    p_set=loads["B"],
);
n.loads
bus carrier type p_set q_set sign active
Load
A electricity demand A 42000.0 0.0 -1.0 True
B electricity demand B 650.0 0.0 -1.0 True

Finally, we add the connection between Region B and Region A with a 500 MW transmission line, modelled as a link:

n.add(
    "Link",
    "A-B",
    bus0="A",
    bus1="B",
    p_min_pu=-1,
    p_nom=500,
);
n.links
bus0 bus1 type carrier efficiency active build_year lifetime p_nom p_nom_mod ... shut_down_cost min_up_time min_down_time up_time_before down_time_before ramp_limit_up ramp_limit_down ramp_limit_start_up ramp_limit_shut_down p_nom_opt
Link
A-B A B 1.0 True 0 inf 500.0 0.0 ... 0.0 0 0 1 0 NaN NaN 1.0 1.0 0.0

1 rows × 34 columns

Optimisation#

With all input data transferred into PyPSA’s data structure, we can now build and run the resulting optimisation problem. In PyPSA, building, solving and retrieving results from the optimisation model is contained in a single function call n.optimize(). This function optimizes dispatch and investment decisions for least cost.

The n.optimize() function can take a variety of arguments. The most relevant for the moment is the choice of the solver (e.g. “highs” and “gurobi”). They need to be installed on your computer, to use them here!

n.optimize(log_to_console=False)

Hide code cell output

WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['A-B'], dtype='object', name='Link')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['A', 'B'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options:
 - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 6 primals, 14 duals
Objective: 1.38e+06
Solver model: available
Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
('ok', 'optimal')

Let’s have a look at the results.

Since the power flow and dispatch are generally time-varying quantities, these are stored in a different location than e.g. n.generators. They are stored in n.generators_t. Thus, to find out the dispatch of the generators, run

n.generators_t.p
Generator B hydro A coal A wind A gas A oil
snapshot
now 1150.0 35000.0 3000.0 1500.0 2000.0

or if you prefer it in relation to the generators nominal capacity

n.generators_t.p / n.generators.p_nom
Generator B hydro A coal A wind A gas A oil
snapshot
now 0.958333 1.0 1.0 0.1875 1.0

You see that the time index has the value ‘now’. This is the default index when no time series data has been specified and the network only covers a single state (e.g. a particular hour).

Similarly you will find the power flow in transmission lines at

n.lines_t.p0
Line
snapshot
now
n.lines_t.p1
Line
snapshot
now

The p0 will tell you the flow from bus0 to bus1. p1 will tell you the flow from bus1 to bus0.

What about the market clearing prices in the electricity market?

n.buses_t.marginal_price
Bus A B
snapshot
now 172.413793 -0.0

Modifying networks#

Modifying data of components in an existing PyPSA network can be done by modifying the entries of a pandas.DataFrame. For instance, if we want to reduce the cross-border transmission capacity between Region A and Region B, we’d run:

n.links.loc["A-B", "p_nom"] = 400
n.links
bus0 bus1 type carrier efficiency active build_year lifetime p_nom p_nom_mod ... shut_down_cost min_up_time min_down_time up_time_before down_time_before ramp_limit_up ramp_limit_down ramp_limit_start_up ramp_limit_shut_down p_nom_opt
Link
A-B A B AC 1.0 True 0 inf 400.0 0.0 ... 0.0 0 0 1 0 NaN NaN 1.0 1.0 500.0

1 rows × 34 columns

n.optimize(solver_name="highs")

Hide code cell output

WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['A-B'], dtype='object', name='Link')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['A', 'B'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 6 primals, 14 duals
Objective: 1.40e+06
Solver model: available
Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
Running HiGHS 1.11.0 (git hash: 364c83a): Copyright (c) 2025 HiGHS under MIT licence terms
LP   linopy-problem-17h2eayz has 14 rows; 6 cols; 19 nonzeros
Coefficient ranges:
  Matrix [1e+00, 1e+00]
  Cost   [2e+01, 2e+02]
  Bound  [0e+00, 0e+00]
  RHS    [4e+02, 4e+04]
Presolving model
1 rows, 2 cols, 2 nonzeros  0s
0 rows, 0 cols, 0 nonzeros  0s
Presolve : Reductions: rows 0(-14); columns 0(-6); elements 0(-19) - Reduced to empty
Solving the original LP from the solution after postsolve
Model name          : linopy-problem-17h2eayz
Model status        : Optimal
Objective value     :  1.3986326317e+06
P-D objective error :  1.6647013314e-16
HiGHS run time      :          0.00
Writing the solution to /tmp/linopy-solve-40u0zpa5.sol
('ok', 'optimal')

You can see that the production of the hydro power plant was reduced and that of the gas power plant increased owing to the reduced transmission capacity.

n.generators_t.p
Generator B hydro A coal A wind A gas A oil
snapshot
now 1050.0 35000.0 3000.0 1600.0 2000.0

Exploring the optimization model#

n.model
Linopy LP model
===============

Variables:
----------
 * Generator-p (snapshot, Generator)
 * Link-p (snapshot, Link)

Constraints:
------------
 * Generator-fix-p-lower (snapshot, Generator-fix)
 * Generator-fix-p-upper (snapshot, Generator-fix)
 * Link-fix-p-lower (snapshot, Link-fix)
 * Link-fix-p-upper (snapshot, Link-fix)
 * Bus-nodal_balance (Bus, snapshot)

Status:
-------
ok
n.model.constraints["Generator-fix-p-upper"]
Constraint `Generator-fix-p-upper` [snapshot: 1, Generator-fix: 5]:
-------------------------------------------------------------------
[now, B hydro]: +1 Generator-p[now, B hydro] ≤ 1200.0
[now, A coal]: +1 Generator-p[now, A coal]   ≤ 35000.0
[now, A wind]: +1 Generator-p[now, A wind]   ≤ 3000.0
[now, A gas]: +1 Generator-p[now, A gas]     ≤ 8000.0
[now, A oil]: +1 Generator-p[now, A oil]     ≤ 2000.0
n.model.constraints["Bus-nodal_balance"]
Constraint `Bus-nodal_balance` [Bus: 2, snapshot: 1]:
-----------------------------------------------------
[A, now]: +1 Generator-p[now, A coal] + 1 Generator-p[now, A wind] + 1 Generator-p[now, A gas] + 1 Generator-p[now, A oil] - 1 Link-p[now, A-B] = 42000.0
[B, now]: +1 Generator-p[now, B hydro] + 1 Link-p[now, A-B]                                                                                     = 650.0
n.model.objective
Objective:
----------
LinearExpression: +24.24 Generator-p[now, A coal] + 172.4 Generator-p[now, A gas] + 137.1 Generator-p[now, A oil]
Sense: min
Value: 1398632.6317348

Exercises#

Task 1: Model an outage of the transmission line by setting it’s capacity to zero. How does the model compensate for the lack of transmission?

Hide code cell content

n.lines["s_nom"] = 0.0
n.optimize(log_to_console=False)
n.generators_t.p
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['A-B'], dtype='object', name='Link')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['A', 'B'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options:
 - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 6 primals, 14 duals
Objective: 1.40e+06
Solver model: available
Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
Generator B hydro A coal A wind A gas A oil
snapshot
now 1050.0 35000.0 3000.0 1600.0 2000.0

Task 2: Double the wind capacity. How is the electricity price affected? What generator is price-setting?

Hide code cell content

n.generators.loc["A wind", "p_nom"] *= 2
n.optimize(log_to_console=False)
n.buses_t.marginal_price
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['A-B'], dtype='object', name='Link')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['A', 'B'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options:
 - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 6 primals, 14 duals
Objective: 9.31e+05
Solver model: available
Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
Bus A B
snapshot
now 137.142857 -0.0

Task 3: Region A brings a new 1000 MW nuclear power plant into operation with an estimated marginal electricity generation cost of 20 €/MWh. Will that affect the electricity price?

Hide code cell content

n.add(
    "Generator",
    "A nuclear",
    bus="A",
    carrier="nuclear",
    p_nom=1000,
    marginal_cost=20,
)
n.optimize(log_to_console=False)
n.buses_t.marginal_price
WARNING:pypsa.consistency:The following links have carriers which are not defined:
Index(['A-B'], dtype='object', name='Link')
WARNING:pypsa.consistency:The following generators have carriers which are not defined:
Index(['A nuclear'], dtype='object', name='Generator')
WARNING:pypsa.consistency:The following buses have carriers which are not defined:
Index(['A', 'B'], dtype='object', name='Bus')
INFO:linopy.model: Solve problem using Highs solver
INFO:linopy.model:Solver options:
 - log_to_console: False
INFO:linopy.io: Writing time: 0.02s
INFO:linopy.constants: Optimization successful: 
Status: ok
Termination condition: optimal
Solution: 7 primals, 16 duals
Objective: 8.59e+05
Solver model: available
Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Link-fix-p-lower, Link-fix-p-upper were not assigned to the network.
Bus A B
snapshot
now 24.242424 -0.0