Quickstart 1 - Markets¶
Problem Description¶
Consider the following simple electricity market with two zones, each with a generator and a load. The demand in zone 1 is 500 MW and in zone 2 is 1500 MW. The generators have a nominal capacity of 2000 MW each, with cost functions defined as follows:
- Zone 1: C1(g1) = 10 g1 + 0.005 g1²
- Zone 2: C2(g2) = 13 g2 + 0.01 g2²
Find the least-cost dispatch of the generators to meet the load while respecting the transmission capacity. Identify the marginal prices at each bus and the flow on the transmission line. Calculate the congestion rent.
PyPSA Solution¶
For installation instructions, consult the Installation section first.
The first step is always to import the pypsa module:
import pypsa
A new PyPSA network instance can be created with the pypsa.Network constructor.
n = pypsa.Network()
Components like buses can be added with n.add() and registered under an arbitrary name, e.g. "zone_1":
n.add("Bus", "zone_1")
n.add("Bus", "zone_2")
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 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||
| zone_1 | 1.0 | 0.0 | 0.0 | AC | 1.0 | 0.0 | inf | PQ | |||||
| zone_2 | 1.0 | 0.0 | 0.0 | AC | 1.0 | 0.0 | inf | PQ |
Next, add the loads to the network, where p_set specifies the power demand in MW at the corresponding bus:
n.add("Load", "load_1", bus="zone_1", p_set=500)
n.add("Load", "load_2", bus="zone_2", p_set=1500)
n.loads
| bus | carrier | type | p_set | q_set | sign | active | |
|---|---|---|---|---|---|---|---|
| name | |||||||
| load_1 | zone_1 | 500.0 | 0.0 | -1.0 | True | ||
| load_2 | zone_2 | 1500.0 | 0.0 | -1.0 | True |
Generators are added in a similar way, where p_nom specifies the nominal capacity, and marginal_cost and marginal_cost_quadratic specify the linear and quadratic coefficients of the cost function:
n.add(
"Generator",
"gen_1",
bus="zone_1",
p_nom=2000,
marginal_cost=10,
marginal_cost_quadratic=0.005,
)
n.add(
"Generator",
"gen_2",
bus="zone_2",
p_nom=2000,
marginal_cost=13,
marginal_cost_quadratic=0.01,
)
n.generators
| bus | control | type | p_nom | p_nom_mod | p_nom_extendable | p_nom_min | p_nom_max | p_nom_set | p_min_pu | ... | 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 | weight | p_nom_opt | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||||||||||
| gen_1 | zone_1 | PQ | 2000.0 | 0.0 | False | 0.0 | inf | NaN | 0.0 | ... | 0 | 0 | 1 | 0 | NaN | NaN | 1.0 | 1.0 | 1.0 | 0.0 | |
| gen_2 | zone_2 | PQ | 2000.0 | 0.0 | False | 0.0 | inf | NaN | 0.0 | ... | 0 | 0 | 1 | 0 | NaN | NaN | 1.0 | 1.0 | 1.0 | 0.0 |
2 rows × 38 columns
Lines connecting two buses bus0 and bus1 are added with a nominal capacity s_nom in MW and a reactance x in Ohm (which is required for modelling power flow):
n.add("Line", "line_1", bus0="zone_1", bus1="zone_2", x=0.01, s_nom=400)
n.lines
| bus0 | bus1 | type | x | r | g | b | s_nom | s_nom_mod | s_nom_extendable | ... | v_ang_min | v_ang_max | sub_network | x_pu | r_pu | g_pu | b_pu | x_pu_eff | r_pu_eff | s_nom_opt | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | |||||||||||||||||||||
| line_1 | zone_1 | zone_2 | 0.01 | 0.0 | 0.0 | 0.0 | 400.0 | 0.0 | False | ... | -inf | inf | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
1 rows × 32 columns
With all components added, the network can be optimised with the n.optimize() method.
This function constructs the optimisation problem with the linopy library, solves it with a selected solver ("highs" as default), and stores the results in the network instance n.
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(['zone_1', 'zone_2'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['line_1'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow: Index(['line_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 time: 0.02s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 3 primals, 8 duals Objective: 3.94e+04 Solver model: available Solver message: Optimal
INFO:pypsa.optimization.optimize:The shadow-prices of the constraints Generator-fix-p-lower, Generator-fix-p-upper, Line-fix-s-lower, Line-fix-s-upper were not assigned to the network.
('ok', 'optimal')
The optimised generators dispatch can be accessed with:
n.generators_t.p
| name | gen_1 | gen_2 |
|---|---|---|
| snapshot | ||
| now | 900.0 | 1100.0 |
The market clearing prices per bus can be accessed with:
n.buses_t.marginal_price
| name | zone_1 | zone_2 |
|---|---|---|
| snapshot | ||
| now | 19.00009 | 35.00011 |
The optimised flows on transmission lines can be accessed with:
n.lines_t.p1
| name | line_1 |
|---|---|
| snapshot | |
| now | -400.0 |
Here, n.lines_t.p0 denotes flow from bus0 to bus1 if values are positive. The related attribute n.lines_t.p1 denotes flow from bus1 to bus0 if values are positive.
The congestion rent can be calculated as the product of the flow on the line and the price difference between the two buses:
n.buses_t.marginal_price.eval("zone_2 - zone_1") * n.lines_t.p0["line_1"]
snapshot now 6400.008 dtype: float64
This example is based on Tom Brown's Energy Systems course, taken from the lecture on Complex Markets, slides 37ff.