Demand Side Response#

This tutorial demonstrates how PyPSA-GB models domestic Demand Side Response (DSR) - households reducing electricity consumption during peak periods in exchange for incentive payments.

DSR programs like National Grid ESO’s “Saving Sessions” provide crucial flexibility during winter peak demand by:

  • Reducing demand during critical 2-hour windows (typically 17:00-19:00)

  • Helping avoid blackouts and expensive peaker plants

  • Providing households with bill savings (£3-10/event)

The HT35_flex scenario includes 1,500 MW of domestic DSR capacity, representing household participation in demand response events.

What You’ll Learn#

  1. DSR configuration modes (regular, winter, both)

  2. Event scheduling patterns and frequency

  3. DSR dispatch behavior and system value

  4. Comparison with other flexibility mechanisms (EV, batteries)

  5. Configuration parameters and capacity sizing

1. Setup#

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

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

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

2. Load Network with DSR#

The HT35_flex scenario includes event-based demand flexibility representing domestic DSR participation.

[2]:
# Load solved network with DSR enabled
n = pypsa.Network("../../../resources/network/HT30_event_response_solved.nc")

print("Network loaded")
print(f"  Buses: {len(n.buses)}")
print(f"  Generators: {len(n.generators)}")
print(f"  Period: {n.snapshots[0]} to {n.snapshots[-1]}")
print(f"  Snapshots: {len(n.snapshots)}")
INFO:pypsa.network.io:Imported network 'HT30_event_response (Clustered)' has buses, carriers, generators, lines, links, loads, storage_units, stores, sub_networks
Network loaded
  Buses: 20
  Generators: 161
  Period: 2030-02-21 00:00:00 to 2030-03-07 23:00:00
  Snapshots: 360

3. DSR Configuration#

Let’s examine how DSR is configured in the HT35_flex scenario.

[3]:
# Load configuration
with open("../../../config/defaults.yaml") as f:
    defaults = yaml.safe_load(f)

with open("../../../config/scenarios.yaml") as f:
    scenarios = yaml.safe_load(f)

# Get DSR configuration
dsr_defaults = defaults.get('demand_flexibility', {}).get('event_response', {})
scenario_dsr = scenarios.get('HT35_flex', {}).get('demand_flexibility', {}).get('event_response', {})

# Effective configuration (scenario overrides defaults)
eff_enabled = scenario_dsr.get('enabled', dsr_defaults.get('enabled', False))
eff_mode = scenario_dsr.get('mode', dsr_defaults.get('mode', 'regular'))
eff_capacity = scenario_dsr.get('dsr_capacity_mw', dsr_defaults.get('dsr_capacity_mw', None))
eff_window = scenario_dsr.get('event_window', dsr_defaults.get('event_window', ['17:00', '19:00']))
eff_winter_months = dsr_defaults.get('winter_months', [10, 11, 12, 1, 2, 3])

print("=" * 70)
print("HT35_FLEX DSR CONFIGURATION")
print("=" * 70)

print(f"\nENABLED: {eff_enabled}")

print(f"\nMODE: '{eff_mode}'")
if eff_mode == 'regular':
    print("  → 2 events per week throughout the year")
elif eff_mode == 'winter':
    print(f"  → 5 events per week during winter months {eff_winter_months}")
elif eff_mode == 'both':
    print(f"  → 2 events/week year-round + 5 events/week in winter {eff_winter_months}")

print(f"\nCAPACITY:")
if eff_capacity:
    print(f"  dsr_capacity_mw = {eff_capacity} MW (user-defined)")
else:
    part_rate = dsr_defaults.get('participation_rate', 0.33)
    max_red = dsr_defaults.get('max_reduction_fraction', 0.10)
    print(f"  Calculated from: participation_rate ({part_rate}) × max_reduction ({max_red})")

print(f"\nEVENT WINDOW:")
print(f"  {eff_window[0]} - {eff_window[1]} (peak demand period)")

print(f"\nWINTER MONTHS (for 'winter' or 'both' modes):")
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
               'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
winter_names = [month_names[m-1] for m in eff_winter_months]
print(f"  {', '.join(winter_names)}")

print("\n" + "=" * 70)
======================================================================
HT35_FLEX DSR CONFIGURATION
======================================================================

ENABLED: True

MODE: 'both'
  → 2 events/week year-round + 5 events/week in winter [10, 11, 12, 1, 2, 3]

CAPACITY:
  Calculated from: participation_rate (0.33) × max_reduction (0.1)

EVENT WINDOW:
  17:00 - 19:00 (peak demand period)

WINTER MONTHS (for 'winter' or 'both' modes):
  Oct, Nov, Dec, Jan, Feb, Mar

======================================================================

4. DSR Components in PyPSA#

DSR is modeled as Generator components with:

  • p_nom: Maximum demand reduction capacity (MW)

  • p_max_pu: Time-varying availability (0 or 1, only during events)

  • carrier: ‘demand response’

  • marginal_cost: High cost (£500/MWh) = only dispatch when needed

These generators represent households reducing consumption, which appears as “negative demand” or “generation” in the model.

[4]:
# Find DSR generators
dsr_gens = n.generators[n.generators.carrier == 'demand response']

print(f"DSR Generators: {len(dsr_gens)}")
print(f"Total DSR Capacity: {dsr_gens.p_nom.sum():.1f} MW")
print(f"Average per bus: {dsr_gens.p_nom.mean():.2f} MW")
print(f"Marginal cost: £{dsr_gens.marginal_cost.iloc[0]:.0f}/MWh (incentive payment)")

# Show sample DSR generators
print("\nSample DSR Generators:")
print(dsr_gens[['bus', 'carrier', 'p_nom', 'marginal_cost']].head(10))
DSR Generators: 11
Total DSR Capacity: 1483.2 MW
Average per bus: 134.84 MW
Marginal cost: £40/MWh (incentive payment)

Sample DSR Generators:
                                     bus          carrier       p_nom  \
name
ABHA11 demand response         cluster_5  demand response    8.167500
ABNE3- demand response__agg80  cluster_2  demand response   93.976345
ABTH11 demand response__agg26  cluster_5  demand response  193.985896
ALNE3J demand response__agg17  cluster_8  demand response    9.925903
AMEM11 demand response__agg56  cluster_3  demand response  464.719781
ARBR3- demand response__agg28  cluster_0  demand response   24.772280
BERW3- demand response__agg19  cluster_6  demand response   48.911278
BESW11 demand response__agg25  cluster_1  demand response  245.879089
BIRK11 demand response__agg25  cluster_9  demand response  212.920884
BRAW11 demand response__agg27  cluster_4  demand response  178.110098

                               marginal_cost
name
ABHA11 demand response                  40.0
ABNE3- demand response__agg80           40.0
ABTH11 demand response__agg26           40.0
ALNE3J demand response__agg17           40.0
AMEM11 demand response__agg56           40.0
ARBR3- demand response__agg28           40.0
BERW3- demand response__agg19           40.0
BESW11 demand response__agg25           40.0
BIRK11 demand response__agg25           40.0
BRAW11 demand response__agg27           40.0

5. DSR Dispatch Analysis#

Now let’s analyze when and how much DSR was actually dispatched by the optimizer.

[6]:
# Get DSR dispatch (actual demand reduction)
if len(dsr_gens) > 0 and len(n.generators_t.p) > 0:
    dsr_cols = [c for c in n.generators_t.p.columns if c in dsr_gens.index]

    if dsr_cols:
        # Total DSR dispatch across all buses
        total_dsr = n.generators_t.p[dsr_cols].sum(axis=1) / 1000  # GW

        print("=" * 70)
        print("DSR DISPATCH SUMMARY")
        print("=" * 70)

        print(f"\nCapacity vs Utilization:")
        print(f"  Total DSR capacity: {dsr_gens.p_nom.sum():.1f} MW")
        print(f"  Peak dispatch: {total_dsr.max():.2f} GW")
        print(f"  Peak utilization: {total_dsr.max() / (dsr_gens.p_nom.sum()/1000) * 100:.1f}%")

        print(f"\nEnergy Delivered:")
        print(f"  Total DSR energy: {total_dsr.sum():.1f} GWh")
        print(f"  Hours with DSR > 0: {(total_dsr > 0.001).sum()}")
        print(f"  Average during events: {total_dsr[total_dsr > 0.001].mean():.2f} GW")

        # Plot dispatch time series
        fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=False)

        # Full time series
        axes[0].fill_between(total_dsr.index, total_dsr.values,
                            alpha=0.6, color='orange', label='DSR Dispatch')
        axes[0].axhline(y=dsr_gens.p_nom.sum()/1000, color='red', linestyle='--',
                       label=f'Total Capacity: {dsr_gens.p_nom.sum()/1000:.2f} GW')
        axes[0].set_ylabel('DSR Dispatch (GW)')
        axes[0].set_xlabel('Time')
        axes[0].set_title('Demand Side Response Dispatch Over Time')
        axes[0].legend()
        axes[0].grid(True, alpha=0.3)

        # Average by hour of day
        dsr_by_hour = total_dsr.groupby(total_dsr.index.hour).mean()

        bars = axes[1].bar(dsr_by_hour.index, dsr_by_hour.values,
                          color='orange', alpha=0.7)

        # Highlight event window
        window_start = int(eff_window[0].split(':')[0])
        window_end = int(eff_window[1].split(':')[0])
        for hour in range(window_start, window_end):
            if hour < len(bars):
                bars[hour].set_color('red')
                bars[hour].set_alpha(0.8)

        axes[1].set_ylabel('Average DSR (GW)')
        axes[1].set_xlabel('Hour of Day')
        axes[1].set_title(f'DSR Dispatch by Hour (Event Window: {eff_window[0]}-{eff_window[1]})')
        axes[1].set_xticks(range(0, 24, 1))
        axes[1].grid(True, alpha=0.3)

        # Add annotation for event window
        axes[1].axvspan(window_start, window_end, alpha=0.2, color='red',
                       label='Event Window')
        axes[1].legend()

        plt.tight_layout()
        plt.show()

        print("\n" + "=" * 70)
    else:
        print("No DSR dispatch data found")
else:
    print("No DSR generators found")
======================================================================
DSR DISPATCH SUMMARY
======================================================================

Capacity vs Utilization:
  Total DSR capacity: 1483.2 MW
  Peak dispatch: 1.48 GW
  Peak utilization: 100.0%

Energy Delivered:
  Total DSR energy: 3.0 GWh
  Hours with DSR > 0: 2
  Average during events: 1.48 GW
../_images/tutorials_15-demand-side-response_10_1.png

======================================================================

6. DSR Value Analysis#

When does DSR get dispatched? Compare DSR activation with system marginal prices to understand its value.

[7]:
# Compare DSR dispatch with marginal prices
if len(dsr_gens) > 0 and len(n.generators_t.p) > 0 and len(n.buses_t.marginal_price) > 0:
    dsr_cols = [c for c in n.generators_t.p.columns if c in dsr_gens.index]

    if dsr_cols:
        total_dsr = n.generators_t.p[dsr_cols].sum(axis=1) / 1000  # GW

        # Average marginal price across all buses
        avg_price = n.buses_t.marginal_price.mean(axis=1)

        # Create comparison DataFrame
        comparison = pd.DataFrame({
            'dsr_gw': total_dsr,
            'price': avg_price
        })

        # When is DSR active?
        dsr_active = comparison[comparison['dsr_gw'] > 0.001]
        dsr_inactive = comparison[comparison['dsr_gw'] <= 0.001]

        print("DSR VALUE ANALYSIS")
        print("=" * 70)

        if len(dsr_active) > 0:
            print(f"\nPrice Statistics:")
            print(f"  Average price when DSR active: £{dsr_active['price'].mean():.2f}/MWh")
            print(f"  Average price when DSR inactive: £{dsr_inactive['price'].mean():.2f}/MWh")
            print(f"  Price premium captured: £{dsr_active['price'].mean() - dsr_inactive['price'].mean():.2f}/MWh")

            print(f"\nDispatch Triggers:")
            print(f"  Min price during DSR: £{dsr_active['price'].min():.2f}/MWh")
            print(f"  Max price during DSR: £{dsr_active['price'].max():.2f}/MWh")
            print(f"  DSR marginal cost: £{dsr_gens.marginal_cost.iloc[0]:.0f}/MWh")

            # Total value delivered
            total_value = (dsr_active['dsr_gw'] * dsr_active['price']).sum()
            total_cost = (dsr_active['dsr_gw'] * dsr_gens.marginal_cost.iloc[0]).sum()
            print(f"\nEconomic Value:")
            print(f"  Value of DSR (at marginal price): £{total_value:,.0f}")
            print(f"  Cost of DSR (incentive payments): £{total_cost:,.0f}")
            print(f"  Net system benefit: £{total_value - total_cost:,.0f}")

            # Plot price vs DSR
            fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

            # Time series overlay
            ax1 = axes[0]
            ax1.fill_between(comparison.index, comparison['dsr_gw'],
                            alpha=0.5, color='orange', label='DSR Dispatch')
            ax1.set_ylabel('DSR Dispatch (GW)', color='orange')
            ax1.tick_params(axis='y', labelcolor='orange')

            ax2 = ax1.twinx()
            ax2.plot(comparison.index, comparison['price'],
                    color='blue', linewidth=1, alpha=0.7, label='Marginal Price')
            ax2.axhline(y=dsr_gens.marginal_cost.iloc[0], color='red',
                       linestyle='--', label=f'DSR Cost: £{dsr_gens.marginal_cost.iloc[0]:.0f}/MWh')
            ax2.set_ylabel('Marginal Price (£/MWh)', color='blue')
            ax2.tick_params(axis='y', labelcolor='blue')
            ax2.legend(loc='upper right')

            ax1.set_title('DSR Dispatch vs System Marginal Price')
            ax1.legend(loc='upper left')

            # Scatter plot: price vs DSR
            axes[1].scatter(dsr_active['price'], dsr_active['dsr_gw'],
                          alpha=0.6, c='orange', s=50, label='DSR Active')
            axes[1].axvline(x=dsr_gens.marginal_cost.iloc[0], color='red',
                          linestyle='--', label=f'DSR Cost: £{dsr_gens.marginal_cost.iloc[0]:.0f}/MWh')
            axes[1].set_xlabel('Marginal Price (£/MWh)')
            axes[1].set_ylabel('DSR Dispatch (GW)')
            axes[1].set_title('DSR Dispatch vs Price (Scatter)')
            axes[1].legend()
            axes[1].grid(True, alpha=0.3)

            plt.tight_layout()
            plt.show()

            print("\n" + "=" * 70)
        else:
            print("No DSR was dispatched during this period")
    else:
        print("No DSR dispatch data")
else:
    print("Marginal price data not available")
DSR VALUE ANALYSIS
======================================================================

Price Statistics:
  Average price when DSR active: £48.36/MWh
  Average price when DSR inactive: £18.78/MWh
  Price premium captured: £29.58/MWh

Dispatch Triggers:
  Min price during DSR: £48.34/MWh
  Max price during DSR: £48.38/MWh
  DSR marginal cost: £40/MWh

Economic Value:
  Value of DSR (at marginal price): £143
  Cost of DSR (incentive payments): £119
  Net system benefit: £25
../_images/tutorials_15-demand-side-response_12_1.png

======================================================================

7. Geographic Distribution of DSR#

DSR capacity is distributed across network buses proportionally to peak demand. Let’s visualize this.

[8]:
# Analyze DSR distribution across buses
if len(dsr_gens) > 0:
    # Group by bus
    dsr_by_bus = dsr_gens.groupby('bus')['p_nom'].sum().sort_values(ascending=False)

    print(f"DSR Distribution Across {len(dsr_by_bus)} Buses")
    print(f"  Total capacity: {dsr_by_bus.sum():.1f} MW")
    print(f"  Top 10 buses: {dsr_by_bus.head(10).sum():.1f} MW ({dsr_by_bus.head(10).sum()/dsr_by_bus.sum()*100:.1f}%)")
    print(f"  Min capacity: {dsr_by_bus.min():.2f} MW")
    print(f"  Max capacity: {dsr_by_bus.max():.2f} MW")
    print(f"  Mean capacity: {dsr_by_bus.mean():.2f} MW")
    print(f"  Median capacity: {dsr_by_bus.median():.2f} MW")

    # Plot distribution
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # Top 20 buses
    top20 = dsr_by_bus.head(20)
    axes[0].barh(range(len(top20)), top20.values, color='orange', alpha=0.7)
    axes[0].set_yticks(range(len(top20)))
    axes[0].set_yticklabels(top20.index, fontsize=8)
    axes[0].set_xlabel('DSR Capacity (MW)')
    axes[0].set_title('Top 20 Buses by DSR Capacity')
    axes[0].invert_yaxis()
    axes[0].grid(True, alpha=0.3, axis='x')

    # Histogram of capacity distribution
    axes[1].hist(dsr_by_bus.values, bins=30, color='orange', alpha=0.7, edgecolor='black')
    axes[1].axvline(x=dsr_by_bus.mean(), color='red', linestyle='--',
                   label=f'Mean: {dsr_by_bus.mean():.2f} MW')
    axes[1].axvline(x=dsr_by_bus.median(), color='blue', linestyle='--',
                   label=f'Median: {dsr_by_bus.median():.2f} MW')
    axes[1].set_xlabel('DSR Capacity per Bus (MW)')
    axes[1].set_ylabel('Number of Buses')
    axes[1].set_title('Distribution of DSR Capacity')
    axes[1].legend()
    axes[1].grid(True, alpha=0.3)

    plt.tight_layout()
    plt.show()

    print("\nTop 10 Buses:")
    for i, (bus, capacity) in enumerate(dsr_by_bus.head(10).items(), 1):
        print(f"  {i:2d}. {bus:15s}: {capacity:>8.2f} MW")
else:
    print("No DSR generators found")
DSR Distribution Across 10 Buses
  Total capacity: 1483.2 MW
  Top 10 buses: 1483.2 MW (100.0%)
  Min capacity: 1.83 MW
  Max capacity: 464.72 MW
  Mean capacity: 148.32 MW
  Median capacity: 136.04 MW
../_images/tutorials_15-demand-side-response_14_1.png

Top 10 Buses:
   1. cluster_3      :   464.72 MW
   2. cluster_1      :   245.88 MW
   3. cluster_9      :   212.92 MW
   4. cluster_5      :   202.15 MW
   5. cluster_4      :   178.11 MW
   6. cluster_2      :    93.98 MW
   7. cluster_6      :    48.91 MW
   8. cluster_0      :    24.77 MW
   9. cluster_8      :     9.93 MW
  10. cluster_7      :     1.83 MW

8. Configuration Reference#

DSR is configured in config/defaults.yaml under demand_flexibility.event_response:

event_response:
  enabled: false                    # Toggle DSR on/off
  mode: "regular"                   # regular, winter, or both
  event_window: ["17:00", "19:00"]  # Peak period (typically 17:00-19:00)
  dsr_capacity_mw: null             # User-defined capacity (MW)
  participation_rate: 0.33          # Fallback: 33% of households participate
  max_reduction_fraction: 0.10      # Fallback: 10% max demand reduction
  winter_months: [10, 11, 12, 1, 2, 3]  # Oct-Mar for winter mode

Key Parameters:#

Parameter

Description

Typical Value

enabled

Turn DSR on/off

false (opt-in)

mode

Event frequency

regular, winter, both

dsr_capacity_mw

Total DSR capacity

1000-2000 MW

event_window

Daily event period

[“17:00”, “19:00”]

participation_rate

Household participation (fallback)

0.2-0.4

max_reduction_fraction

Max demand reduction (fallback)

0.05-0.15

winter_months

Winter months for ‘winter’/’both’ modes

[10,11,12,1,2,3]

Mode Comparison:#

Mode

Frequency

Use Case

regular

2 events/week year-round

Baseline flexibility testing

winter

5 events/week Oct-Mar

High winter demand periods

both

2/week summer + 5/week winter

Most realistic scenario

Capacity Sizing:#

If dsr_capacity_mw is not set, capacity is calculated as:

DSR Capacity = participation_rate × max_reduction_fraction × Peak Demand

For GB with ~40 GW peak demand:

  • Conservative: 0.25 × 0.08 × 40,000 MW = 800 MW

  • Moderate: 0.33 × 0.10 × 40,000 MW = 1,320 MW

  • Ambitious: 0.40 × 0.12 × 40,000 MW = 1,920 MW

The HT35_flex scenario uses 1,500 MW as a realistic mid-point target for 2035.

9. Real-World Context: National Grid ESO Saving Sessions#

PyPSA-GB’s DSR modeling is inspired by real programs like National Grid ESO’s Demand Flexibility Service (“Saving Sessions”):

How Saving Sessions Works:#

  1. Enrollment: Households with smart meters sign up through their energy supplier

  2. Event notification: ESO announces events 24 hours in advance (typically 17:00-19:00)

  3. Baseline establishment: Smart meter calculates typical usage for that period

  4. Demand reduction: Households reduce usage (delay cooking, heating, EV charging)

  5. Reward payment: £3-10 per kWh saved, paid via energy supplier

Winter 2022/23 Results:#

  • 1.6 million households participated

  • 21 events held (Nov 2022 - Mar 2023)

  • ~500 MW average reduction per event

  • 3,300 MWh total energy saved

  • £50/MWh average cost (vs. £150-300/MWh for backup generation)

2035 Projection (HT35 Scenario):#

  • 5-10 million households with smart meters and flexibility tariffs

  • 1,500 MW achievable reduction (3x current)

  • 50-100 events/year (winter-focused)

  • Cost-effective alternative to peaker plants and imports

Why DSR is Valuable:#

  1. Avoids generation costs: £50-100/MWh vs. £150-300/MWh for backup

  2. Reduces network stress: Temporal load shifting prevents bottlenecks

  3. Consumer benefits: Households earn rewards for flexibility

  4. Low carbon: Reduces need for fossil fuel peaker plants

  5. Scalable: Can grow with smart meter rollout