Chris Parmer — home

California duck curve

Grid Operations — net load by hour, spring days

Example from the compendium of canonical charts

Grid Operations — California duck curve (net load by hour, spring days)

Python Code

"""Grid Operations — California duck curve (net load by hour, spring days)."""


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

URL = "https://raw.githubusercontent.com/dlevonian/california_renewables/master/caiso_final.csv"


def synthetic_duck(year=2019):
    """Synthetic spring duck-curve days."""
    hours = np.arange(1, 25)
    # Approximate CAISO net load profile for spring days
    profiles = {
        "Mar 21": [23000, 22000, 21500, 21000, 21200, 22500,
                   23000, 22000, 19000, 15000, 12000, 11000,
                   10500, 10800, 11500, 13000, 16000, 20000,
                   24000, 26000, 26500, 26000, 25000, 24000],
        "Mar 28": [22500, 21500, 21000, 20500, 20800, 22000,
                   22500, 21000, 18000, 14000, 11500, 10500,
                   10000, 10300, 11000, 12500, 15500, 19500,
                   23500, 25500, 26000, 25500, 24500, 23500],
        "Apr 4":  [22000, 21000, 20500, 20000, 20300, 21500,
                   22000, 20500, 17000, 13000, 10500, 9500,
                   9000,  9300,  10000, 11500, 15000, 19000,
                   23000, 25000, 25500, 25000, 24000, 23000],
        "Apr 11": [21500, 20500, 20000, 19500, 19800, 21000,
                   21500, 20000, 16500, 12500, 10000, 9000,
                   8500,  8800,  9500,  11000, 14500, 18500,
                   22500, 24500, 25000, 24500, 23500, 22500],
        "Apr 18": [21000, 20000, 19500, 19000, 19300, 20500,
                   21000, 19500, 16000, 12000, 9500,  8500,
                   8000,  8300,  9000,  10500, 14000, 18000,
                   22000, 24000, 24500, 24000, 23000, 22000],
    }
    return hours, profiles


def generate():
    try:
        print("fetching CAISO renewables CSV …")
        df = fetch_csv(URL)
        df.columns = [c.strip() for c in df.columns]
        print(f"  loaded {len(df)} rows; columns: {list(df.columns)}")

        # Find required columns
        def find_col(candidates):
            for c in candidates:
                for col in df.columns:
                    if c.lower() == col.lower():
                        return col
            return None

        date_col   = find_col(["DATE", "date"])
        hour_col   = find_col(["HOUR", "hour", "HR"])
        total_col  = find_col(["TOTAL", "total", "DEMAND"])
        solar_pv   = find_col(["SOLAR_PV", "solar_pv"])
        solar_th   = find_col(["SOLAR_THERMAL", "solar_thermal"])
        wind_col   = find_col(["WIND", "wind"])

        missing = [n for n, c in [("DATE", date_col), ("HOUR", hour_col),
                                   ("TOTAL", total_col), ("SOLAR_PV", solar_pv),
                                   ("WIND", wind_col)] if c is None]
        if missing:
            raise ValueError(f"Missing columns: {missing}")

        # Parse date
        df[date_col] = pd.to_numeric(df[date_col], errors="coerce")
        df[hour_col] = pd.to_numeric(df[hour_col], errors="coerce")
        for col in [total_col, solar_pv, solar_th, wind_col]:
            if col:
                df[col] = pd.to_numeric(df[col], errors="coerce")

        # Net load
        renew = df[solar_pv].fillna(0)
        if solar_th:
            renew += df[solar_th].fillna(0)
        renew += df[wind_col].fillna(0)
        df["net_load"] = df[total_col] - renew

        # Filter spring (March-April)
        df["month"] = (df[date_col] // 100) % 100  # YYYYMMDD → MM
        spring = df[df["month"].isin([3, 4])].dropna(subset=[hour_col, "net_load"])

        # Pick 5 representative days (sample by unique dates)
        unique_dates = spring[date_col].dropna().unique()
        if len(unique_dates) < 5:
            raise ValueError(f"Too few spring dates: {len(unique_dates)}")
        np.random.seed(42)
        chosen = np.sort(np.random.choice(unique_dates, 5, replace=False))

        days_data = {}
        for d in chosen:
            ddf = spring[spring[date_col] == d].sort_values(hour_col)
            if len(ddf) >= 20:
                s = str(int(d))
                label = f"{s[4:6]}/{s[6:8]}/{s[:4]}"
                days_data[label] = (ddf[hour_col].values, ddf["net_load"].values)

        if len(days_data) < 3:
            raise ValueError("Not enough days with full data")

        use_synthetic = False

    except Exception as e:
        print(f"  fetch failed ({e}); using synthetic duck curve")
        use_synthetic = True

    colors = [VIOLET, GREEN, ORANGE, PINK, TEAL]

    fig = go.Figure()

    if use_synthetic:
        hours, profiles = synthetic_duck()
        for i, (label, vals) in enumerate(profiles.items()):
            fig.add_trace(go.Scatter(
                x=hours, y=vals,
                mode="lines",
                name=label,
                line=dict(color=colors[i % len(colors)], width=2),
                hovertemplate=f"{label}<br>Hour %{{x}}: %{{y:,.0f}} MW<extra></extra>",
            ))
    else:
        for i, (label, (hrs, nl)) in enumerate(days_data.items()):
            fig.add_trace(go.Scatter(
                x=hrs, y=nl,
                mode="lines",
                name=label,
                line=dict(color=colors[i % len(colors)], width=2),
                hovertemplate=f"{label}<br>Hour %{{x}}: %{{y:,.0f}} MW<extra></extra>",
            ))

    # Annotate duck anatomy
    fig.add_annotation(
        x=13,
        text="Duck's belly<br>(solar midday dip)",
        showarrow=False,
        font=dict(size=10),
        bgcolor="rgba(255,255,255,0.85)",
        bordercolor="#d9d9e0",
        yref="paper", y=0.12,
        xanchor="center",
    )
    fig.add_annotation(
        x=20,
        text="Duck's neck<br>(evening ramp)",
        showarrow=False,
        font=dict(size=10),
        bgcolor="rgba(255,255,255,0.85)",
        bordercolor="#d9d9e0",
        yref="paper", y=0.88,
        xanchor="center",
    )

    fig.update_layout(
        xaxis=dict(title="Hour of Day", tickvals=list(range(1, 25, 2)), range=[1, 24], showgrid=False),
        yaxis=dict(title="Net Load (MW)", showgrid=False),
        legend=dict(x=0.02, y=0.98, xanchor="left", yanchor="top"),
        margin=dict(t=50, b=50, l=70, r=60),
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly