Marginal Costs#

This tutorial covers marginal cost modeling and locational marginal prices (LMPs) in PyPSA-GB.

What You’ll Learn#

  • Understanding marginal costs

  • Locational marginal prices (LMPs)

  • Price formation in the LOPF

  • Network congestion and price spreads

  • Generator revenue analysis

1. Setup#

[1]:
import pypsa
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import warnings

warnings.filterwarnings('ignore')
plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams['figure.figsize'] = [12, 6]
plt.rcParams['figure.dpi'] = 100

colors = {
    'CCGT': '#FF6B35', 'nuclear': '#E91E63', 'coal': '#424242',
    'wind_onshore': '#3B6182', 'wind_offshore': '#6BAED6', 'solar_pv': '#FFBB00',
    'biomass': '#4CAF50', 'OCGT': '#FF9800'
}

print(f"PyPSA version: {pypsa.__version__}")
PyPSA version: 1.0.7

2. Understanding Marginal Costs#

What is Marginal Cost?#

The marginal cost is the cost of producing one additional MWh of electricity. It includes:

  • Fuel costs

  • Carbon costs (CO₂ price × emission factor / efficiency)

  • Variable O&M costs

For renewables, marginal cost is typically zero (no fuel cost).

3. Load a Solved Network#

[2]:
# Load network
n = pypsa.Network("../../../resources/network/EE50_clustered_solved.nc")

print(f"Network loaded")
print(f"  Buses: {len(n.buses)}")
print(f"  Generators: {len(n.generators)}")
print(f"  Snapshots: {len(n.snapshots)}")
INFO:pypsa.network.io:Imported network 'EE50_clustered (Clustered)' has buses, carriers, generators, lines, links, loads, storage_units, stores, sub_networks
Network loaded
  Buses: 110
  Generators: 5235
  Snapshots: 168

4. Generator Marginal Costs#

[3]:
# Marginal costs by technology
mc_by_carrier = n.generators.groupby('carrier')['marginal_cost'].mean().sort_values()

# Filter out very high values (load shedding)
mc_by_carrier = mc_by_carrier[mc_by_carrier < 1000]

print("Average Marginal Cost by Technology (£/MWh):")
for carrier, mc in mc_by_carrier.items():
    print(f"  {carrier}: £{mc:.2f}")
Average Marginal Cost by Technology (£/MWh):
  CHP: £0.00
  marine: £0.00
  gas_engine: £0.00
  wind_onshore: £1.44
  wind_offshore: £1.44
  solar_pv: £4.58
  large_hydro: £7.04
  geothermal: £12.46
  nuclear: £18.04
  EU_import: £25.80
  waste_to_energy: £75.60
  landfill_gas: £94.80
  sewage_gas: £103.80
  biomass: £110.40
  advanced_biofuel: £119.40
  biogas: £121.80
  OCGT: £152.96
  CCGT: £152.96
  oil: £240.61
[4]:
# Marginal cost bar chart
fig, ax = plt.subplots(figsize=(10, 6))

carrier_colors = [colors.get(c, '#888888') for c in mc_by_carrier.index]
mc_by_carrier.plot(kind='barh', ax=ax, color=carrier_colors, edgecolor='black')

ax.set_xlabel('Marginal Cost (£/MWh)')
ax.set_ylabel('Technology')
ax.set_title('Generator Marginal Costs')

plt.tight_layout()
plt.show()
../_images/tutorials_8-marginal-costs_8_0.png

4.1 Marginal Cost Components#

[5]:
# Illustrative marginal cost breakdown
# Actual values depend on fuel prices and carbon price

cost_breakdown = pd.DataFrame({
    'Technology': ['CCGT', 'Coal', 'OCGT', 'Nuclear', 'Biomass', 'Wind', 'Solar'],
    'Fuel (£/MWh)': [30, 15, 45, 5, 50, 0, 0],
    'Carbon (£/MWh)': [15, 40, 22, 0, 0, 0, 0],
    'VOM (£/MWh)': [3, 5, 5, 7, 5, 3, 2]
})

cost_breakdown['Total'] = cost_breakdown['Fuel (£/MWh)'] + cost_breakdown['Carbon (£/MWh)'] + cost_breakdown['VOM (£/MWh)']
cost_breakdown = cost_breakdown.set_index('Technology')

print("Illustrative Marginal Cost Breakdown (£/MWh):")
cost_breakdown
Illustrative Marginal Cost Breakdown (£/MWh):
Illustrative Marginal Cost Breakdown (£/MWh):
[5]:
Fuel (£/MWh) Carbon (£/MWh) VOM (£/MWh) Total
Technology
CCGT 30 15 3 48
Coal 15 40 5 60
OCGT 45 22 5 72
Nuclear 5 0 7 12
Biomass 50 0 5 55
Wind 0 0 3 3
Solar 0 0 2 2
[6]:
# Stacked bar chart
fig, ax = plt.subplots(figsize=(12, 6))

cost_breakdown[['Fuel (£/MWh)', 'Carbon (£/MWh)', 'VOM (£/MWh)']].plot(
    kind='bar', stacked=True, ax=ax,
    color=['#3498db', '#e74c3c', '#2ecc71']
)

ax.set_ylabel('Cost (£/MWh)')
ax.set_xlabel('Technology')
ax.set_title('Marginal Cost Breakdown')
ax.tick_params(axis='x', rotation=45)
ax.legend(title='Component')

plt.tight_layout()
plt.show()
../_images/tutorials_8-marginal-costs_11_0.png

5. Locational Marginal Prices (LMPs)#

What are LMPs?#

LMPs represent the cost of serving one additional MW of demand at each bus. They are:

  • The shadow price (dual variable) from the power balance constraint

  • Equal to the marginal generator’s cost when there’s no congestion

  • Different across buses when transmission lines are constrained

[7]:
# Check if marginal prices are available
if 'marginal_price' in n.buses_t:
    lmps = n.buses_t.marginal_price

    print("LMP Statistics (£/MWh):")
    print(f"  Mean: £{lmps.mean().mean():.2f}")
    print(f"  Min: £{lmps.min().min():.2f}")
    print(f"  Max: £{lmps.max().max():.2f}")
    print(f"  Std Dev (across buses): £{lmps.std().mean():.2f}")
else:
    print("Marginal prices not available - solve with keep_shadowprices=True")
    lmps = None
LMP Statistics (£/MWh):
  Mean: £15.25
  Min: £-105.08
  Max: £165.93
  Std Dev (across buses): £11.11
[8]:
# System average price time series
if lmps is not None:
    system_price = lmps.mean(axis=1)

    fig, ax = plt.subplots(figsize=(14, 5))

    ax.plot(system_price.index, system_price.values, linewidth=1.5, color='blue')
    ax.fill_between(system_price.index, system_price.values, alpha=0.3)

    ax.axhline(y=system_price.mean(), color='red', linestyle='--',
              label=f'Mean: £{system_price.mean():.2f}')

    ax.set_ylabel('Marginal Price (£/MWh)')
    ax.set_xlabel('Time')
    ax.set_title('System Marginal Price Over Time')
    ax.legend()

    plt.tight_layout()
    plt.show()
../_images/tutorials_8-marginal-costs_14_0.png

5.1 LMP Variability Across Buses#

[9]:
# Average LMP by bus
if lmps is not None:
    avg_lmp = lmps.mean().sort_values(ascending=False)

    print("Top 10 Highest Average LMP Buses:")
    print(avg_lmp.head(10).round(2).to_string())

    print(f"\nTop 10 Lowest Average LMP Buses:")
    print(avg_lmp.tail(10).round(2).to_string())
Top 10 Highest Average LMP Buses:
name
cluster_66    53.67
cluster_11    40.16
cluster_27    38.16
cluster_13    38.09
cluster_35    37.15
cluster_93    35.31
cluster_45    33.83
cluster_50    33.83
cluster_65    33.22
cluster_99    31.51

Top 10 Lowest Average LMP Buses:
name
cluster_26     2.49
cluster_71     2.45
cluster_2      2.25
cluster_7      1.88
cluster_73     1.88
cluster_48     1.16
cluster_6      0.92
cluster_62     0.61
cluster_49     0.57
cluster_90   -30.55
[10]:
# Plot LMPs for selected buses
if lmps is not None and len(lmps.columns) > 3:
    fig, ax = plt.subplots(figsize=(14, 5))

    # Select a few representative buses
    sample_buses = [avg_lmp.idxmax(), avg_lmp.idxmin()] + list(avg_lmp.sample(3).index)

    for bus in sample_buses[:5]:
        ax.plot(lmps.index, lmps[bus], linewidth=1.5, label=bus, alpha=0.7)

    ax.set_ylabel('LMP (£/MWh)')
    ax.set_xlabel('Time')
    ax.set_title('Locational Marginal Prices at Selected Buses')
    ax.legend(loc='upper right')

    plt.tight_layout()
    plt.show()
../_images/tutorials_8-marginal-costs_17_0.png

5.2 Price Spread (Congestion Indicator)#

[11]:
# Price spread between buses
if lmps is not None:
    price_spread = lmps.max(axis=1) - lmps.min(axis=1)

    fig, ax = plt.subplots(figsize=(14, 5))

    ax.fill_between(price_spread.index, price_spread.values, alpha=0.5, color='orange')
    ax.plot(price_spread.index, price_spread.values, color='darkorange', linewidth=1)

    ax.set_ylabel('Price Spread (£/MWh)')
    ax.set_xlabel('Time')
    ax.set_title('LMP Spread (Max - Min across buses) - Congestion Indicator')

    plt.tight_layout()
    plt.show()

    print(f"Price Spread Statistics:")
    print(f"  Mean: £{price_spread.mean():.2f}")
    print(f"  Max: £{price_spread.max():.2f}")
    print(f"  Hours with spread > £10: {(price_spread > 10).sum()}")
../_images/tutorials_8-marginal-costs_19_0.png
Price Spread Statistics:
  Mean: £84.60
  Max: £266.02
  Hours with spread > £10: 151

6. Price Duration Curve#

[12]:
if lmps is not None:
    fig, ax = plt.subplots(figsize=(12, 6))

    sorted_prices = system_price.sort_values(ascending=False).values
    hours = np.arange(1, len(sorted_prices) + 1)
    hours_pct = hours / len(hours) * 100

    ax.plot(hours_pct, sorted_prices, linewidth=2, color='blue')
    ax.fill_between(hours_pct, sorted_prices, alpha=0.3)

    # Reference lines
    ax.axhline(y=system_price.mean(), color='red', linestyle='--',
              label=f'Mean: £{system_price.mean():.0f}')
    ax.axhline(y=system_price.median(), color='green', linestyle=':',
              label=f'Median: £{system_price.median():.0f}')

    ax.set_xlabel('% of Time')
    ax.set_ylabel('Marginal Price (£/MWh)')
    ax.set_title('Price Duration Curve')
    ax.legend()

    plt.tight_layout()
    plt.show()
../_images/tutorials_8-marginal-costs_21_0.png

7. Price vs Generation Mix#

[13]:
# Calculate renewable share
generation = n.generators_t.p.groupby(n.generators.carrier, axis=1).sum()
renewable_carriers = ['wind_onshore', 'wind_offshore', 'solar_pv']
renewable_gen = generation[[c for c in renewable_carriers if c in generation.columns]].sum(axis=1)
total_gen = generation.sum(axis=1)
renewable_share = renewable_gen / total_gen * 100

if lmps is not None:
    fig, ax = plt.subplots(figsize=(10, 6))

    scatter = ax.scatter(renewable_share, system_price, alpha=0.5, c='blue', s=30)

    # Trend line
    z = np.polyfit(renewable_share, system_price, 1)
    p = np.poly1d(z)
    x_sorted = renewable_share.sort_values()
    ax.plot(x_sorted, p(x_sorted), 'r--', linewidth=2, label='Trend')

    ax.set_xlabel('Renewable Share (%)')
    ax.set_ylabel('Marginal Price (£/MWh)')
    ax.set_title('Price vs Renewable Generation Share')
    ax.legend()

    # Correlation
    corr = renewable_share.corr(system_price)
    print(f"Correlation: {corr:.3f}")

    plt.tight_layout()
    plt.show()
Correlation: -0.847
Correlation: -0.847
../_images/tutorials_8-marginal-costs_23_1.png

8. Generator Revenue Analysis#

[14]:
# Calculate generator revenue
if lmps is not None:
    # Revenue = generation × LMP at bus
    revenue = pd.DataFrame()

    for gen in n.generators.index:
        bus = n.generators.loc[gen, 'bus']
        if bus in lmps.columns and gen in n.generators_t.p.columns:
            gen_output = n.generators_t.p[gen]
            gen_lmp = lmps[bus]
            revenue[gen] = gen_output * gen_lmp

    # Total revenue by carrier
    revenue_by_carrier = revenue.sum().groupby(n.generators.carrier).sum() / 1e6  # £M

    print("Total Revenue by Technology (£M):")
    print(revenue_by_carrier.sort_values(ascending=False).round(2).to_string())
Total Revenue by Technology (£M):
carrier
wind_offshore       85.48
wind_onshore        30.42
nuclear             26.51
solar_pv            26.16
EU_import           18.47
gas_engine           1.75
large_hydro          0.87
marine               0.70
waste_to_energy      0.51
advanced_biofuel     0.07
landfill_gas         0.06
sewage_gas           0.01
CHP                  0.00
biogas               0.00
CCGT                 0.00
biomass              0.00
OCGT                 0.00
load_shedding        0.00
geothermal           0.00
oil                  0.00
[15]:
# Revenue per MWh (average price received)
if lmps is not None:
    gen_by_carrier = n.generators_t.p.groupby(n.generators.carrier, axis=1).sum().sum()
    revenue_total = revenue.sum().groupby(n.generators.carrier).sum()

    avg_price_received = revenue_total / gen_by_carrier
    avg_price_received = avg_price_received.dropna().sort_values(ascending=False)

    print("Average Price Received (£/MWh):")
    for carrier, price in avg_price_received.items():
        print(f"  {carrier}: £{price:.2f}")

    print(f"\nSystem average price: £{system_price.mean():.2f}")
Average Price Received (£/MWh):
  advanced_biofuel: £118.61
  sewage_gas: £101.92
  landfill_gas: £98.37
  waste_to_energy: £87.78
  nuclear: £26.28
  CCGT: £25.29
  large_hydro: £19.96
  EU_import: £18.78
  load_shedding: £16.98
  solar_pv: £16.49
  OCGT: £14.47
  gas_engine: £13.89
  wind_onshore: £13.64
  biogas: £12.04
  wind_offshore: £10.66
  biomass: £10.63
  CHP: £2.53
  geothermal: £2.48
  oil: £2.45
  marine: £2.20

System average price: £15.25