The 2026 Super El Nino — a climbing index and a warming ocean¶
By September 2026 this El Nino has become one of the strongest on record. NOAA declared El Nino conditions on 11 June 2026; the weekly Nino 3.4 index (centred 12 August) reached +2.7 °C, and forecasters put a greater-than-90% chance on a very strong event through the Northern Hemisphere fall/winter of 2026–27 — with a real chance it exceeds every El Nino on record back to 1950.
This notebook tells that story three ways with the earthlens climate-indices and erddap backends: the Oceanic Nino Index (ONI) climbing out of a shallow 2024–25 La Nina, a global sea-surface-temperature anomaly animation, and a Pacific-centred close-up — Asia and Australia on the west rim, the whole of North and South America on the east — at native resolution.
Needs
pyramids-gis[viz](cleopatra) for the animations. The ONI series and NOAA Coral Reef Watch SST-anomaly grid are both public — no credentials required.
Setup¶
pyramids supplies Dataset / DatasetCollection / merge_rasters for reading, mosaicking, and animating the SST-anomaly grid; cleopatra supplies the LineGlyph line-chart renderer, apply_blank_canvas plus the dark reference-map basemap for the animations, and the named DATA_STYLES colour presets; earthlens supplies the unified EarthLens entry point. Every plot in this notebook renders through pyramids or cleopatra — matplotlib.pyplot is only used for plt.show() / plt.close() housekeeping, never for drawing.
import base64
import time
import warnings
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from cleopatra.glyphs.base.animation import gif_from_video
from cleopatra.glyphs.primitives.line_glyph import LINE_DEFAULT_OPTIONS, LineGlyph
from cleopatra.styling.colors import DATA_STYLES, resolve_colormap
from cleopatra.styling.styles import apply_blank_canvas
from IPython.display import HTML
from loguru import logger
from pyramids.dataset import Dataset, GeoReference
from pyramids.dataset.collection import DatasetCollection
from pyramids.dataset.merge import merge_rasters
from pyramids.plot import ColorBar, FrameLabel
from earthlens.core import EarthLens
from earthlens.erddap._helpers import build_constraints, build_griddap_url
from earthlens.erddap.catalog import Catalog
warnings.filterwarnings("ignore")
logger.remove()
OUT = Path("out") / "el_nino_2026"
OUT.mkdir(parents=True, exist_ok=True)
# The overlay lockup carries its own navy scrim, built for watermarking over
# arbitrary/dark imagery -- see docs/_images/branding/earthlens-brand-kit/BRAND-GUIDE.md.
LOGO = Path(
"../../_images/branding/earthlens-brand-kit/logo/earthlens-lockup-stacked-overlay.png"
)
# LinkedIn's recommended single-image post size (1200x627, 1.91:1) at dpi=150.
SOCIAL_FIGSIZE = (8.0, 4.18)
1 · The Oceanic Nino Index, 2023–2026¶
The climate-indices backend pulls NOAA PSL's ONI series — a 3-month running mean of the Nino 3.4 SST anomaly. Fetching 2023 onward captures the full prior cycle for context: the 2023–24 El Nino peaking near +2.1 °C, its decay into a shallow 2024–25 La Nina, and this new event's climb.
oni_dir = OUT / "oni"
oni_dir.mkdir(parents=True, exist_ok=True)
oni_df = EarthLens(
data_source="climate-indices",
variables=["oni"],
start="2023-01-01",
end="2026-08-31",
path=oni_dir,
).download(progress_bar=False)
oni = oni_df.dropna(subset=["value"]).assign(date=lambda d: pd.to_datetime(d["date"]))
oni.tail()
Plot the index¶
Drawn with cleopatra's LineGlyph (Axes.plot under the hood, styled through the shared color_1/color_2/line_width options) rather than a bare matplotlib call. NOAA's ENSO strength bands (weak/moderate/strong/very strong) are added as reference lines on the glyph's own returned axes — the same "render with the library, adjust via the returned object" pattern used for the raster animations below. The smoothed 3-month ONI lags the raw weekly index — its latest published value understates just how fast this event is moving, so the annotation notes the raw +2.7 °C weekly reading alongside it.
crimson = LINE_DEFAULT_OPTIONS["color_2"]
line = LineGlyph(
oni["date"].to_numpy(), oni["value"].to_numpy(), figsize=(10, 4.5), line_width=1.8
)
fig, ax, _ = line.line(color=crimson)
ax.axhline(0, color="0.4", lw=0.8)
for level, band_label in [
(0.5, "weak"),
(1.0, "moderate"),
(1.5, "strong"),
(2.0, "very strong"),
]:
ax.axhline(level, color="0.75", lw=0.7, ls="--")
ax.text(oni["date"].iloc[0], level + 0.04, band_label, fontsize=7, color="0.5")
latest = oni.iloc[-1]
ax.scatter([latest["date"]], [latest["value"]], color=crimson, zorder=5)
ax.annotate(
f"{latest['value']:+.2f} \u00b0C ({latest['date']:%b %Y}, ONI 3-mo mean)\n"
"raw weekly Ni\u00f1o 3.4 already +2.7 \u00b0C by mid-Aug 2026",
xy=(latest["date"], latest["value"]),
xytext=(-210, 15),
textcoords="offset points",
fontsize=8,
arrowprops=dict(arrowstyle="->", color="0.3"),
)
ax.set(
ylabel="ONI (\u00b0C)",
title="Oceanic Ni\u00f1o Index, 2023\u20132026 \u2014 a fast new climb",
)
plt.show()
2 · Shared setup: sub-monthly source data, one fetch/animate pipeline¶
NOAA_DHW / CRW_SSTANOMALY is a native daily, 5 km grid — there is no monthly limitation in the source data at all; both animations below sample it far more densely than monthly, every 10 days. A full-resolution global day is ~25 million pixels (~200 MB): cleopatra's animation path builds a full pixel-index list per frame at plot time, so anything much past ~2 million pixels risks a bare MemoryError. The erddap facade always requests full resolution (no stride kwarg), so fetch_sst_anomaly below reuses earthlens's own griddap URL builder (earthlens.erddap._helpers) directly to ask the server to decimate before sending. Both animations cover the same window — from January 2025, well before this El Nino began (the prior shallow La Nina's trough, ONI around −0.45), through the most recent published day — which cannot yet reach the event's end: NOAA's forecast has it still strengthening into winter 2026–27, so decay hasn't happened yet in the observational record. Already-downloaded days are reused, so a rerun does not re-hit the server.
cleopatra ships named DATA_STYLES presets for dozens of geophysical variables, several of them purpose-built diverging anomaly palettes: anomaly (a plain RdBu_r), hot_cold, precipitation_anomaly, sea_level_anomaly, and temperature_anomaly — a dedicated, zero-centred 19-level colour map made specifically for temperature-anomaly fields, not a generic diverging scale reused across variables. That specificity is why it is the pick here. Its bounds are widened to ±5 °C to match the source product's own colorBarMinimum / colorBarMaximum metadata — red where the ocean is warmer than climatology, blue where it is cooler.
Both animations are rendered at SOCIAL_FIGSIZE × dpi=150 — 1200x627 px, LinkedIn's recommended single-image post size — regardless of each animation's own geographic aspect ratio, extending the social-post-sized convention aral_sea_shrinking.ipynb uses ("a larger, social-post-sized animated GIF"). Each is rendered once to .mp4 (ready for a native LinkedIn video post) and the .gif embedded below is derived from that file via cleopatra's gif_from_video, rather than re-rendering the whole animation a second time — FFmpeg's even-dimension requirement trims the odd 627 px height to 626, an imperceptible 1 px.
anomaly_cmap = resolve_colormap(
next(iter(DATA_STYLES["temperature_anomaly"].values()))["cmap"]
)
def fetch_sst_anomaly(dates, bbox, out_dir, stride=1):
"""Fetch one CRW_SSTANOMALY NetCDF per date, cached by filename."""
out_dir.mkdir(parents=True, exist_ok=True)
row = Catalog().get("NOAA_DHW")
paths = []
for day in dates:
nc_path = out_dir / f"{day}.nc"
if not nc_path.exists():
when = datetime.strptime(day, "%Y-%m-%d")
space = SimpleNamespace(
south=bbox["lat_lim"][0],
north=bbox["lat_lim"][1],
west=bbox["lon_lim"][0],
east=bbox["lon_lim"][1],
)
window = SimpleNamespace(start_date=when, end_date=when)
constraints = build_constraints(space, window, "griddap")
constraints["latitude_step"] = stride
constraints["longitude_step"] = stride
url = build_griddap_url(
row.server_url,
row.dataset_id,
["CRW_SSTANOMALY"],
row.dim_names,
constraints,
)
# NOAA_DHW is a remote-mirror dataset on coastwatch.pfeg.noaa.gov that redirects to
# PacIOOS Hawaii's ERDDAP, which occasionally times out -- retry a few times rather
# than let one flaky request abort the whole fetch loop.
for attempt in range(3):
try:
response = requests.get(url, timeout=120)
response.raise_for_status()
break
except requests.RequestException:
if attempt == 2:
raise
time.sleep(5 * (attempt + 1))
nc_path.write_bytes(response.content)
paths.append(nc_path)
return paths
def _clean_tif(nc_path, tif_path):
"""Mask CRW_SSTANOMALY's -327.68 fill value and write a GeoTIFF."""
field = Dataset.read_file(f'NETCDF:"{nc_path}":CRW_SSTANOMALY')
field = field.apply(lambda v: np.where(v < -300, np.nan, v))
field.to_file(str(tif_path))
def sst_anomaly_tifs(dates, nc_paths, out_dir):
"""Mask the fill value and write one GeoTIFF per frame."""
out_dir.mkdir(parents=True, exist_ok=True)
tif_paths = []
for day, nc_path in zip(dates, nc_paths):
tif_path = out_dir / f"{day}.tif"
if not tif_path.exists():
_clean_tif(nc_path, tif_path)
tif_paths.append(tif_path)
return tif_paths
def pacific_mosaic_tifs(dates, west_bbox, east_bbox, out_dir, stride):
"""Fetch an Asia/Australia tile + an Americas tile per date and mosaic them.
ERDDAP refuses a longitude subset that crosses the antimeridian (verified live --
it hard-rejects any Stop past the axis's real 179.975 maximum), so a true
Pacific-centred view needs two separate requests either side of the dateline. The
eastern (Americas) tile is rebuilt with its geotransform shifted +360 deg so it
sits edge-to-edge after the western tile in one continuous coordinate space, then
`merge_rasters` stitches the two into a single GeoTIFF per date.
"""
out_dir.mkdir(parents=True, exist_ok=True)
west_nc_dir, east_nc_dir = out_dir / "west_nc", out_dir / "east_nc"
tif_paths = []
for day in dates:
merged_path = out_dir / f"{day}.tif"
if not merged_path.exists():
west_nc = fetch_sst_anomaly([day], west_bbox, west_nc_dir, stride=stride)[0]
east_nc = fetch_sst_anomaly([day], east_bbox, east_nc_dir, stride=stride)[0]
west_tif = out_dir / f"{day}_west.tif"
_clean_tif(west_nc, west_tif)
east_field = Dataset.read_file(f'NETCDF:"{east_nc}":CRW_SSTANOMALY')
east_field = east_field.apply(lambda v: np.where(v < -300, np.nan, v))
x_min, px_w, rot1, y_max, rot2, px_h = east_field.geotransform
shifted_geo = (x_min + 360.0, px_w, rot1, y_max, rot2, px_h)
shifted = Dataset.from_array(
east_field.read_array(),
geo_ref=GeoReference(geo=shifted_geo, epsg=4326),
no_data_value=np.nan,
)
east_tif = out_dir / f"{day}_east.tif"
shifted.to_file(str(east_tif))
merge_rasters(
[str(west_tif), str(east_tif)], str(merged_path), method="last"
)
tif_paths.append(merged_path)
return tif_paths
def animate_sst_anomaly(tif_paths, labels, extent, gif_path, fps=5):
"""Animate a temperature_anomaly-styled, dark-canvas map and save it as a GIF.
`extent` is `[west, south, east, north]`. Rendered at `SOCIAL_FIGSIZE`
(LinkedIn's recommended 1200x627 single-image size) -- `figsize=` passed to
`.plot()` is only a hint cleopatra overrides from the data's own aspect
ratio, so the exact size is re-forced via `set_size_inches` after
`add_reference_map` to guarantee a consistent canvas across all three
animations regardless of each one's underlying geography.
"""
west, south, east, north = extent
glyph = DatasetCollection.from_files(tif_paths).plot(
cmap=anomaly_cmap,
vmin=-5,
vmax=5,
figsize=SOCIAL_FIGSIZE,
animation_axis_values=labels,
frame_label=FrameLabel(color="white", size=10),
# apply_blank_canvas only strips the map axes -- it doesn't touch the
# colorbar, which defaults to black tick/label text and disappears
# against the black canvas without this. ticks_spacing is also needed
# here specifically: temperature_anomaly is a discrete 19-level
# ListedColormap (unlike a continuous LinearSegmentedColormap), and
# without an explicit spacing cleopatra labels only the two endpoints.
colorbar=ColorBar(
label_color="white",
tick_color="white",
label="SST anomaly (\u00b0C)",
ticks_spacing=1,
),
)
apply_blank_canvas(glyph.ax, facecolor="black")
glyph.add_reference_map(style="dark", extent=[west, south, east, north])
glyph.fig.set_size_inches(*SOCIAL_FIGSIZE)
glyph.fig.set_dpi(150)
# Both bake their position from the figure's CURRENT size, so they must come
# after set_dpi/layout and before save_animation, per stamp_mark's own docstring.
# lower-left sits over open ocean, clear of the colorbar on the right edge.
glyph.stamp_mark(LOGO, frac=0.18, corner="lower left")
glyph.stamp_watermark("earthlens", credit="github.com/serapeum-org/earthlens")
# Render once to mp4 (yuv444p avoids the chroma subsampling that would
# otherwise degrade a GIF derived from it) and derive the GIF from that file
# rather than re-rendering the whole animation a second time -- exactly
# cleopatra's own documented pattern for `gif_from_video`. Without an explicit
# full-range scale + color_range tag, ffmpeg's default RGB->YUV conversion
# compresses pixel values into 16-235 ("limited"/tv range) rather than the
# true 0-255 -- a real loss of contrast, not just a player misreading a tag,
# confirmed by inspecting the raw encoded Y-plane bytes. That reads pale/
# washed out in any player that doesn't guess the same limited-range
# convention ffmpeg's own decoder assumes.
mp4_path = gif_path.with_suffix(".mp4")
glyph.save_animation(
str(mp4_path),
fps=fps,
pix_fmt="yuv444p",
crf=18,
extra_args=["-vf", "scale=out_range=full", "-color_range", "pc"],
)
gif_from_video(str(mp4_path), str(gif_path), fps=fps)
plt.close("all")
return gif_path
def embed_gif(gif_path, alt):
"""Return an HTML <img> embed of a GIF as a base64 data URI (no <video>, no JS)."""
encoded = base64.b64encode(gif_path.read_bytes()).decode()
return HTML(f'<img src="data:image/gif;base64,{encoded}" alt="{alt}" />')
WINDOW_START, WINDOW_END = "2025-01-01", "2026-09-05"
def frame_dates(freq):
dates = (
pd.date_range(WINDOW_START, WINDOW_END, freq=freq).strftime("%Y-%m-%d").tolist()
)
if dates[-1] != WINDOW_END:
dates.append(WINDOW_END) # always include the latest available day
return dates
3 · Global SST anomaly, every 10 days¶
63 frames, stride=6 (server-side decimated to a constant ~720k px/frame, safe at global extent).
GLOBAL = {"lat_lim": [-90.0, 90.0], "lon_lim": [-180.0, 180.0]}
global_dates = frame_dates("10D")
global_nc = fetch_sst_anomaly(global_dates, GLOBAL, OUT / "global_nc", stride=6)
global_tif = sst_anomaly_tifs(global_dates, global_nc, OUT / "global_tif")
# "%d %b '%y" (e.g. "05 Aug '26") -- the frame-label box truncates past ~10
# characters at this figure width; "05 Aug 2026" (11 chars) clips its last digit.
global_labels = [pd.to_datetime(d).strftime("%d %b '%y") for d in global_dates]
len(global_tif), global_labels[0], "->", global_labels[-1]
global_extent = [*GLOBAL["lon_lim"], *GLOBAL["lat_lim"]]
global_extent = [global_extent[0], global_extent[2], global_extent[1], global_extent[3]]
global_gif = animate_sst_anomaly(
global_tif,
global_labels,
global_extent,
OUT / "el_nino_sst_anomaly_global.gif",
)
embed_gif(global_gif, "Global SST anomaly, Jan 2025 - Sep 2026")
4 · Pacific-centred close-up, every 10 days¶
Recentred on the Pacific rather than cropped to a slice of it: Asia and Australia on the west rim, the whole of North and South America on the east, at a wide enough latitude band (-55 to 65) to actually include Australia and the southern tips of South America — the earlier narrow tropical band (-20 to 20) cut both off entirely. Fetched as two tiles (a Pacific-only box can't be requested in one piece — see pacific_mosaic_tifs above) at stride=3, denser than the global view's stride=6 since the combined area is smaller.
PACIFIC_WEST = {"lat_lim": [-55.0, 65.0], "lon_lim": [80.0, 180.0]}
PACIFIC_EAST = {"lat_lim": [-55.0, 65.0], "lon_lim": [-180.0, -25.0]}
pacific_dates = frame_dates("10D")
pacific_tif = pacific_mosaic_tifs(
pacific_dates, PACIFIC_WEST, PACIFIC_EAST, OUT / "pacific_mosaic", stride=3
)
pacific_labels = [pd.to_datetime(d).strftime("%d %b '%y") for d in pacific_dates]
len(pacific_tif), pacific_labels[0], "->", pacific_labels[-1]
# The eastern tile's longitudes were shifted +360 before merging, so the combined
# extent runs 80 -> 335 (not the usual -180..180) in one continuous coordinate space.
pacific_extent = [
PACIFIC_WEST["lon_lim"][0],
PACIFIC_WEST["lat_lim"][0],
PACIFIC_EAST["lon_lim"][1] + 360.0,
PACIFIC_WEST["lat_lim"][1],
]
pacific_gif = animate_sst_anomaly(
pacific_tif,
pacific_labels,
pacific_extent,
OUT / "el_nino_sst_anomaly_pacific.gif",
)
embed_gif(
pacific_gif,
"Pacific-centred SST anomaly (Asia/Australia west, the Americas east), Jan 2025 - Sep 2026",
)
Recap¶
The climate-indices ONI series and two erddap SST-anomaly animations — global, and a Pacific-centred close-up spanning Asia/Australia to the Americas — tell the same story from three angles: a scalar index climbing out of La Nina, the worldwide warming pattern, and the specific ocean basin driving it, both animations sized for a social-media post. All three from public, no-credential earthlens backends, sampled far denser than monthly throughout.
Try it yourself¶
- Narrow
PACIFIC_WEST/PACIFIC_EASTback to the classic Nino 3.4 region (lon_lim=[-170, -120], lat_lim=[-5, 5], no mosaic needed) for a tighter, more official view. - Swap in
CRW_DHW(Degree Heating Weeks) from the sameNOAA_DHWdataset for the coral-bleaching-stress angle. - Pair this with the
droughtbackend over Indonesia/Australia, orchcprecipitation over Peru, for the regional-impact half of the story. - See the climate-indices and ERDDAP backend references.