Climate-mode indices: PDO, ENSO, AO (NOAA NCEI / CPC / PSL)#

Climate-mode indices are monthly series, one number per month, that summarize a large-scale pattern: the Pacific Decadal Oscillation (PDO), the El Niño-Southern Oscillation (ENSO), the Arctic and North Atlantic Oscillations (AO, NAO), the Pacific/North American pattern (PNA) and the Atlantic Multidecadal Oscillation (AMO). They are the usual first explanation for why one winter’s snowpack is not like the next.

Three NOAA offices publish them:

  • CPC, the Climate Prediction Center, part of the National Weather Service, which issues ENSO advisories and keeps the operational ENSO, AO, NAO and PNA series;

  • NCEI, the National Centers for Environmental Information, NOAA’s data archive, which keeps the ERSSTv5 sea-surface-temperature record and the PDO and AMO computed from it;

  • PSL, the Physical Sciences Laboratory in Boulder, a research lab that maintains MEI.v2 and a widely used page of copies of many indices in one format (its “correlation page”).

The same name covers several different series, so source="noaa" (the default) reads each index from the office that maintains it: the PDO and AMO from NCEI, ENSO, AO, NAO and PNA from CPC, and MEI.v2 from PSL. For ENSO that means RONI, the relative Oceanic Niño Index, which CPC made its official index in February 2026; the legacy ONI is still published. source="psl" reads PSL’s correlation-page copies, for reproducing older work. No account is needed for either.

The first figure is the PDO, RONI and AO since 1950. The second shows why the source matters: two “PDO” series and the two ENSO indices, side by side. The third ties the indices to snow, with April 1 snow water equivalent at the Paradise SNOTEL station on Mount Rainier against each winter’s mean RONI and PDO. The last two widen that to every long-record SNOTEL and CDEC station in the western US, where ENSO’s influence flips sign between north and south.

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from matplotlib.colors import LinearSegmentedColormap

import easysnowdata as esd

print(f"easysnowdata {esd.__version__}")

# Warm/cool poles for an index's sign, and a gray for neutral winters.
WARM, COOL, NEUTRAL = "#e34948", "#2a78d6", "#7a7a75"
# Two routes or two series compared on one axis: the first two categorical hues.
FIRST, SECOND = "#2a78d6", "#eb6834"
easysnowdata 0.3.3.dev85+gad4995575

Where the product comes from, and what each route asks for.

for src in esd.catalog.get("climate-indices").sources:
    print(f"{src.id:6} {src.title:40} {', '.join(src.requires) or 'no account'}")
noaa   NOAA CPC / NCEI / PSL text files         no account
psl    NOAA PSL correlation-page files          no account

What the default route offers and which file each index comes from. Nothing is downloaded until load.

print(esd.climate.indices.search()[["provider", "cadence", "url"]].to_string())
          provider                                      cadence                                                                                                     url
index
pdo      NOAA NCEI                                      monthly                                  https://www.ncei.noaa.gov/pub/data/cmb/ersst/v5/index/ersst.v5.pdo.dat
roni      NOAA CPC  3-month season, stamped at the centre month                                               https://www.cpc.ncep.noaa.gov/data/indices/RONI.ascii.txt
oni       NOAA CPC  3-month season, stamped at the centre month                                                https://www.cpc.ncep.noaa.gov/data/indices/oni.ascii.txt
nino34    NOAA CPC                                      monthly                                     https://www.cpc.ncep.noaa.gov/data/indices/detrend.nino34.ascii.txt
rnino34   NOAA CPC                                      monthly                                            https://www.cpc.ncep.noaa.gov/data/indices/Rnino34.ascii.txt
mei       NOAA PSL   2-month season, stamped at the later month                                                           https://psl.noaa.gov/enso/mei/data/meiv2.data
ao        NOAA CPC                                      monthly  https://www.cpc.ncep.noaa.gov/products/precip/CWlink/daily_ao_index/monthly.ao.index.b50.current.ascii
nao       NOAA CPC                                      monthly           https://www.cpc.ncep.noaa.gov/products/precip/CWlink/pna/norm.nao.monthly.b5001.current.ascii
pna       NOAA CPC                                      monthly           https://www.cpc.ncep.noaa.gov/products/precip/CWlink/pna/norm.pna.monthly.b5001.current.ascii
amo      NOAA NCEI                                      monthly                                  https://www.ncei.noaa.gov/pub/data/cmb/ersst/v5/index/ersst.v5.amo.dat

Every index since 1950, as one monthly Dataset. A 3-month-season index (RONI, ONI) is stamped at its centre month, so the value for January is the December-February mean. Each variable’s latest attribute says where its file ends; CPC’s ENSO files run a month behind the others.

idx_ds = esd.climate.indices.load(time="1950/..")
print(idx_ds)
print({name: idx_ds[name].attrs["latest"] for name in idx_ds.data_vars})
<xarray.Dataset> Size: 74kB
Dimensions:  (time: 921)
Coordinates:
  * time     (time) datetime64[us] 7kB 1950-01-01 1950-02-01 ... 2026-09-01
Data variables:
    pdo      (time) float64 7kB -1.61 -2.17 -1.89 -1.99 ... -2.16 -1.89 -1.55
    roni     (time) float64 7kB -1.19 -1.08 -0.96 -1.0 ... 0.97 1.36 1.69 nan
    oni      (time) float64 7kB -1.32 -1.2 -1.12 -1.08 ... 1.39 1.8 2.16 nan
    nino34   (time) float64 7kB -1.33 -1.46 -0.8 -1.09 ... 1.47 1.78 2.17 2.56
    mei      (time) float64 7kB nan nan nan nan nan ... 0.27 1.52 2.41 2.54 2.37
    ao       (time) float64 7kB -0.0603 0.6268 -0.0081 ... 0.3035 -0.056 1.156
    nao      (time) float64 7kB 0.92 0.4 -0.36 0.73 ... -0.3134 -1.916 0.5334
    pna      (time) float64 7kB -2.34 -1.04 0.24 0.01 ... 0.6149 -0.0797 -0.6415
    amo      (time) float64 7kB -0.03 -0.13 -0.16 -0.22 ... 0.68 0.95 1.19 1.25
Attributes:
    source:                noaa
    source_id:             noaa
    source_title:          NOAA CPC / NCEI / PSL text files
    source_url:            https://github.com/egagli/geospatial_data_and_visu...
    product_id:            climate-indices
    title:                 Climate-mode indices: PDO, ENSO, AO, NAO, PNA, AMO...
    data_citation:         NOAA Climate Prediction Center (RONI, ONI, Niño 3....
    license:               public domain (U.S. Government work)
    easysnowdata_version:  0.3.3.dev85+gad4995575
    indices:               pdo roni oni nino34 mei ao nao pna amo
{'pdo': '2026-09', 'roni': '2026-08', 'oni': '2026-08', 'nino34': '2026-09', 'mei': '2026-09', 'ao': '2026-09', 'nao': '2026-09', 'pna': '2026-09', 'amo': '2026-09'}

The PDO, RONI and AO since 1950, shaded warm above zero and cool below. The PDO’s long phases stand out (negative 1950-1976, positive 1977-1998, and mostly negative since), as do the big El Niños of 1982-83, 1997-98, 2015-16 and 2023-24.

fig, axes = plt.subplots(3, 1, figsize=(10, 7.5), sharex=True)
for ax, name in zip(axes, ["pdo", "roni", "ao"], strict=True):
    series = idx_ds[name].to_series().dropna()
    esd.plotting.timeseries(
        idx_ds,
        ax=ax,
        variable=name,
        legend=False,
        title=idx_ds[name].attrs["long_name"],
        color="0.35",
        linewidth=0.5,
    )
    ax.fill_between(series.index, series, 0, where=series > 0, color=WARM, lw=0)
    ax.fill_between(series.index, series, 0, where=series < 0, color=COOL, lw=0)
    ax.axhline(0, color="0.2", linewidth=0.6)
    ax.set_xlabel("")
    ax.set_ylabel(f"{name.upper()} [{idx_ds[name].attrs['units']}]")
fig.tight_layout()
Pacific Decadal Oscillation index, Relative Oceanic Niño Index (CPC's official ENSO index since 2026-02), Arctic Oscillation index

Two routes to “the PDO”. PSL’s pdo.data is a different series from NCEI’s, built a different way, and it stopped at 2025-08 (load logs a warning when a file has gone stale). The two agree on the phases but not on the month: they correlate at about 0.9 and can differ by more than one unit.

psl_ds = esd.climate.indices.load(time="1950/..", indices="pdo", source="psl")
both_df = pd.DataFrame(
    {
        "NCEI ERSSTv5": idx_ds["pdo"].to_series(),
        "PSL pdo.data": psl_ds["pdo"].to_series(),
    }
).dropna()
difference = both_df["NCEI ERSSTv5"] - both_df["PSL pdo.data"]
print(f"r = {both_df.corr().iloc[0, 1]:.2f} over {len(both_df)} months")
print(
    f"largest monthly difference {difference.abs().max():.2f} ({difference.abs().idxmax():%Y-%m})"
)
print("PSL file ends", psl_ds["pdo"].attrs["latest"])
pdo from source 'psl' ends 2025-08 and is no longer updated; source='noaa' has a current series.
r = 0.92 over 908 months
largest monthly difference 2.28 (1950-08)
PSL file ends 2025-08

Two ENSO indices from the same office. The ONI measures Niño 3.4 against a fixed climatology, so a warming ocean pushes it up; RONI subtracts the tropical-mean anomaly first. They now disagree by about half a degree, and they can disagree about the phase itself: a winter that is “El Niño” by one can be neutral by the other. Classify winters from the index you name, and say which.

recent = slice("2000-01-01", None)
fig, axes = plt.subplots(2, 1, figsize=(10, 6.2), sharex=False)
axes[0].plot(
    both_df.index, both_df["NCEI ERSSTv5"], color=FIRST, lw=1, label="NCEI ERSSTv5"
)
axes[0].plot(
    both_df.index, both_df["PSL pdo.data"], color=SECOND, lw=1, label="PSL pdo.data"
)
axes[0].set_title("PDO: two series with one name")
axes[0].set_ylabel("PDO [1]")
for name, color in (("oni", SECOND), ("roni", FIRST)):
    series = idx_ds[name].sel(time=recent).to_series().dropna()
    axes[1].plot(series.index, series, color=color, lw=1.2, label=name.upper())
axes[1].axhspan(-0.5, 0.5, color="#f0efec", zorder=0, label="neutral band (±0.5 °C)")
axes[1].set_title("ENSO: the legacy ONI and CPC's official RONI")
axes[1].set_ylabel("anomaly [°C]")
for ax in axes:
    ax.axhline(0, color="0.2", linewidth=0.6)
    ax.legend(loc="lower left", fontsize=8, frameon=False, ncol=3)
fig.tight_layout()

oni_phase = esd.climate.indices.enso_phase(idx_ds["oni"])
roni_phase = esd.climate.indices.enso_phase(idx_ds["roni"])
phases_df = pd.DataFrame({"ONI": oni_phase, "RONI": roni_phase}).dropna()
winters_df = phases_df[phases_df.index.month == 1]
disagree_df = winters_df[winters_df["ONI"] != winters_df["RONI"]]
print(
    f"{len(disagree_df)} of {len(winters_df)} DJF seasons since 1950 get a different phase:"
)
print(disagree_df.set_axis(disagree_df.index.year).to_string())
PDO: two series with one name, ENSO: the legacy ONI and CPC's official RONI
12 of 77 DJF seasons since 1950 get a different phase:
          ONI     RONI
time
1951  La Niña  neutral
1954  neutral  El Niño
1965  La Niña  neutral
1972  La Niña  neutral
1975  La Niña  neutral
1988  El Niño  neutral
1993  neutral  El Niño
2015  El Niño  neutral
2017  neutral  La Niña
2020  El Niño  neutral
2025  neutral  La Niña
2026  neutral  La Niña

Now the snow. seasonal_mean averages each index over November-March and labels the result by water year, so a winter is one row. enso_phase labels each winter by RONI with CPC’s rule (five overlapping seasons beyond ±0.5 °C), read at DJF.

The SWE comes from the daily station archive (easysnowdata.stations.archive) rather than from the networks’ own APIs: one request covers every station in the western US, and the build does not depend on the NRCS server answering. The stations kept are the AWDB (SNOTEL) and CDEC sites whose daily record starts by water year 1996, so each can have 30 winters; the Paradise SNOTEL station on Mount Rainier (679_WA_SNTL) is one of them.

winter_df = esd.climate.indices.seasonal_mean(idx_ds[["roni", "pdo", "pna", "ao"]])
djf_phase = roni_phase[roni_phase.index.month == 1]
djf_phase.index = djf_phase.index.year

WUS = (-125.0, 31.0, -102.0, 49.0)  # the western US, (W, S, E, N)
wus_gdf = esd.stations.archive.inventory(
    WUS, networks=["awdb", "cdec"], daily_only=True
)
wus_gdf = wus_gdf[wus_gdf["earliest_record_date"] <= "1995-10-01"]
wus_ds = esd.stations.archive.load(
    wus_gdf, variables="swe", time="1980-10-01/2026-06-30"
)
is_april1 = (wus_ds["time"].dt.month == 4) & (wus_ds["time"].dt.day == 1)
april1_df = wus_ds["swe"].sel(time=is_april1).to_pandas().T  # water year x station
april1_df.index = april1_df.index.year
print(
    f"{april1_df.shape[1]} stations, water years {april1_df.index.min()}-{april1_df.index.max()}"
)

april1 = april1_df["679_WA_SNTL"].dropna()
snow_df = winter_df.join(april1.rename("swe_cm"), how="inner").join(
    djf_phase.rename("phase")
)
print(
    f"Paradise: {len(snow_df)} water years, {snow_df.index.min()}-{snow_df.index.max()}"
)
792 stations, water years 1981-2026
Paradise: 44 water years, 1981-2026

April 1 SWE against the winter’s mean RONI and PDO. Both lean the same way: cool winters (La Niña, negative PDO) leave more snow at Paradise. What is not obvious is that the PDO’s correlation is as strong as ENSO’s, and so is the PNA’s, so “it was an El Niño year” is only part of the story. One station and 44 winters is a small sample: the correlations are about -0.4, real but loose, and the spread within each phase is wide.

styles = {
    "El Niño": dict(color=WARM, marker="^"),
    "neutral": dict(color=NEUTRAL, marker="o"),
    "La Niña": dict(color=COOL, marker="v"),
}
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.6), sharey=True)
for ax, name, xlabel in (
    (axes[0], "roni", "Nov-Mar mean RONI [°C]"),
    (axes[1], "pdo", "Nov-Mar mean PDO [1]"),
):
    for phase, style in styles.items():
        rows = snow_df[snow_df["phase"] == phase]
        ax.scatter(
            rows[name],
            rows["swe_cm"],
            s=42,
            edgecolor="white",
            linewidth=0.8,
            label=f"{phase} ({len(rows)})",
            **style,
        )
    slope, intercept = np.polyfit(snow_df[name], snow_df["swe_cm"], 1)
    xs = np.linspace(snow_df[name].min(), snow_df[name].max(), 2)
    ax.plot(xs, intercept + slope * xs, color="0.3", lw=1, ls="--")
    r = snow_df[[name, "swe_cm"]].corr().iloc[0, 1]
    ax.set_title(f"r = {r:.2f}", loc="left", fontsize=10)
    ax.axvline(0, color="0.75", lw=0.6)
    ax.set_xlabel(xlabel)
axes[0].set_ylabel("April 1 SWE at Paradise [cm]")
axes[0].legend(title="ENSO phase (RONI, DJF)", fontsize=8, frameon=False)
fig.suptitle(
    "Winter climate modes and April 1 snowpack, Paradise SNOTEL, Mount Rainier"
)
fig.tight_layout()
Winter climate modes and April 1 snowpack, Paradise SNOTEL, Mount Rainier, r = -0.35, r = -0.40

The numbers behind the figure: each index’s correlation with April 1 SWE, and the mean April 1 SWE by ENSO phase.

print(
    snow_df.drop(columns="phase").corr()["swe_cm"].drop("swe_cm").round(2).to_string()
)
print(snow_df.groupby("phase")["swe_cm"].agg(["mean", "count"]).round(1).to_string())
roni   -0.35
pdo    -0.40
pna    -0.39
ao      0.30
          mean  count
phase
El Niño  149.7     12
La Niña  182.0     19
neutral  173.5     13

One station is one point on a much larger pattern. The same correlation for every long-record station in the western US, keeping stations with at least 30 April 1 values and a median April 1 SWE of 5 cm or more (below that, a zero most years says more about melt timing than about the winter).

enough = (april1_df.notna().sum() >= 30) & (april1_df.median() >= 5)
april1_df = april1_df.loc[:, enough]
winters_wus_df = winter_df.reindex(april1_df.index)
r_gdf = wus_gdf.loc[april1_df.columns, ["name", "network", "latitude", "geometry"]]
for name in ("roni", "pdo", "pna", "ao"):
    r_gdf[f"r_{name}"] = april1_df.corrwith(winters_wus_df[name])
# With about 45 winters, |r| above 0.29 is significant at the 5% level.
n_winters = int(april1_df.notna().sum().median())
r_crit = 1.96 / np.sqrt(n_winters - 3)
print(
    f"{len(r_gdf)} stations, median {n_winters} winters, |r| > {r_crit:.2f} at p < 0.05"
)
725 stations, median 45 winters, |r| > 0.30 at p < 0.05

How to read the colours. Each dot is one station, coloured by the correlation (r) between its April 1 SWE and the winter’s mean index.

  • Red (r > 0): April 1 SWE rises with the index. For RONI that means El Niño winters leave more snow and La Niña winters less; for the PDO, the positive (warm) phase leaves more.

  • Blue (r < 0): April 1 SWE falls as the index rises, so La Niña winters (or the negative, cool PDO) leave more snow and El Niño winters less.

  • Gray (r near 0): the index says little about that station’s snowpack.

The deeper the colour, the stronger the relationship; at ±0.6, about a third of the year-to-year variance in April 1 SWE goes with the index (r² ≈ 0.36). Red and blue match the warm and cool poles of the indices in the first figure.

The RONI map is the classic ENSO dipole. The Pacific Northwest and the northern Rockies are blue: La Niña winters are snowier there. Arizona, New Mexico and the southern Sierra Nevada are red: El Niño winters are snowier. Between them, from northern California across Nevada and Utah to Colorado, the stations are gray, and a seasonal outlook based on ENSO says little. The PDO map has the same shape with its pivot further north, so most of the Sierra Nevada and the central Rockies sit near zero.

r_cmap = LinearSegmentedColormap.from_list("r", [COOL, "#f0efec", WARM])
states_gdf = esd.boundaries.admin.states(WUS)
fig, axes = plt.subplots(1, 2, figsize=(12, 6.4))
for ax, name in zip(axes, ["roni", "pdo"], strict=True):
    esd.plotting.points(
        r_gdf.sort_values(f"r_{name}", key=abs),  # strongest on top
        ax=ax,
        column=f"r_{name}",
        legend_label=f"r, April 1 SWE vs Nov-Mar {name.upper()}",
        cmap=r_cmap,
        vmin=-0.6,
        vmax=0.6,
        markersize=16,
        edgecolor="0.25",
        linewidth=0.3,
        title=f"{name.upper()} and April 1 SWE, {len(r_gdf)} stations",
    )
    esd.plotting.add_outline(ax, states_gdf, edgecolor="0.35", linewidth=0.5)
fig.tight_layout()
fig.text(
    0.5,
    0.04,
    "Red: more April 1 snow when the index is positive (El Niño, warm PDO).   "
    "Blue: more snow when it is negative (La Niña, cool PDO).   "
    "Gray: little relationship.",
    ha="center",
    fontsize=9,
    color="0.25",
)
RONI and April 1 SWE, 725 stations, PDO and April 1 SWE, 725 stations
Text(0.5, 0.04, 'Red: more April 1 snow when the index is positive (El Niño, warm PDO).   Blue: more snow when it is negative (La Niña, cool PDO).   Gray: little relationship.')

The same correlations against latitude, which is most of the story: the median crosses zero near 40-41°N for RONI and near 43°N for the PDO, so the stations above the zero line (red, El Niño-snowy) are almost all in the south and those below it (blue, La Niña-snowy) in the north. Points outside the gray band are significant one station at a time; the stations are not independent, so the band says which ones are worth a look rather than how many “found” an effect.

bins = np.arange(31, 50, 1.5)
fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.4), sharey=True)
for ax, name in zip(axes, ["roni", "pdo"], strict=True):
    column = r_gdf[f"r_{name}"]
    ax.axhspan(
        -r_crit, r_crit, color="#f0efec", zorder=0, label="not significant (p ≥ 0.05)"
    )
    ax.scatter(
        r_gdf["latitude"],
        column,
        c=column,
        cmap=r_cmap,
        vmin=-0.6,
        vmax=0.6,
        s=14,
        edgecolor="0.25",
        linewidth=0.3,
    )
    by_band = column.groupby(pd.cut(r_gdf["latitude"], bins)).median()
    centres = [interval.mid for interval in by_band.index]
    ax.plot(centres, by_band.values, color="0.15", lw=1.6, label="median by 1.5° band")
    ax.axhline(0, color="0.2", linewidth=0.6)
    ax.set_xlabel("latitude [°N]")
    ax.set_title(f"Nov-Mar {name.upper()}", loc="left", fontsize=10)
axes[0].set_ylabel("r with April 1 SWE")
axes[0].legend(loc="lower left", fontsize=8, frameon=False)
fig.suptitle("Where the climate modes matter for April 1 snowpack")
fig.tight_layout()
Where the climate modes matter for April 1 snowpack, Nov-Mar RONI, Nov-Mar PDO

The numbers, by latitude band: the median correlation with each index and the number of stations. The PNA tracks ENSO in the north (it is the atmospheric bridge between the two), and the AO says little anywhere.

bands = pd.cut(r_gdf["latitude"], [31, 36, 39, 42, 45, 49])
summary_df = r_gdf.groupby(bands, observed=True)[
    ["r_roni", "r_pdo", "r_pna", "r_ao"]
].median()
summary_df["stations"] = r_gdf.groupby(bands, observed=True).size()
print(summary_df.round(2).to_string())
          r_roni  r_pdo  r_pna  r_ao  stations
latitude
(31, 36]    0.52   0.25   0.21 -0.13         8
(36, 39]    0.13   0.04   0.01 -0.07       189
(39, 42]    0.00   0.04  -0.02 -0.04       211
(42, 45]   -0.18  -0.00  -0.16  0.06       163
(45, 49]   -0.33  -0.17  -0.33  0.13       154

Total running time of the script: (0 minutes 8.066 seconds)