Negative LMPs from Line Congestion¶
This notebook demonstrates how negative locational marginal prices (LMPs) can occur in electricity systems due to line congestion. Using a simple 3-bus linearised DC power flow model, we reproduce the phenomenon implemented in Kyri Baker's "3bus_LMP" example. When cheap generation is trapped behind a congested line, LMPs can drop below zero as the system redistributes power flows to meet demand. This behaviour is a direct result of the duality of the DC-OPF (DC Optimal Power Flow) problem, where LMPs emerge as the shadow prices of nodal power balance. With this example, we illustrate how network topology, generator costs, and constraints jointly shape prices in modern electricity markets.
Model setup¶
First, define the example electricity system in PyPSA.
import pypsa
n = pypsa.Network()
We create three buses with generators of marginal costs 10 €/MWh (Bus1), 20 €/MWh (Bus2), and 100 €/MWh (Bus3). We connect all buses with their neighbours. All lines have the same reactance of x=1. The line connecting Bus1 and Bus3 is bottlenecked at a maximum capacity of 10 MW. A single load of 100 MW is connected to Bus3.
# Add three buses in a triangular layout
n.add("Bus", "Bus1", x=0, y=2) # Top-left
n.add("Bus", "Bus2", x=2, y=2) # Top-right
n.add("Bus", "Bus3", x=1, y=0); # Bottom (load)
# Add generators
n.add("Generator", "Gen1", bus="Bus1", p_nom=100, marginal_cost=10)
n.add("Generator", "Gen2", bus="Bus2", p_nom=100, marginal_cost=20)
n.add("Generator", "Gen3", bus="Bus3", p_nom=100, marginal_cost=100);
# Add a load of 100 MW at Bus2
n.add("Load", "Load3", bus="Bus3", p_set=100);
# Add three lines
n.add("Line", "Line12", bus0="Bus1", bus1="Bus2", x=1, s_nom=100)
n.add("Line", "Line23", bus0="Bus2", bus1="Bus3", x=1, s_nom=100)
n.add("Line", "Line13", bus0="Bus1", bus1="Bus3", x=1, s_nom=10);
Part 1: Negative LMPs in the DC-OPF solution¶
We solve the above network for a single timestep ("now"), representing one hour. As we have defined the components as Line components, Kirchhoff voltage law (KVL) applies. As no investments are allowed, this operational model is equivalent to a DC-OPF formulation.
n.optimize()
/tmp/ipykernel_7604/1261279110.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['Bus1', 'Bus2', 'Bus3'], 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(['Line12', 'Line23', 'Line13'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow: Index(['Line12', 'Line23', 'Line13'], 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.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 6 primals, 16 duals Objective: 7.60e+03 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, Kirchhoff-Voltage-Law were not assigned to the network.
('ok', 'optimal')
Solving the model yields an optimal solution with an objective value of 7600 €.
print(f"Objective value: {n.objective} €")
Objective value: 7600.0 €
We find that in the optimal solution, Gen2 and Gen3 provide 30 and 70 MW to serve the load at Bus3 respectively. Due to the KVL constraints, Gen1 is not dispatched at all, although being the cheapest, as the line connecting Bus1 and Bus3 is congested.
n.generators_t.p
| name | Gen1 | Gen2 | Gen3 |
|---|---|---|---|
| snapshot | |||
| now | -0.0 | 30.0 | 70.0 |
Given that all lines have equal reactances, two-thirds of Gen2's dispatch flow across Line23 and the remaining third flows across Line13 and Line12. Accordingly, Line13 carries 10 MW and is congested. Note that the Line12 is defined as from Bus1 to Bus2, hence the injection p0 at Bus2 is negative.
n.lines_t.p0
| name | Line12 | Line23 | Line13 |
|---|---|---|---|
| snapshot | |||
| now | -10.0 | 20.0 | 10.0 |
Looking at the marginal prices, we see that the LMP at Bus1 is negative: -60 €/MWh. As the LMP is the dual variable to the nodal balance constraint, this means we can improve (or reduce) the objective value by relieving the demand at Bus1 by 1 MW (see Part 2).
n.buses_t.marginal_price
| name | Bus1 | Bus2 | Bus3 |
|---|---|---|---|
| snapshot | |||
| now | -60.0 | 20.0 | 100.0 |
Part 2: Relieving line congestion¶
To see what happens when we relieve the nodal balance at Bus1, we attach a load of 1 MW.
n.add("Load", "Load1", bus="Bus1", p_set=1);
... and resolve.
n.optimize()
/tmp/ipykernel_7604/1261279110.py:1: FutureWarning: The default value of `include_objective_constant` will change from True to False in version 2.0. Set `include_objective_constant` explicitly to suppress this warning. Using False improves LP numerical conditioning by not including the objective constant as a variable. n.optimize() WARNING:pypsa.consistency:The following buses have carriers which are not defined. Run n.sanitize() to add them. Components with undefined carriers: Index(['Bus1', 'Bus2', 'Bus3'], 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(['Line12', 'Line23', 'Line13'], dtype='object', name='name')
WARNING:pypsa.consistency:The following lines have zero r, which could break the linear load flow: Index(['Line12', 'Line23', 'Line13'], 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'], 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.03s
INFO:linopy.constants: Optimization successful: Status: ok Termination condition: optimal Solution: 6 primals, 16 duals Objective: 7.54e+03 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, Kirchhoff-Voltage-Law were not assigned to the network.
('ok', 'optimal')
print(f"Objective value: {n.objective} €")
Objective value: 7540.0 €
n.generators_t.p
| name | Gen1 | Gen2 | Gen3 |
|---|---|---|---|
| snapshot | |||
| now | -0.0 | 32.0 | 69.0 |
n.lines_t.p0
| name | Line12 | Line23 | Line13 |
|---|---|---|---|
| snapshot | |||
| now | -11.0 | 21.0 | 10.0 |
So what has happened? By attaching a 1 MW load at bus 1, line congestion is relieved: An additional off-take of 1 MW at bus 1 reduces the net flow from Bus1 to Bus3. Assuming Load3 to remain unchanged, this enables an injection of 2 MW at Bus1 (coming from Gen2), with 1 MW consumed and 1 MW flowing from Bus1 to Bus3. Gen2 essentially increases its dispatch by 2 MW (20 €/MWh x 2 MW x 1h = 40 €). Line13 is still fully utilised at 10 MW. Being the most expensive option, Gen3 decreases its output by 1 MW (100 €/MWh x (-1 MW) x 1h = -100 €). This combined effect creates a net reduction in total system costs of -100 € + 40 € = - 60 €.
Part 3: Plotting the regional dispatch and flows¶
In the following, we plot the nodal generation, LMPs, line flows and line loadings on a map.
bus_size = (
n.statistics.supply(groupby="bus", components=["Generator", "Load"])
.groupby("bus")
.sum()
)
line_flows = n.lines_t.p0.iloc[0]
bus_color = n.buses_t.marginal_price.iloc[0]
line_loading = n.lines_t.p0.iloc[0] / n.lines.s_nom
n.plot.map(
bus_size=bus_size / 8000,
line_width=line_flows / 5,
line_flow=line_flows / 30,
bus_color=bus_color,
line_color=line_loading,
);
References¶
- Kyri Baker (2023). 3bus_LMPs. GitHub repository. https://github.com/kyribaker/3bus_LMPs
- Kyri Baker & Harsha Gangammanavar (2024). Locational marginal prices obey DC circuit laws. arXiv preprint arXiv:2403.19032.
https://doi.org/10.48550/arXiv.2403.19032