CAISO Outage Reason Codes and Derate Depth#

Authors: Brian Nguyen · Claire Kim · Created: 2026-08-25 · Tags: #Experiment #Outages #Weather

Notebook 11 models how many forced outages start on a county-day. This notebook asks what the reason codes say those outages are, and where the temperature response actually lives once the filings are read by reason.

from pathlib import Path
import sys
import warnings

import numpy as np
import pandas as pd
import polars as pl
from bokeh.io import output_notebook, show
from bokeh.layouts import row
from bokeh.models import ColumnDataSource, HoverTool, Range1d, Span
from bokeh.plotting import figure
from bokeh.resources import INLINE
from bokeh.transform import dodge
from scipy.stats import nbinom, poisson
from statsmodels.tools.sm_exceptions import (
    ConvergenceWarning,
    HessianInversionWarning,
)

PROJECT_ROOT = Path.cwd()
if PROJECT_ROOT.name == "notebooks":
    PROJECT_ROOT = PROJECT_ROOT.parent
sys.path.insert(0, str(PROJECT_ROOT))

SRC_DIR = PROJECT_ROOT / "src"
if str(SRC_DIR) not in sys.path:
    sys.path.insert(0, str(SRC_DIR))

from src.caiso_outage_nb_pipeline import (
    COUNTY_DAILY_FEATURES,
    build_caiso_county_nb_panel,
    build_caiso_market_zone_nb_panel,
    fit_negative_binomial_model,
    likelihood_ratio_block_tests,
    model_performance_by_group,
    overdispersion_summary,
    poisson_negative_binomial_comparison,
    resource_mapping_coverage_summary,
    rollup_county_predictions_to_market_zone,
    rolling_backtest_negative_binomial_model,
)
from src.caiso_outage_specification_search import (
    add_candidate_interaction_columns,
    add_extreme_weather_columns,
    candidate_specifications,
    coefficient_stability_across_expanding_fits,
    compare_specifications,
    extreme_specifications,
    fit_nb1_model,
    l1_regularization_path,
    purged_walk_forward_family_comparison,
    variance_family_comparison,
    walk_forward_specification_metrics,
)
from chart_theme import SERIES, corr_heatmap, html_table, style

DATA_DIR = PROJECT_ROOT / "data"
PROCESSED_DIR = DATA_DIR / "processed"
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)

pd.options.display.max_columns = 80
pd.options.display.float_format = "{:.4f}".format

# The county fits sit on the alpha = 0 dispersion boundary, so statsmodels
# reports a failed convergence check and an uninvertible Hessian while still
# returning the Poisson-limit coefficients. The tables below carry the fitted
# alpha and the held-out scores, which is where a failed fit shows.
warnings.filterwarnings("ignore", category=ConvergenceWarning)
warnings.filterwarnings("ignore", category=HessianInversionWarning)

output_notebook(INLINE, hide_banner=True)

Setup: The Panel, the Filings, and the Block Tests#

The same county-day panel as notebook 11, plus the matched filing-level events the reason codes are read from.

county_panel, mapped_events, resource_crosswalk, county_zone_weights = build_caiso_county_nb_panel(DATA_DIR)

county_feature_blocks = {
    "outage state": [
        "log1p_Y_lag1",
        "log1p_planned_start_count_lag1",
        "log1p_forced_start_mw_sum_lag1",
    ],
    "size": ["log_county_nameplate_capacity_mw", "log_county_plant_count"],
    "weather": ["cdd65", "hdd65", "temperature_f_std", "county_weather_basis_risk_flag"],
    "load": ["log_system_load_mean"],
    "calendar": ["weekend", "month_sin", "month_cos"],
    "zone effects": ["region_NP15", "region_SP15"],
}
county_block_tests = likelihood_ratio_block_tests(
    county_panel,
    feature_cols=COUNTY_DAILY_FEATURES,
    feature_blocks=county_feature_blocks,
    high_count_quantile=0.90,
    maxiter=300,
)

The target \(Y\) treats every forced start alike, but the report says why each generator was down (nature_of_work). Notebook 02 mapped that taxonomy at the system level; the county crosswalk built here lets us ask the sharper questions - which generators, in which counties, report temperature as the problem, and does a reason-aware target change the model?

1. Half of Matched Forced Starts Are Temperature Filings#

matched_forced = mapped_events[
    mapped_events["county"].notna() & mapped_events["outage_type"].eq("FORCED")
]
reason_counts = (
    matched_forced.groupby("nature_of_work")["outage_mrid_str"]
    .nunique()
    .sort_values()
    .tail(10)
)

fig = figure(
    title="Matched Forced Outage Starts by Reason Code",
    y_range=reason_counts.index.tolist(),
    x_axis_label="Outage Starts",
    height=360,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
fig.hbar(
    y=reason_counts.index.tolist(),
    right=reason_counts.tolist(),
    height=0.6,
    color=[
        SERIES[1] if reason == "AMBIENT_DUE_TO_TEMP" else SERIES[0]
        for reason in reason_counts.index
    ],
)
fig.add_tools(HoverTool(tooltips=[("reason", "@y"), ("starts", "@right{0,0}")]))
show(style(fig))
ambient_events = matched_forced[matched_forced["ambient_temperature_flag"]]
events_per_filer = ambient_events.groupby("resource_name")["outage_mrid_str"].nunique()
n_panel_days = county_panel["date"].nunique()
near_daily = events_per_filer[events_per_filer > 0.8 * n_panel_days]

ambient_audit = pd.DataFrame(
    [
        {
            "matched forced starts": int(matched_forced["outage_mrid_str"].nunique()),
            "ambient-temperature starts": int(ambient_events["outage_mrid_str"].nunique()),
            "ambient share": round(
                ambient_events["outage_mrid_str"].nunique()
                / matched_forced["outage_mrid_str"].nunique(),
                3,
            ),
            "ambient filers": int(events_per_filer.size),
            "near-daily filers (80%+ of days)": int(near_daily.size),
            "their share of ambient starts": round(
                float(near_daily.sum() / events_per_filer.sum()), 3
            ),
        }
    ]
)
html_table(pl.from_pandas(ambient_audit))
1 rows × 6 columns
matched forced starts ambient-temperature starts ambient share ambient filers near-daily filers (80%+ of days) their share of ambient starts
152,080 77,021 0.506 189 20 0.437

AMBIENT_DUE_TO_TEMP is the largest forced-outage reason, about half of the matched forced starts in the panel. The audit row reframes it: 20 of the 189 filers file on more than 80% of the panel’s days - a five-year bar - and still carry 44% of the ambient starts, so for that roster an ambient filing is a routine daily derate, not an episodic breakdown. Notebook 02 section 4.3 maps the wider AMBIENT family.

2. Which Generators, in Which Counties#

ambient_by_county = (
    ambient_events.groupby(["region", "county"])
    .agg(
        starts=("outage_mrid_str", "nunique"),
        mw_sum=("max_curtailment_mw", "sum"),
        generators=("resource_name", "nunique"),
    )
    .sort_values("starts")
    .tail(12)
    .reset_index()
)
county_labels = (ambient_by_county["county"] + " (" + ambient_by_county["region"] + ")").tolist()

fig = figure(
    title="Ambient-Temperature Outage Starts by County",
    y_range=county_labels,
    x_axis_label="Ambient-Temperature Starts",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
county_source = ColumnDataSource(
    {
        "county": county_labels,
        "starts": ambient_by_county["starts"].tolist(),
        "mw_sum": ambient_by_county["mw_sum"].tolist(),
        "generators": ambient_by_county["generators"].tolist(),
    }
)
fig.hbar(y="county", right="starts", height=0.6, color=SERIES[0], source=county_source)
fig.add_tools(
    HoverTool(tooltips=[("county", "@county"), ("starts", "@starts{0,0}"),
              ("cumulative MW", "@mw_sum{0,0}"), ("generators", "@generators")])
)
show(style(fig))
county_weather_daily = county_panel[
    ["date", "region", "county", "cdd65", "temperature_f_mean"]
]
ambient_with_weather = ambient_events.merge(
    county_weather_daily, on=["date", "region", "county"], how="left"
)


def derate_temperature_correlation(derate_mw):
    """Correlate one filer's declared derate MW with its county temperature.

    A unit that filed once, or that files the same MW every time, has no
    correlation to report; returning NaN keeps those groups out of the
    ranking instead of dividing by a zero standard deviation.
    """
    paired = pd.concat(
        [derate_mw, ambient_with_weather.loc[derate_mw.index, "temperature_f_mean"]],
        axis=1,
    ).dropna()
    if len(paired) < 2 or paired.nunique().min() < 2:
        return np.nan
    return paired.iloc[:, 0].corr(paired.iloc[:, 1])


filer_depth = (
    ambient_with_weather.groupby(["resource_name", "region", "county"])
    .agg(
        starts=("outage_mrid_str", "nunique"),
        max_mw=("max_curtailment_mw", "max"),
        corr_mw_temperature=("max_curtailment_mw", derate_temperature_correlation),
    )
    .reset_index()
)
filer_depth = (
    filer_depth[filer_depth["starts"] >= 200]
    .sort_values("corr_mw_temperature", ascending=False)
    .round({"max_mw": 1, "corr_mw_temperature": 3})
)
html_table(
    pl.from_pandas(
        filer_depth.rename(
            columns={
                "resource_name": "generator",
                "starts": "starts",
                "max_mw": "max derate (MW)",
                "corr_mw_temperature": "corr of derate MW with county temperature",
            }
        )
    ),
    max_rows=40,
)
68 rows × 6 columns (first 40 of 68)
generator region county starts max derate (MW) corr of derate MW with county temperature
GWF HENRIETTA PEAKER PLANT UNIT 2 ZP26 Kings 536 10.30 0.936
GWF HENRIETTA PEAKER PLANT UNIT 1 ZP26 Kings 536 10.00 0.934
Mammoth G2 ZP26 Mono 1,093 10.50 0.668
McGrath Beach Peaker SP15 Ventura 1,504 48.60 0.566
Pio Pico Unit 1 SP15 San Diego 1,846 111.30 0.528
LIVE OAK LIMITED ZP26 Kern 1,325 49.70 0.490
GATEWAY GENERATING STATION NP15 Contra Costa 1,686 585.00 0.470
Pio Pico Unit 3 SP15 San Diego 1,856 112.00 0.439
MCKITTRICK LIMITED ZP26 Kern 1,165 47.50 0.431
Hanford Peaker Plant ZP26 Kings 548 98.50 0.421
Midway Peaking Aggregate ZP26 Fresno 898 119.90 0.371
Pio Pico Unit 2 SP15 San Diego 1,849 112.70 0.356
Mira Loma Peaker SP15 San Bernardino 1,389 47.20 0.355
MIDWAY SUNSET COGENERATION PLANT ZP26 Kern 1,658 248.00 0.341
Panoche Peaker ZP26 Fresno 743 50.00 0.324
Tracy Combined Cycle Power Plant NP15 San Joaquin 550 336.00 0.261
Sunrise Power Project AGGREGATE II ZP26 Kern 1,561 586.00 0.229
Bear Mountain Limited ZP26 Kern 1,352 49.20 0.213
CHALK CLIFF LIMITED ZP26 Kern 1,328 48.70 0.211
Mountainview Gen Sta. Unit 3 SP15 San Bernardino 1,225 555.00 0.211
Marsh Landing 3 NP15 Contra Costa 1,801 209.00 0.209
OTAY MESA ENERGY CENTER SP15 San Diego 1,654 603.70 0.198
Marsh Landing 1 NP15 Contra Costa 1,737 204.20 0.192
Mariposa Energy NP15 Alameda 496 198.00 0.191
Mountainview Gen Sta. Unit 4 SP15 San Bernardino 1,417 555.00 0.188
DOUBLE "C" LIMITED ZP26 Kern 1,306 52.20 0.186
Marsh Landing 2 NP15 Contra Costa 1,719 202.70 0.186
Stanton 2 SP15 Orange 737 49.60 0.160
Marsh Landing 4 NP15 Contra Costa 1,726 204.30 0.152
Sentinel Unit 3 SP15 Riverside 590 105.70 0.146
Los Medanos Energy Center AGGREGATE NP15 Contra Costa 1,606 389.00 0.143
HIGH SIERRA LIMITED ZP26 Kern 1,312 52.40 0.120
La Paloma Generating Plant Unit #1 ZP26 Kern 1,645 267.00 0.116
Russell City Energy Center NP15 Alameda 1,365 615.20 0.111
PANOCHE ENERGY CENTER (Aggregated) ZP26 Fresno 1,069 417.00 0.098
HIGH DESERT POWER PROJECT AGGREGATE SP15 San Bernardino 728 850.00 0.080
BADGER CREEK LIMITED ZP26 Kern 1,272 48.10 0.074
LA PALOMA GENERATING PLANT, UNIT #4 ZP26 Kern 1,640 267.00 0.073
KERN FRONT LIMITED ZP26 Kern 1,306 52.40 0.065
King City Energy Center, Unit 1 ZP26 Monterey 735 44.60 0.064

The roster is Kern’s Central Valley fleet, San Diego’s peakers and combined-cycle plants, Contra Costa’s CCGTs, Alameda’s Russell City, San Bernardino’s Mira Loma and Mountainview, plus Mammoth’s geothermal in Mono County - gas turbines and combined-cycle plants almost throughout.

3. The Heat Signal Lives in the Depth, Not the Count#

County-day CDD65 barely correlates with the count of ambient filings - the near-daily filers file regardless. What temperature moves is how many MW each filing declares lost. Pooled first, over county-days with at least one ambient filing:

ambient_county_daily = (
    ambient_with_weather.groupby(["date", "region", "county"])
    .agg(
        ambient_starts=("outage_mrid_str", "nunique"),
        ambient_mw=("max_curtailment_mw", "sum"),
        cdd65=("cdd65", "first"),
    )
    .reset_index()
)
pooled_depth = pd.DataFrame(
    [
        {
            "county-day quantity": "ambient filing count",
            "corr with CDD65": round(
                ambient_county_daily["cdd65"].corr(ambient_county_daily["ambient_starts"]), 3
            ),
        },
        {
            "county-day quantity": "summed ambient derate MW",
            "corr with CDD65": round(
                ambient_county_daily["cdd65"].corr(ambient_county_daily["ambient_mw"]), 3
            ),
        },
    ]
)
html_table(pl.from_pandas(pooled_depth))
2 rows × 2 columns
county-day quantity corr with CDD65
ambient filing count 0.123
summed ambient derate MW 0.152

Then the same relationship one generator at a time, for filers with at least 200 ambient starts:

roster = filer_depth.sort_values("corr_mw_temperature")
roster_labels = (roster["resource_name"] + " (" + roster["county"] + ")").tolist()
roster_source = ColumnDataSource(
    {
        "generator": roster_labels,
        "correlation": roster["corr_mw_temperature"].tolist(),
        "starts": roster["starts"].tolist(),
        "color": [
            SERIES[0] if value > 0 else SERIES[7]
            for value in roster["corr_mw_temperature"]
        ],
    }
)
fig = figure(
    title="Derate Depth vs County Temperature by Generator",
    y_range=roster_labels,
    x_axis_label="Correlation of Filed Derate MW With County Temperature",
    height=640,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
fig.hbar(y="generator", right="correlation", height=0.7, color="color",
         source=roster_source)
fig.add_layout(Span(location=0, dimension="height", line_color="black", line_width=1))
fig.add_tools(
    HoverTool(tooltips=[("generator", "@generator"),
              ("correlation", "@correlation{0.000}"), ("starts", "@starts{0,0}")])
)
show(style(fig))

The two strongest bars, point by point - each dot one of that unit’s ambient events:

exemplars = filer_depth[filer_depth["starts"] >= 300].head(2)

fig = figure(
    title="Derate Depth Against County Temperature, Two Exemplar Units",
    x_axis_label="County Daily Mean Temperature (F)",
    y_axis_label="Filed Derate (MW)",
    height=380,
    sizing_mode="stretch_width",
    toolbar_location="above",
)
renderers = []
for color, unit in zip(SERIES, exemplars["resource_name"]):
    unit_events = ambient_with_weather[
        ambient_with_weather["resource_name"] == unit
    ]
    renderers.append(
        fig.scatter(
            unit_events["temperature_f_mean"].to_numpy(),
            unit_events["max_curtailment_mw"].to_numpy(),
            color=color, size=6, alpha=0.5, legend_label=unit, name=unit,
        )
    )
fig.add_tools(
    HoverTool(renderers=renderers, tooltips=[("unit", "$name"),
              ("temperature (F)", "@x{0.0}"), ("derate MW", "@y{0.0}")])
)
fig.legend.location = "top_left"
show(style(fig))
  • Per unit, depth tracks temperature closely. The twin Henrietta peaker units in Kings County track county temperature at 0.936 and 0.934, Mammoth’s geothermal unit at 0.67, and a band of Valley and San Diego peakers between 0.4 and 0.6. The big CCGTs are weaker over five years than over one (Gateway 0.47, Marsh Landing 0.15 to 0.21): their filings mix seasonal maintenance into the temperature signal.

  • Pooled, it washes out. County-day CDD65 reaches only 0.12 against ambient filing counts and 0.15 against summed derate MW.

  • A few units deepen in cold instead. San Diego’s MMC Escondido aggregate (-0.43) and El Cajon (-0.27) file their largest derates on cold days; Alameda’s two GT units flatten to about -0.05 over five years. The mechanism is not settled here.

4. A Reason-Aware Count Target Does Not Rescue Weather#

County-day correlations of the weather features against every outcome measure, with no-filing days counted as zero ambient MW:

ambient_mw_daily = (
    ambient_events.groupby(["date", "region", "county"])["max_curtailment_mw"]
    .sum()
    .rename("ambient_mw_sum")
    .reset_index()
)
correlation_frame = county_panel.merge(
    ambient_mw_daily, on=["date", "region", "county"], how="left"
)
correlation_frame["ambient_mw_sum"] = correlation_frame["ambient_mw_sum"].fillna(0.0)

CORRELATION_VARIABLES = {
    "CDD65": "cdd65",
    "HDD65": "hdd65",
    "temperature dispersion": "temperature_f_std",
    "all forced starts": "Y",
    "ambient starts": "ambient_temperature_start_count",
    "plant-trouble starts": "plant_trouble_start_count",
    "forced MW": "forced_start_mw_sum",
    "ambient MW": "ambient_mw_sum",
}
correlation_matrix = correlation_frame[list(CORRELATION_VARIABLES.values())].corr()
correlation_square = pl.DataFrame(
    {"variable": list(CORRELATION_VARIABLES)}
    | {
        label: correlation_matrix[code].tolist()
        for label, code in CORRELATION_VARIABLES.items()
    }
)
corr_heatmap(correlation_square, "variable",
             "County-Day Correlations: Weather Against the Outcome Family")
County-Day Correlations: Weather Against the Outcome Family

CDD65 tops out at 0.08 against every outcome, HDD65 is mildly negative against all five, and only temperature dispersion registers (0.10 against all forced starts, 0.14-0.21 against the ambient measures). CDD65 against HDD65 at -0.55 is definitional; ambient starts against all forced starts at 0.89 follows from ambient filings being half of \(Y\). The block tests make the same point on the model’s own likelihood:

ambient_block_tests = likelihood_ratio_block_tests(
    county_panel,
    feature_cols=COUNTY_DAILY_FEATURES,
    feature_blocks=county_feature_blocks,
    target_col="ambient_temperature_start_count",
    high_count_quantile=0.90,
    maxiter=300,
)
trouble_block_tests = likelihood_ratio_block_tests(
    county_panel,
    feature_cols=COUNTY_DAILY_FEATURES,
    feature_blocks=county_feature_blocks,
    target_col="plant_trouble_start_count",
    high_count_quantile=0.90,
    maxiter=300,
)
block_lr_by_target = (
    county_block_tests[["block", "lr_chi2"]]
    .rename(columns={"lr_chi2": "all forced starts (Y)"})
    .merge(
        ambient_block_tests[["block", "lr_chi2"]].rename(
            columns={"lr_chi2": "ambient-temperature starts"}
        ),
        on="block",
    )
    .merge(
        trouble_block_tests[["block", "lr_chi2"]].rename(
            columns={"lr_chi2": "plant-trouble starts"}
        ),
        on="block",
    )
    .round(1)
)
html_table(pl.from_pandas(block_lr_by_target))
6 rows × 4 columns
block all forced starts (Y) ambient-temperature starts plant-trouble starts
outage state 31,615.90 21,725.40 2,989.80
size 5,570.60 2,502.30 2,770.20
zone effects 1,665.40 1,301.00 204.90
calendar 168.40 176.30 182.10
weather 47.40 845.10 80.80
load 18.70 115.00 59.50

Block importance (lr_chi2) for three count targets, same features and split throughout. The ambient-temperature count is persistence again: its weather block rises to 845 (from 47 on total \(Y\)) while persistence rises to 21,725, because the near-daily filers file tomorrow whenever they filed today. Plant trouble is a fleet story - size and persistence roughly tied, weather real but small.

Findings#

  • Half of matched forced starts are routine ambient-temperature capacity filings rather than equipment failures.

  • The heat signal lives in filed derate MW per unit, not in county-day counts: the temperature response is a depth phenomenon.

  • Re-cutting the count target by reason does not rescue weather - persistence still dominates every reason-specific target.

  • The next weather-outage model is unit-level derate depth: filed MW as a function of temperature per generator - the physical supply channel behind weather-price coincidence.