Chris Parmer — home

load-duration curve for California (CAISO) grid

Power Systems

Example from the compendium of canonical charts

Power Systems — load-duration curve for California (CAISO) grid

Python Code

"""Power Systems — load-duration curve for California (CAISO) grid."""
import io
from pathlib import Path

# ── Palette + theme (matching the Plotly Studio gallery these charts ship in) ──
VIOLET, TEAL, GREEN, PINK, ORANGE = "#845EEE", "#52B3D0", "#55B685", "#DA5597", "#E9A23B"
PRIMARY, SECONDARY = VIOLET, TEAL
COLORWAY = [VIOLET, TEAL, GREEN, PINK, ORANGE]
BG, TEXT, GRID, MUTED = "#ffffff", "#1c2024", "#d9d9e0", "#60646c"
FONT = "Inter, -apple-system, BlinkMacSystemFont, sans-serif"
COLORSCALE = [[0, "rgba(132, 94, 238, 0.05)"], [1, "rgba(132, 94, 238, 0.9)"]]


def apply_theme(fig):
    """Light gallery theme: white background, Inter font, soft gridlines."""
    fig.update_layout(
        paper_bgcolor=BG, plot_bgcolor=BG, colorway=COLORWAY,
        font=dict(family=FONT, color=TEXT, size=12),
        legend=dict(font=dict(color=TEXT)),
        hoverlabel=dict(bgcolor="#f0f0f3", font=dict(color=TEXT, family=FONT), bordercolor=GRID),
    )
    fig.update_xaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)
    fig.update_yaxes(gridcolor=GRID, linecolor=GRID, zerolinecolor=GRID)


def fetch_csv(url, **kwargs):
    import io
    import pandas as pd
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return pd.read_csv(io.StringIO(r.text), **kwargs)


def fetch_json(url):
    import requests
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return r.json()

import numpy as np
import pandas as pd
import plotly.graph_objects as go


# EIA 930 balance file — filter for CISO respondent
EIA_URL = "https://www.eia.gov/electricity/gridmonitor/sixMonthFiles/EIA930_BALANCE_2024_Jan_Jun.csv"


def build_ldc(demand_mw):
    """Return (pct_hours, demand_sorted_desc)."""
    ds = np.sort(demand_mw)[::-1]
    pct = np.linspace(0, 100, len(ds))
    return pct, ds


def generate():
    try:
        print("fetching EIA 930 balance CSV (large file, may take a moment) …")
        import requests
        r = requests.get(EIA_URL, verify=False, timeout=120, stream=True)
        r.raise_for_status()

        # Stream and collect in memory
        content = r.content
        print(f"  downloaded {len(content)//1024//1024} MB")

        df = pd.read_csv(io.BytesIO(content), low_memory=False)
        df.columns = [c.strip() for c in df.columns]
        print(f"  full file: {len(df)} rows; columns sample: {list(df.columns[:8])}")

        # Find respondent / balancing authority column
        resp_col = next(
            (c for c in df.columns if any(k in c.lower() for k in ("respondent", "balancing authority", "ba_code", "balancing_authority"))),
            None
        )
        if resp_col is None:
            raise ValueError(f"No respondent column found. Columns: {list(df.columns)}")

        # Filter CISO (California ISO) — EIA 930 uses "California ISO"
        ciso_mask = (
            df[resp_col].astype(str).str.strip().str.upper().isin(["CISO", "CAISO"]) |
            df[resp_col].astype(str).str.strip().str.lower().str.contains("california")
        )
        ciso = df[ciso_mask]
        print(f"  CISO rows: {len(ciso)}")
        if len(ciso) < 100:
            raise ValueError("Too few CISO rows")

        # Find demand column
        demand_col = next(
            (c for c in ciso.columns if "demand" in c.lower() and "forecast" not in c.lower()),
            None
        )
        if demand_col is None:
            raise ValueError(f"No demand column found. Columns: {list(ciso.columns)}")

        demand = pd.to_numeric(ciso[demand_col], errors="coerce").dropna()
        demand = demand[demand > 0].values
        label = "CAISO (CA) — Jan–Jun 2024"
        source = "EIA-930 Balance"

    except Exception as e:
        print(f"  EIA fetch failed ({e}); using synthetic California-like LDC")
        np.random.seed(13)
        n = 4380  # 6 months of hourly data
        # Bimodal: summer peak demand ~50 GW, winter ~35 GW, base ~25 GW
        base = 28000
        t = np.linspace(0, 2 * np.pi, n)
        seasonal = 8000 * np.sin(t)
        diurnal  = 5000 * np.abs(np.sin(t * 365 / 2))
        noise    = np.random.normal(0, 1200, n)
        demand   = base + seasonal + diurnal + noise
        demand   = np.maximum(demand, 15000)
        label    = "California (synthetic)"
        source   = "Synthetic"

    pct, ds = build_ldc(demand)

    # Annotate key points
    p_peak = ds[0]
    p_base = ds[-1]
    p_med  = ds[len(ds)//2]

    fig = go.Figure()

    fig.add_trace(go.Scatter(
        x=pct, y=ds,
        mode="lines",
        line=dict(color=VIOLET, width=2.5),
        fill="tozeroy",
        fillcolor="rgba(132,94,238,0.12)",
        name=label,
        hovertemplate="%{x:.1f}% of hours<br>%{y:,.0f} MW<extra></extra>",
    ))

    # Annotations
    for pct_ann, val, text in [
        (0,   p_peak, f"Peak: {p_peak:,.0f} MW"),
        (50,  p_med,  f"Median: {p_med:,.0f} MW"),
        (95,  ds[int(0.95*len(ds))], f"Baseload: {ds[int(0.95*len(ds))]:,.0f} MW"),
    ]:
        fig.add_annotation(
            x=pct_ann, y=val, text=text,
            showarrow=True, arrowhead=2,
            ax=30, ay=-30,
            font=dict(size=11),
        )

    fig.update_layout(
        xaxis=dict(title="% of Hours", ticksuffix="%", range=[0, 100]),
        yaxis=dict(title="Demand (MW)"),
        legend=dict(x=0.98, y=0.98, xanchor="right", yanchor="top"),
        margin=dict(t=50, b=50, l=70, r=60),
    )
    apply_theme(fig)
    return fig


fig = generate()
fig.show()

Made with Plotly