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))
| 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,
)
| 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))
| 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")
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))
| 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.