Skip to content

Understanding Negative Locational Marginal Prices

Overview

This tutorial demonstrates how negative locational marginal prices (LMPs) can occur in electricity systems due to transmission line congestion. Using a simple 3-bus DC power flow model, we'll reproduce a scenario where cheap generation is trapped behind a congested line, causing prices to drop below zero.

What you'll learn

  • What locational marginal prices (LMPs) are and why they matter
  • How transmission congestion affects electricity pricing
  • Creating a DC power flow model with pyconvexity
  • Working with transmission lines and their constraints
  • Interpreting negative price signals

Key concept

When cheap generation cannot reach demand due to transmission congestion, the local price where that generation is located can drop below zero. This signals that power has negative value at that location because it cannot be delivered where it's needed.

Prerequisites

bash
pip install pyconvexity

Download Example Files

The Economic Problem

Imagine three locations:

  • Bus1 (Austin): Cheap generator (10 €/MWh)
  • Bus2 (Houston): Medium-cost generator (20 €/MWh)
  • Bus3 (San Antonio): Expensive generator (100 €/MWh) + all the load

The direct line from Bus1 to Bus3 has only 10 MW capacity (bottleneck), while other lines have 100 MW. When load exceeds what can flow through the bottleneck, the cheap generator at Bus1 cannot serve the load. The system must use more expensive generation at Bus2 and Bus3, causing Bus1's price to drop—potentially below zero.

Step 1: Set up the database

python
import pyconvexity as px
import numpy as np
from datetime import datetime, timedelta
import os

# Create database file
db_path = "three_bus_lmp.db"
px.create_database_with_schema(db_path)

Step 2: Create network and time periods

We'll model 24 hours to see how LMPs vary with changing load:

python
with px.database_context(db_path) as conn:
    # Define time range
    start_time = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
    end_time = start_time + timedelta(hours=23)
    
    # Create network
    network_req = px.CreateNetworkRequest(
        name="Three Bus LMP Example",
        description="3-bus system demonstrating negative LMPs due to line congestion",
        start_time=start_time.strftime("%Y-%m-%d %H:%M:%S"),
        end_time=end_time.strftime("%Y-%m-%d %H:%M:%S"),
        time_resolution="PT1H",
    )
    
    px.create_network(conn, network_req)

Step 3: Create carrier and buses

python
    # Create electricity carrier
    carrier_id = px.create_carrier(
        conn,
        name="AC",
        co2_emissions=0.0,
        color="#1f77b4",
        nice_name="Electricity",
    )
    
    # Define bus locations (Texas)
    bus_coords = {
        "Bus1": {"latitude": 30.3, "longitude": -97.7},   # Austin area
        "Bus2": {"latitude": 30.3, "longitude": -95.4},   # Houston area
        "Bus3": {"latitude": 29.4, "longitude": -98.5},   # San Antonio area
    }
    
    # Create buses
    bus_ids = {}
    for bus_name, coords in bus_coords.items():
        bus_id = px.create_component(
            conn,
            component_type="BUS",
            name=bus_name,
            carrier_id=carrier_id,
            latitude=coords["latitude"],
            longitude=coords["longitude"],
        )
        bus_ids[bus_name] = bus_id
        px.set_static_attribute(conn, bus_id, "v_nom", px.StaticValue(400.0))

Step 4: Create generators with different costs

This is where the price differential originates. We create three generators with very different marginal costs:

python
    # Gen1 at Bus1: Cheap generation (10 €/MWh)
    gen1_id = px.create_component(
        conn,
        component_type="GENERATOR",
        name="Gen1",
        bus_id=bus_ids["Bus1"],
        carrier_id=carrier_id,
        latitude=bus_coords["Bus1"]["latitude"] + 0.05,
        longitude=bus_coords["Bus1"]["longitude"] + 0.05,
    )
    px.set_static_attribute(conn, gen1_id, "p_nom", px.StaticValue(100.0))
    px.set_static_attribute(conn, gen1_id, "marginal_cost", px.StaticValue(10.0))
    
    # Gen2 at Bus2: Medium cost (20 €/MWh)
    gen2_id = px.create_component(
        conn,
        component_type="GENERATOR",
        name="Gen2",
        bus_id=bus_ids["Bus2"],
        carrier_id=carrier_id,
        latitude=bus_coords["Bus2"]["latitude"] + 0.05,
        longitude=bus_coords["Bus2"]["longitude"] + 0.05,
    )
    px.set_static_attribute(conn, gen2_id, "p_nom", px.StaticValue(100.0))
    px.set_static_attribute(conn, gen2_id, "marginal_cost", px.StaticValue(20.0))
    
    # Gen3 at Bus3: Expensive generation (100 €/MWh)
    gen3_id = px.create_component(
        conn,
        component_type="GENERATOR",
        name="Gen3",
        bus_id=bus_ids["Bus3"],
        carrier_id=carrier_id,
        latitude=bus_coords["Bus3"]["latitude"] + 0.05,
        longitude=bus_coords["Bus3"]["longitude"] + 0.05,
    )
    px.set_static_attribute(conn, gen3_id, "p_nom", px.StaticValue(100.0))
    px.set_static_attribute(conn, gen3_id, "marginal_cost", px.StaticValue(100.0))

Why these costs matter: In an uncongested system, only Gen1 would run (it's cheapest). But with congestion, expensive generators must run even when cheaper ones have spare capacity.

Step 5: Create load at Bus3

All load is concentrated at Bus3, far from the cheapest generation:

python
    # Create varying load (100 MW base ±20%)
    base_load = 100.0
    load_variation = base_load * (1 + np.random.uniform(-0.2, 0.2, size=24))
    
    load_id = px.create_component(
        conn,
        component_type="LOAD",
        name="Load3",
        bus_id=bus_ids["Bus3"],
        carrier_id=carrier_id,
        latitude=bus_coords["Bus3"]["latitude"] + 0.1,
        longitude=bus_coords["Bus3"]["longitude"] + 0.1,
    )
    
    px.set_timeseries_attribute(conn, load_id, "p_set", load_variation.tolist())

Step 6: Create transmission lines (the key!)

Here's where we create the bottleneck that causes negative LMPs:

python
    # Line12: Bus1 to Bus2, 100 MW capacity
    line12_id = px.create_component(
        conn,
        component_type="LINE",
        name="Line12",
        bus0_id=bus_ids["Bus1"],
        bus1_id=bus_ids["Bus2"],
        carrier_id=carrier_id,
        latitude=(bus_coords["Bus1"]["latitude"] + bus_coords["Bus2"]["latitude"]) / 2.0,
        longitude=(bus_coords["Bus1"]["longitude"] + bus_coords["Bus2"]["longitude"]) / 2.0,
    )
    px.set_static_attribute(conn, line12_id, "x", px.StaticValue(1.0))  # Reactance
    px.set_static_attribute(conn, line12_id, "s_nom", px.StaticValue(100.0))
    
    # Line23: Bus2 to Bus3, 100 MW capacity
    line23_id = px.create_component(
        conn,
        component_type="LINE",
        name="Line23",
        bus0_id=bus_ids["Bus2"],
        bus1_id=bus_ids["Bus3"],
        carrier_id=carrier_id,
        latitude=(bus_coords["Bus2"]["latitude"] + bus_coords["Bus3"]["latitude"]) / 2.0,
        longitude=(bus_coords["Bus2"]["longitude"] + bus_coords["Bus3"]["longitude"]) / 2.0,
    )
    px.set_static_attribute(conn, line23_id, "x", px.StaticValue(1.0))
    px.set_static_attribute(conn, line23_id, "s_nom", px.StaticValue(100.0))
    
    # Line13: Bus1 to Bus3, only 10 MW capacity (BOTTLENECK!)
    line13_id = px.create_component(
        conn,
        component_type="LINE",
        name="Line13",
        bus0_id=bus_ids["Bus1"],
        bus1_id=bus_ids["Bus3"],
        carrier_id=carrier_id,
        latitude=(bus_coords["Bus1"]["latitude"] + bus_coords["Bus3"]["latitude"]) / 2.0,
        longitude=(bus_coords["Bus1"]["longitude"] + bus_coords["Bus3"]["longitude"]) / 2.0,
    )
    px.set_static_attribute(conn, line13_id, "x", px.StaticValue(1.0))
    px.set_static_attribute(conn, line13_id, "s_nom", px.StaticValue(10.0))  # Only 10 MW!
    
    conn.commit()
    print("Model created successfully!")

Critical detail: Line13 (direct path to load) has only 10 MW capacity while load is ~100 MW. This forces power to flow through the longer path (Bus1→Bus2→Bus3) or use local expensive generation.

Step 7: Solve and analyze

python
# Solve the network
print("Solving network...")
result = px.solve_network(
    db_path=db_path,
    solver_name="highs",
    progress_callback=lambda progress, msg: print(f"  {progress}%: {msg}"),
)

print("Optimization complete!")
print(f"Success: {result.get('success', False)}")
print(f"Objective: {result.get('objective_value', 'N/A')}")

Understanding the Results

What happens during optimization:

  1. Without congestion: Gen1 (10 €/MWh) would serve all load directly
  2. With congestion: Only 10 MW can flow directly from Bus1 to Bus3
  3. System response:
    • Gen1 runs at full capacity but can't deliver all power to Bus3
    • Power flows indirectly: Bus1 → Bus2 → Bus3
    • Gen2 and Gen3 must also run to meet demand
  4. Price formation:
    • Bus3 (load): High price (~100 €/MWh) reflecting expensive local generation
    • Bus2: Medium price (~20-100 €/MWh)
    • Bus1: Negative price (cheaper than everywhere else, but trapped)

Why negative? At Bus1, there's excess cheap generation capacity that cannot reach demand. The marginal value of an additional MW at Bus1 is negative because it would require curtailing even cheaper generation or cannot be used at all.

Export to Excel

Export the model and results to Excel for further analysis:

python
from pyconvexity.io.excel_exporter import ExcelModelExporter

xlsx_path = db_path.replace('.db', '.xlsx')
exporter = ExcelModelExporter()
exporter.export_model_to_excel(db_path=db_path, output_path=xlsx_path)

The Excel file includes all component data, timeseries results, and solve statistics.

Key Takeaway

This example demonstrates a fundamental challenge in electricity markets: transmission constraints can prevent economic dispatch, leading to price separation and even negative locational prices. This is why transmission expansion is often as important as generation capacity in reducing system costs.

© Copyright 2025 Bayesian Energy