Produces the input for the red line in Oliver et al. (2018) Fig. 2, the globally averaged MHW days after removing the signature of ENSO.
The paper’s method (Methods, “Removing ENSO”)¶
We removed the influence of ENSO from the SST time series, before the MHW detection, using a statistical approach. We first estimated the ENSO signal at each pixel by regressing daily SSTs onto the multivariate ENSO index (MEI) and subtracted the linear prediction based on this model. We included monthly leads and lags of the MEI up to ±1 year, into a multiple linear regression model. The MEI is defined monthly as a 2-month average (Dec–Jan, Jan–Feb, etc.) and we assumed the monthly values to be centred on the middle of second month.
Two details from that paragraph drive the implementation:
±1 year of monthly leads and lags → 25 MEI predictors (−12 … +12) plus an intercept.
Detection afterwards uses the ORIGINAL climatology and threshold, not one recomputed from the ENSO-less series. The paper is explicit about why: “what we consider MHWs, and what ecosystems are adapted to, are based on the real-world threshold”. That happens in
03_analysis.pyunderMHW_ENSO_REMOVED=1; this notebook only produces the ENSO-less SST.
Deviation from the paper¶
The paper used the original MEI (Wolter & Timlin). NOAA PSL froze that index
in 2018 and now maintains MEI.v2, computed from a different variable set over
a different base period. We use the original MEI, still published at
psl.noaa.gov/enso/mei.old/, because matching the paper’s index matters more
here than using the current one — its last update (Dec 2018) postdates the
paper, so it is the same data the authors had.
import io
import os
import re
import urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
import xarray as xr
TARGET_RES_DEG = float(os.environ.get("MHW_TARGET_RES_DEG", 1.0))
MEI_URL = "https://psl.noaa.gov/enso/mei.old/table.html"
MAX_LAG_MONTHS = 12 # ±1 year, per the paper
PROC_DIR = Path("../data/processed")
RAW_DIR = Path("../data/raw")
IN_PATH = PROC_DIR / f"sst_clean_{TARGET_RES_DEG:g}deg.nc"
OUT_PATH = PROC_DIR / f"sst_enso_removed_{TARGET_RES_DEG:g}deg.nc"
MEI_PATH = RAW_DIR / "mei_original.csv"
# Latitude rows per chunk. The regression is cheap; holding the full cube is
# not (12784 x 180 x 336 float32 is ~3 GB, doubled while predicting).
LAT_CHUNK = int(os.environ.get("MHW_ENSO_LAT_CHUNK", 20))Fetch and parse the MEI¶
Self-contained, like every other input here: no manual download step.
if MEI_PATH.exists():
mei_monthly = pd.read_csv(MEI_PATH, index_col=0, parse_dates=True)["mei"]
print(f"MEI: reusing {MEI_PATH}")
else:
with urllib.request.urlopen(MEI_URL, timeout=60) as fh:
html = fh.read().decode("utf-8", errors="replace")
# Rows are: YEAR then 12 bimonthly values (DECJAN ... NOVDEC).
rows = []
for line in html.splitlines():
m = re.match(r"^\s*(19|20)(\d{2})\s+((?:-?\.?\d[\d.]*\s*){12})$", line)
if m:
year = int(m.group(1) + m.group(2))
vals = [float(v) for v in m.group(3).split()]
rows.append((year, vals))
if not rows:
raise RuntimeError(f"could not parse any MEI rows from {MEI_URL}")
# "we assumed the monthly values to be centred on the middle of second
# month": DECJAN -> mid-January, JANFEB -> mid-February, ... NOVDEC -> mid-December.
index, values = [], []
for year, vals in rows:
for month, v in enumerate(vals, start=1):
index.append(pd.Timestamp(year=year, month=month, day=15))
values.append(v)
mei_monthly = pd.Series(values, index=pd.DatetimeIndex(index), name="mei").sort_index()
MEI_PATH.parent.mkdir(parents=True, exist_ok=True)
mei_monthly.to_frame().to_csv(MEI_PATH)
print(f"MEI: parsed {len(mei_monthly)} bimonthly values "
f"({mei_monthly.index[0].date()} .. {mei_monthly.index[-1].date()})")MEI: reusing ../data/raw/mei_original.csv
Build the design matrix¶
One column per monthly lead/lag of the MEI, each interpolated to the daily SST time axis, plus an intercept.
sst = xr.open_dataarray(IN_PATH)
time = pd.DatetimeIndex(sst.time.values)
print(f"SST: {dict(sst.sizes)} {time[0].date()} .. {time[-1].date()}")
lags = range(-MAX_LAG_MONTHS, MAX_LAG_MONTHS + 1)
columns = []
for lag in lags:
shifted = mei_monthly.copy()
# A positive lag shifts the index FORWARD, so the MEI value from `lag`
# months earlier lines up with the SST date being predicted.
shifted.index = shifted.index + pd.DateOffset(months=lag)
# Monthly -> daily by linear interpolation onto the SST time axis.
col = shifted.reindex(shifted.index.union(time)).interpolate("time").reindex(time)
columns.append(col.to_numpy(dtype="float64"))
X = np.column_stack([np.ones(len(time))] + columns) # intercept first
n_pred = X.shape[1]
if not np.isfinite(X).all():
bad = np.where(~np.isfinite(X).all(axis=1))[0]
raise RuntimeError(
f"design matrix has {len(bad)} non-finite row(s) — the MEI does not "
f"cover {time[bad[0]].date()} .. {time[bad[-1]].date()} with ±"
f"{MAX_LAG_MONTHS} months of padding"
)
print(f"design matrix: {X.shape[0]} days x {n_pred} predictors "
f"(intercept + {len(list(lags))} MEI lags {lags.start}..{lags.stop - 1})")
# Solve once: the design matrix is identical for every cell.
XtX_inv_Xt = np.linalg.solve(X.T @ X, X.T) # (n_pred, n_time)SST: {'time': 12784, 'latitude': 180, 'longitude': 336} 1982-01-01 .. 2016-12-31
design matrix: 12784 days x 26 predictors (intercept + 25 MEI lags -12..12)
Remove the ENSO prediction, cell by cell¶
The intercept is deliberately retained. We subtract only the MEI terms, so
the mean and seasonal cycle survive untouched — detection in 03 then applies
the original threshold to this series, which would detect nothing if the
series had been recentred on zero.
Each chunk is written to its own file as soon as it lands, then the files are streamed into one at the end — the same pattern 01 uses for the download bands, and for the same reason. Accumulating the chunks in memory instead holds the whole ~3 GB cube and then doubles it while diffing, which gets the process OOM-killed on a 15 GB machine. It also makes this resumable.
CHUNK_DIR = PROC_DIR / f"enso_chunks_{TARGET_RES_DEG:g}deg"
CHUNK_DIR.mkdir(parents=True, exist_ok=True)
n_lat = sst.sizes["latitude"]
doy = time.dayofyear.to_numpy()
chunk_paths, ss_removed, n_removed, max_removed = [], 0.0, 0, 0.0
for lat0 in range(0, n_lat, LAT_CHUNK):
cpath = CHUNK_DIR / f"chunk_{lat0:05d}.nc"
chunk_paths.append(cpath)
if cpath.exists():
print(f" lat {lat0:3d}..{min(lat0 + LAT_CHUNK, n_lat):3d} cached", flush=True)
continue
block = sst.isel(latitude=slice(lat0, lat0 + LAT_CHUNK)).load()
arr = block.values.reshape(block.sizes["time"], -1).astype("float64")
ocean = np.isfinite(arr).all(axis=0)
resid = arr.copy()
if ocean.any():
y = arr[:, ocean]
# Regress the ANOMALY, not the raw SST. The 25 MEI predictors span
# ±12 months, and lagged copies of a smooth series can be combined into
# an annual harmonic — so fitted against raw SST the model absorbs the
# seasonal cycle. Measured on the first version of this notebook, which
# did exactly that: 66% of the "ENSO signal" it removed was seasonal
# (3.67 °C of seasonal amplitude, against the SST's own 8.21 °C). The
# result mangled the series relative to the season-varying threshold and
# MORE than doubled detected MHW days, when removing ENSO should reduce
# them. Removing the seasonal cycle first confines the fit to
# interannual variability, which is what ENSO is.
clim = np.full((366, y.shape[1]), np.nan)
for d in range(1, 367):
sel = doy == d
if sel.any():
clim[d - 1] = y[sel].mean(axis=0)
anom = y - clim[doy - 1]
beta = XtX_inv_Xt @ anom # (n_pred, n_ocean)
enso = X[:, 1:] @ beta[1:, :] # MEI terms only
# Demean so the long-term mean and the trend are preserved exactly:
# only ENSO-driven *variability* is taken out.
enso -= enso.mean(axis=0, keepdims=True)
resid[:, ocean] = y - enso
# Accumulate diagnostics here; a whole-cube diff afterwards would need
# another copy of the cube.
ss_removed += float(np.sum(enso ** 2))
n_removed += enso.size
max_removed = max(max_removed, float(np.abs(enso).max()))
out = block.copy(data=resid.reshape(block.shape).astype("float32"))
out.name = "analysed_sst"
tmp = cpath.with_suffix(".nc.tmp")
out.to_netcdf(tmp, encoding={"analysed_sst": {"zlib": True, "complevel": 4}})
os.replace(tmp, cpath)
print(f" lat {lat0:3d}..{min(lat0 + LAT_CHUNK, n_lat):3d} "
f"{ocean.sum():5d} ocean cells -> {cpath.name}", flush=True)
del block, arr, resid, out
if n_removed:
print(f"\nremoved ENSO signal: rms {np.sqrt(ss_removed / n_removed):.4f} degC, "
f"max |.| {max_removed:.4f} degC") lat 0.. 20 cached
lat 20.. 40 cached
lat 40.. 60 cached
lat 60.. 80 cached
lat 80..100 cached
lat 100..120 cached
lat 120..140 cached
lat 140..160 cached
lat 160..180 cached
Combine and save¶
open_mfdataset keeps this lazy and to_netcdf streams it, so the full cube
is never resident.
combined = xr.open_mfdataset(
[str(p) for p in chunk_paths], combine="by_coords", engine="netcdf4"
)["analysed_sst"].sortby("latitude")
combined.attrs.update(
description="SST with the linear MEI prediction removed (Oliver et al. 2018 Methods)",
mei_source=MEI_URL,
mei_lags_months=f"-{MAX_LAG_MONTHS}..+{MAX_LAG_MONTHS}",
note="Intercept retained: only the MEI terms are subtracted, so the "
"original climatology and threshold remain applicable.",
)
print("combined:", dict(combined.sizes))
tmp = OUT_PATH.with_suffix(".nc.tmp")
combined.to_netcdf(tmp, encoding={"analysed_sst": {"zlib": True, "complevel": 4}})
os.replace(tmp, OUT_PATH)
print(f"wrote {OUT_PATH}")combined: {'time': 12784, 'latitude': 180, 'longitude': 336}
wrote ../data/processed/sst_enso_removed_1deg.nc