Storage Systems#

This tutorial covers electricity storage modeling in PyPSA-GB, including batteries and pumped hydro.

What You’ll Learn#

  • Storage capacity and parameters

  • State of charge dynamics

  • Charging and discharging patterns

  • Storage arbitrage and value

  • Comparing storage technologies

1. Setup#

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

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

colors = {
    'battery': '#9C27B0', 'pumped_hydro': '#3F51B5', 'storage': '#673AB7',
    'charging': '#E91E63', 'discharging': '#4CAF50'
}

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

2. Load Network#

[2]:
# Load a network with storage
n = pypsa.Network("../../../resources/network/HT35_clustered_solved.nc")

print(f"Network loaded")
print(f"  Snapshots: {len(n.snapshots)}")
print(f"  Storage Units: {len(n.storage_units)}")
INFO:pypsa.network.io:Imported network 'HT35_clustered (Clustered)' has buses, carriers, generators, lines, links, loads, storage_units, stores, sub_networks
Network loaded
  Snapshots: 168
  Storage Units: 784

3. Storage Capacity Overview#

[3]:
# Storage capacity summary
storage = n.storage_units.copy()

print(f"Total storage units: {len(storage)}")

# Capacity by carrier
capacity = storage.groupby('carrier').agg({
    'p_nom': 'sum',       # Power capacity (MW)
    'max_hours': 'mean'   # Average duration (hours)
})

capacity['p_nom_GW'] = capacity['p_nom'] / 1000
capacity['energy_GWh'] = capacity['p_nom_GW'] * capacity['max_hours']

print("\nStorage Capacity:")
for carrier in capacity.index:
    print(f"\n{carrier}:")
    print(f"  Power: {capacity.loc[carrier, 'p_nom_GW']:.2f} GW")
    print(f"  Duration: {capacity.loc[carrier, 'max_hours']:.1f} hours")
    print(f"  Energy: {capacity.loc[carrier, 'energy_GWh']:.2f} GWh")
Total storage units: 784

Storage Capacity:

Battery:
  Power: 26.77 GW
  Duration: 2.0 hours
  Energy: 53.54 GWh

Domestic Battery:
  Power: 1.99 GW
  Duration: 2.0 hours
  Energy: 3.98 GWh

LAES:
  Power: 3.85 GW
  Duration: 6.0 hours
  Energy: 23.11 GWh

Pumped Storage Hydroelectricity:
  Power: 6.49 GW
  Duration: 8.0 hours
  Energy: 51.88 GWh
[4]:
# Capacity bar chart
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Power capacity
ax1 = axes[0]
bar_colors = [colors.get(c, '#888888') for c in capacity.index]
capacity['p_nom_GW'].plot(kind='bar', ax=ax1, color=bar_colors, edgecolor='black')
ax1.set_ylabel('Power Capacity (GW)')
ax1.set_xlabel('Storage Type')
ax1.set_title('Storage Power Capacity')
ax1.tick_params(axis='x', rotation=45)

# Energy capacity
ax2 = axes[1]
capacity['energy_GWh'].plot(kind='bar', ax=ax2, color=bar_colors, edgecolor='black')
ax2.set_ylabel('Energy Capacity (GWh)')
ax2.set_xlabel('Storage Type')
ax2.set_title('Storage Energy Capacity')
ax2.tick_params(axis='x', rotation=45)

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_7_0.png

4. Storage Parameters#

[5]:
# Key storage parameters
params = storage.groupby('carrier').agg({
    'efficiency_store': 'mean',     # Charging efficiency
    'efficiency_dispatch': 'mean',  # Discharging efficiency
    'standing_loss': 'mean',        # Self-discharge rate
    'marginal_cost': 'mean',        # Dispatch cost
    'cyclic_state_of_charge': 'first'  # Cyclic constraint
})

params['round_trip_efficiency'] = params['efficiency_store'] * params['efficiency_dispatch'] * 100

print("Storage Parameters:")
for carrier in params.index:
    print(f"\n{carrier}:")
    print(f"  Charging Efficiency: {params.loc[carrier, 'efficiency_store']*100:.1f}%")
    print(f"  Discharging Efficiency: {params.loc[carrier, 'efficiency_dispatch']*100:.1f}%")
    print(f"  Round-Trip Efficiency: {params.loc[carrier, 'round_trip_efficiency']:.1f}%")
    print(f"  Standing Loss: {params.loc[carrier, 'standing_loss']*100:.3f}%/hour")
Storage Parameters:

Battery:
  Charging Efficiency: 92.0%
  Discharging Efficiency: 92.0%
  Round-Trip Efficiency: 84.6%
  Standing Loss: 0.100%/hour

Domestic Battery:
  Charging Efficiency: 92.0%
  Discharging Efficiency: 92.0%
  Round-Trip Efficiency: 84.6%
  Standing Loss: 0.100%/hour

LAES:
  Charging Efficiency: 60.0%
  Discharging Efficiency: 60.0%
  Round-Trip Efficiency: 36.0%
  Standing Loss: 0.100%/hour

Pumped Storage Hydroelectricity:
  Charging Efficiency: 87.0%
  Discharging Efficiency: 87.0%
  Round-Trip Efficiency: 75.7%
  Standing Loss: 0.100%/hour

5. State of Charge Analysis#

[6]:
# State of charge time series
soc = n.storage_units_t.state_of_charge

print(f"State of Charge data shape: {soc.shape}")

# Aggregate by carrier
soc_by_carrier = soc.groupby(n.storage_units.carrier, axis=1).sum() / 1000  # GWh

print("\nState of Charge Statistics (GWh):")
print(soc_by_carrier.describe().round(2))
State of Charge data shape: (168, 784)

State of Charge Statistics (GWh):
carrier  Battery  Domestic Battery    LAES  Pumped Storage Hydroelectricity
count     168.00            168.00  168.00                           168.00
mean       23.24              2.11   10.02                            16.64
std        16.46              1.01    8.75                            13.92
min         1.44              0.02    0.06                             0.48
25%         8.67              1.37    2.39                             3.45
50%        16.40              1.99    5.53                            13.75
75%        41.05              3.06   20.57                            26.64
max        50.22              3.93   22.91                            48.46
[7]:
# State of charge time series plot
fig, ax = plt.subplots(figsize=(14, 5))

for col in soc_by_carrier.columns:
    ax.plot(soc_by_carrier.index, soc_by_carrier[col],
            color=colors.get(col, '#888888'), linewidth=1.5, label=col)

ax.set_ylabel('State of Charge (GWh)')
ax.set_xlabel('Time')
ax.set_title('Storage State of Charge Over Time')
ax.legend()

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_12_0.png
[8]:
# SOC percentage (normalized)
fig, ax = plt.subplots(figsize=(14, 5))

for carrier in soc_by_carrier.columns:
    carrier_storage = n.storage_units[n.storage_units.carrier == carrier]
    max_energy = (carrier_storage['p_nom'] * carrier_storage['max_hours']).sum() / 1000
    soc_pct = soc_by_carrier[carrier] / max_energy * 100

    ax.plot(soc_pct.index, soc_pct.values,
            color=colors.get(carrier, '#888888'), linewidth=1.5, label=carrier)

ax.set_ylabel('State of Charge (%)')
ax.set_xlabel('Time')
ax.set_title('Storage State of Charge (% of max)')
ax.legend()
ax.set_ylim(0, 100)

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_13_0.png

6. Charging and Discharging Patterns#

[9]:
# Power dispatch (positive = discharge, negative = charge)
power = n.storage_units_t.p

# Aggregate by carrier
power_by_carrier = power.groupby(n.storage_units.carrier, axis=1).sum() / 1000  # GW

print("Storage Power Statistics (GW):")
print(power_by_carrier.describe().round(2))
Storage Power Statistics (GW):
carrier  Battery  Domestic Battery    LAES  Pumped Storage Hydroelectricity
count     168.00            168.00  168.00                           168.00
mean       -0.31             -0.03   -0.28                            -0.18
std         3.79              0.33    1.06                             1.51
min       -13.70             -0.87   -3.70                            -3.75
25%        -0.99             -0.15   -0.21                            -0.34
50%        -0.34             -0.06   -0.03                            -0.13
75%         0.47              0.17    0.00                             0.08
max        12.13              0.84    2.39                             4.42
[10]:
# Power dispatch plot
fig, ax = plt.subplots(figsize=(14, 5))

for col in power_by_carrier.columns:
    ax.plot(power_by_carrier.index, power_by_carrier[col],
            color=colors.get(col, '#888888'), linewidth=1, label=col, alpha=0.8)

ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.fill_between(power_by_carrier.index, power_by_carrier.sum(axis=1),
               where=power_by_carrier.sum(axis=1) > 0, alpha=0.3, color='green', label='Discharging')
ax.fill_between(power_by_carrier.index, power_by_carrier.sum(axis=1),
               where=power_by_carrier.sum(axis=1) < 0, alpha=0.3, color='red', label='Charging')

ax.set_ylabel('Power (GW)')
ax.set_xlabel('Time')
ax.set_title('Storage Charging/Discharging (positive = discharge)')
ax.legend()

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_16_0.png
[11]:
# Daily pattern
power_hourly = power_by_carrier.groupby(power_by_carrier.index.hour).mean()

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

for col in power_hourly.columns:
    ax.plot(power_hourly.index, power_hourly[col],
            color=colors.get(col, '#888888'), linewidth=2, marker='o', label=col)

ax.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
ax.set_xlabel('Hour of Day')
ax.set_ylabel('Average Power (GW)')
ax.set_title('Average Daily Storage Pattern')
ax.set_xticks(range(0, 24, 2))
ax.legend()

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_17_0.png

7. Storage Utilization#

[12]:
# Calculate utilization metrics
utilization = []

for carrier in power_by_carrier.columns:
    carrier_storage = n.storage_units[n.storage_units.carrier == carrier]
    total_capacity = carrier_storage['p_nom'].sum() / 1000  # GW

    if total_capacity > 0:
        # Total energy discharged
        discharged = power_by_carrier[carrier][power_by_carrier[carrier] > 0].sum()  # GWh
        charged = -power_by_carrier[carrier][power_by_carrier[carrier] < 0].sum()  # GWh

        # Capacity factor based on discharge
        hours = len(n.snapshots)
        cf = discharged / (total_capacity * hours) * 100

        # Cycles (discharge energy / energy capacity)
        energy_capacity = (carrier_storage['p_nom'] * carrier_storage['max_hours']).sum() / 1000
        cycles = discharged / energy_capacity if energy_capacity > 0 else 0

        utilization.append({
            'Carrier': carrier,
            'Power (GW)': total_capacity,
            'Discharged (GWh)': discharged,
            'Charged (GWh)': charged,
            'Capacity Factor (%)': cf,
            'Cycles': cycles
        })

util_df = pd.DataFrame(utilization).set_index('Carrier')
print("Storage Utilization:")
util_df.round(2)
Storage Utilization:
[12]:
Power (GW) Discharged (GWh) Charged (GWh) Capacity Factor (%) Cycles
Carrier
Battery 26.77 165.88 218.67 3.69 3.10
Domestic Battery 1.99 17.40 23.21 5.20 4.37
LAES 3.85 23.68 70.98 3.66 1.02
Pumped Storage Hydroelectricity 6.49 66.38 96.71 6.09 1.28

8. Arbitrage Value#

[13]:
# Calculate arbitrage value using marginal prices
if 'marginal_price' in n.buses_t and len(n.buses_t.marginal_price.columns) > 0:
    lmps = n.buses_t.marginal_price

    # Revenue from arbitrage = discharge × price - charge × price
    arbitrage = []

    for carrier in power_by_carrier.columns:
        carrier_storage = n.storage_units[n.storage_units.carrier == carrier]

        total_revenue = 0
        total_cost = 0

        for unit in carrier_storage.index:
            if unit in power.columns:
                bus = n.storage_units.loc[unit, 'bus']
                if bus in lmps.columns:
                    p = power[unit]
                    price = lmps[bus]

                    # Discharge revenue
                    discharge = p[p > 0]
                    revenue = (discharge * price[p > 0]).sum()

                    # Charge cost
                    charge = -p[p < 0]
                    cost = (charge * price[p < 0]).sum()

                    total_revenue += revenue
                    total_cost += cost

        arbitrage.append({
            'Carrier': carrier,
            'Revenue (£M)': total_revenue / 1e6,
            'Cost (£M)': total_cost / 1e6,
            'Profit (£M)': (total_revenue - total_cost) / 1e6
        })

    arb_df = pd.DataFrame(arbitrage).set_index('Carrier')
    print("Storage Arbitrage Value:")
    print(arb_df.round(2))
else:
    print("Marginal prices not available - re-solve with keep_shadowprices=True")
Storage Arbitrage Value:
                                 Revenue (£M)  Cost (£M)  Profit (£M)
Carrier
Battery                                  3.90       1.64         2.26
Domestic Battery                         0.39       0.21         0.18
LAES                                     0.65       0.20         0.44
Pumped Storage Hydroelectricity          1.11       0.28         0.83
[14]:
# Average charge/discharge prices
if 'marginal_price' in n.buses_t and len(n.buses_t.marginal_price.columns) > 0:
    avg_prices = []

    for carrier in power_by_carrier.columns:
        carrier_storage = n.storage_units[n.storage_units.carrier == carrier]

        all_discharge_prices = []
        all_charge_prices = []

        for unit in carrier_storage.index:
            if unit in power.columns:
                bus = n.storage_units.loc[unit, 'bus']
                if bus in lmps.columns:
                    p = power[unit]
                    price = lmps[bus]

                    all_discharge_prices.extend(price[p > 0].values)
                    all_charge_prices.extend(price[p < 0].values)

        avg_prices.append({
            'Carrier': carrier,
            'Avg Discharge Price (£/MWh)': np.mean(all_discharge_prices) if all_discharge_prices else 0,
            'Avg Charge Price (£/MWh)': np.mean(all_charge_prices) if all_charge_prices else 0,
            'Spread (£/MWh)': np.mean(all_discharge_prices) - np.mean(all_charge_prices) if all_discharge_prices and all_charge_prices else 0
        })

    price_df = pd.DataFrame(avg_prices).set_index('Carrier')
    print("Average Charge/Discharge Prices:")
    print(price_df.round(2))
Average Charge/Discharge Prices:
                                 Avg Discharge Price (£/MWh)  \
Carrier
Battery                                                17.02
Domestic Battery                                       15.67
LAES                                                   21.56
Pumped Storage Hydroelectricity                        18.77

                                 Avg Charge Price (£/MWh)  Spread (£/MWh)
Carrier
Battery                                              6.49           10.54
Domestic Battery                                     5.17           10.50
LAES                                                 6.08           15.47
Pumped Storage Hydroelectricity                      6.40           12.37

9. Comparing Storage Technologies#

[15]:
# Duration distribution
fig, ax = plt.subplots(figsize=(10, 6))

for carrier in storage['carrier'].unique():
    carrier_data = storage[storage.carrier == carrier]['max_hours']
    ax.hist(carrier_data, bins=20, alpha=0.6,
           color=colors.get(carrier, '#888888'), label=carrier, edgecolor='black')

ax.set_xlabel('Duration (hours)')
ax.set_ylabel('Number of Units')
ax.set_title('Storage Duration Distribution')
ax.legend()

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_24_0.png
[16]:
# Power vs Energy capacity scatter
fig, ax = plt.subplots(figsize=(10, 8))

for carrier in storage['carrier'].unique():
    carrier_data = storage[storage.carrier == carrier]
    power_gw = carrier_data['p_nom'] / 1000
    energy_gwh = power_gw * carrier_data['max_hours']

    ax.scatter(power_gw, energy_gwh, s=80, alpha=0.6,
              color=colors.get(carrier, '#888888'), label=carrier, edgecolors='black')

# Reference lines for duration
for hours in [1, 2, 4, 8, 24]:
    x = np.linspace(0, power_gw.max(), 100)
    ax.plot(x, x * hours, 'k--', alpha=0.3)
    ax.annotate(f'{hours}h', xy=(x[-1], x[-1]*hours), fontsize=8, alpha=0.5)

ax.set_xlabel('Power Capacity (GW)')
ax.set_ylabel('Energy Capacity (GWh)')
ax.set_title('Storage: Power vs Energy Capacity')
ax.legend()

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_25_0.png

10. Geographic Distribution#

[17]:
# Storage capacity by bus
storage_by_bus = storage.groupby('bus')['p_nom'].sum() / 1000  # GW

fig, ax = plt.subplots(figsize=(10, 12))

# Get bus coordinates
bus_x = n.buses.loc[storage_by_bus.index, 'x']
bus_y = n.buses.loc[storage_by_bus.index, 'y']

# Size proportional to capacity
sizes = storage_by_bus * 200  # Scale for visibility

scatter = ax.scatter(bus_x, bus_y, s=sizes, c=storage_by_bus,
                    cmap='Purples', alpha=0.7, edgecolors='black', linewidth=0.5)

# Plot lines for context
for line in n.lines.index:
    bus0, bus1 = n.lines.loc[line, ['bus0', 'bus1']]
    if bus0 in n.buses.index and bus1 in n.buses.index:
        ax.plot([n.buses.loc[bus0, 'x'], n.buses.loc[bus1, 'x']],
               [n.buses.loc[bus0, 'y'], n.buses.loc[bus1, 'y']],
               color='gray', linewidth=0.3, alpha=0.5)

plt.colorbar(scatter, label='Storage Capacity (GW)', shrink=0.8)
ax.set_xlabel('X (m)')
ax.set_ylabel('Y (m)')
ax.set_title('Storage Capacity Distribution')
ax.set_aspect('equal')

plt.tight_layout()
plt.show()
../_images/tutorials_10-storage_27_0.png