Europe's summer of extreme heat, 2026¶
Summer 2026 was Europe's hottest on record in several countries. Western Europe had its warmest June on record (20.74 °C average, +3.05 °C above the 1991–2020 normal); the first heatwave began 24 May, and by mid-August Europe was in its fifth heatwave of the season. The peak reading was 46.5 °C at Noto, Sicily, on 22 July — France recorded its hottest summer ever (24 °C average, +3.6 °C above normal). The season is estimated to have caused roughly 36,210 deaths across Europe and fuelled record wildfires burning over 1.2 million acres.
This notebook covers the full arc — from before the first heatwave through the most recently available day — with the earthlens ecmwf backend reaching ERA5-Land directly from the Copernicus Climate Data Store: a daily land-area-mean time series and a daily animated map, both sub-monthly (there is no monthly step anywhere in this notebook).
Needs a
~/.cdsapirc(free CDS account) andpyramids-gis[viz](cleopatra).
Sources: WMO, Al Jazeera — France's hottest summer, CNN, Wikipedia — 2026 European heatwaves.
Setup¶
pyramids supplies Dataset / DatasetCollection; cleopatra supplies LineGlyph, apply_blank_canvas, the dark reference-map basemap, and the genuine ECMWF Magics "2t" 2 m-temperature palette (DATA_STYLES["temperature_2m"]); earthlens supplies the unified EarthLens entry point and AggregationConfig.
import base64
import warnings
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
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
from pyramids.dataset.collection import DatasetCollection
from pyramids.plot import ColorBar
from earthlens.aggregate import AggregationConfig
from earthlens.core import EarthLens
warnings.filterwarnings("ignore")
logger.remove()
OUT = Path("out") / "european_heatwave_summer_2026"
OUT.mkdir(parents=True, exist_ok=True)
EUROPE = {"lat_lim": [34.0, 62.0], "lon_lim": [-12.0, 32.0]}
# Overlay variant: carries its own navy scrim, built for watermarking over
# arbitrary/dark imagery down to 80 px wide -- see 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)
# END verified live against CDS's own ERA5-Land availability (a request past this
# raises ValueError -- "does not match any constraint entry" -- rather than silently
# returning nothing). Re-verify before bumping this further: the day CDS actually
# publishes through moves roughly a week behind the real calendar date.
START, END = "2026-05-15", "2026-09-07"
1 · Fetch the whole window in one CDS request¶
The ecmwf backend takes the whole date range in a single request and returns one multi-day NetCDF — aggregate= then reduces it into one GeoTIFF per day (op="mean", the natural daily summary for a state variable like 2 m temperature) without a second pass. CDS is a queue, not a live API ("this may take several minutes" per the ecmwf backend's own log line), but that is still one wait for the whole ~3.5-month window rather than ~110 separate waits.
raw_kelvin_dir = OUT / "raw_kelvin"
daily_tifs = sorted(raw_kelvin_dir.glob("*.tif")) if raw_kelvin_dir.exists() else []
if not daily_tifs:
# CDS is a queue, not a live API -- only submit a request when the
# aggregated output isn't already on disk, same caching convention as
# every other fetch helper in this notebook.
#
# One request per calendar month boundary, not one request for the whole
# window: CDS builds a single day=[01..31] list applied uniformly across
# every month named in the request, so a request spanning into a month
# that is still being published gets rejected in full the moment that
# list contains a day CDS has not published yet -- confirmed live, a
# single request for the whole START..END span returns zero files even
# though every earlier month in it is complete. Splitting at the most
# recent full month boundary keeps each request's day range something
# CDS will actually answer.
end_ts = pd.Timestamp(END)
month_start = end_ts.replace(day=1)
if pd.Timestamp(START) < month_start:
windows = [
(START, (month_start - pd.Timedelta(days=1)).strftime("%Y-%m-%d")),
(month_start.strftime("%Y-%m-%d"), END),
]
else:
windows = [(START, END)]
daily_tifs = []
for w_start, w_end in windows:
job = EarthLens(
data_source="ecmwf",
variables={"reanalysis-era5-land": ["2m-temperature"]},
start=w_start,
end=w_end,
temporal_resolution="daily",
path=OUT,
**EUROPE,
)
daily_tifs += job.download(
aggregate=AggregationConfig(
freq="D", op="mean", cell_size=0.1, out_dir=raw_kelvin_dir
)
)
daily_tifs = sorted(daily_tifs)
# The cache check above only asks "is raw_kelvin_dir non-empty", not "does it
# cover what START/END are asking for right now" -- so a stale cache from a
# previous, narrower window is served silently, with no error. Catch that
# here instead of downstream: reparse each cached file's own date and compare
# the set against the calendar days START..END actually calls for.
cached_days = pd.DatetimeIndex(
[pd.to_datetime(Path(p).stem.split("_")[-1], format="%Y%m%d") for p in daily_tifs]
)
expected_days = pd.date_range(START, END, freq="D")
assert set(cached_days) == set(expected_days), (
f"cached tiles cover {cached_days.min().date()}..{cached_days.max().date()} "
f"({len(cached_days)} days) but START/END ask for {START}..{END} "
f"({len(expected_days)} days) -- delete {raw_kelvin_dir} and re-run"
)
len(daily_tifs)
2 · Kelvin to °C, and the daily land-area-mean series¶
ERA5-Land only covers land pixels — the ~44% of this box's area that is ocean (Atlantic, Mediterranean, North Sea, ...) is already NaN in the aggregated output, so np.nanmean over the box is naturally a land-area mean, not diluted by sea-surface temperatures. Each day is converted to t2m_celsius/ and reduced to one point of the time series — no second data source needed for the index-style panel below.
celsius_dir = OUT / "t2m_celsius"
celsius_dir.mkdir(parents=True, exist_ok=True)
celsius_paths = []
area_mean = []
for path in sorted(daily_tifs):
cpath = celsius_dir / Path(path).name
if not cpath.exists():
field = Dataset.read_file(str(path))
field = field.apply(lambda kelvin: kelvin - 273.15)
field.to_file(str(cpath))
celsius_paths.append(cpath)
area_mean.append(np.nanmean(Dataset.read_file(str(cpath)).read_array()))
dates = [
pd.to_datetime(Path(p).stem.split("_")[-1], format="%Y%m%d")
for p in sorted(daily_tifs)
]
daily = pd.Series(area_mean, index=pd.DatetimeIndex(dates), name="t2m").sort_index()
daily.tail()
Plot the area-mean time series¶
Drawn with cleopatra's LineGlyph, not bare matplotlib. The first heatwave's onset and the Sicily station record are marked as reference annotations on the glyph's own returned axes — the land-area mean itself never reaches a single station's 46.5 °C peak, so that point is annotated separately rather than read off the line.
crimson = LINE_DEFAULT_OPTIONS["color_2"]
line = LineGlyph(
daily.index.to_numpy(), daily.to_numpy(), figsize=(10, 4.5), line_width=1.6
)
fig, ax, _ = line.line(color=crimson)
onset = pd.Timestamp("2026-05-24")
ax.axvline(onset, color="0.4", lw=0.8, ls="--")
ax.text(
onset, daily.max() + 0.4, "1st heatwave\nonset (24 May)", fontsize=7, color="0.4"
)
peak_day = pd.Timestamp("2026-07-22")
if peak_day in daily.index:
ax.annotate(
"46.5 \u00b0C at Noto, Sicily (station record,\nnot this land-area mean)",
xy=(peak_day, daily.loc[peak_day]),
xytext=(15, 20),
textcoords="offset points",
fontsize=8,
arrowprops=dict(arrowstyle="->", color="0.3"),
)
ax.set(
ylabel="land-area-mean 2 m temperature (\u00b0C)",
title="Europe land-area-mean 2 m temperature, summer 2026",
)
plt.show()
3 · Animate with the ECMWF "2t" preset¶
The ECMWF-social-media look (style="2t" → apply_blank_canvas → add_reference_map(style="ecmwf-dark")), but the full summer instead of six weeks, and rendered at exactly 1200x627 px — LinkedIn's recommended single-image post size, the same SOCIAL_FIGSIZE used by the el_nino showcase's animations regardless of Europe's own narrower aspect ratio. Rendered once to .mp4 (ready for a native LinkedIn video post) and the .gif embedded below — as an <img>, not an HTML5 <video>, so it plays in every viewer with no JavaScript — is derived from that file via cleopatra's gif_from_video, rather than re-rendering the whole animation a second time.
west, east = EUROPE["lon_lim"]
south, north = EUROPE["lat_lim"]
t2m_cmap = resolve_colormap(next(iter(DATA_STYLES["temperature_2m"].values()))["cmap"])
cube = DatasetCollection.from_files(celsius_paths)
labels = [d.strftime("%d %b") for d in daily.index]
glyph = cube.plot(
cmap=t2m_cmap,
# Re-tuned from 5/40 to 0/38 for the extended Aug 31 -> Sep 7 window's
# actual data range (widened in this same commit); 5/40 clipped/wasted
# banding against the real min/max over the new date span.
vmin=0,
vmax=38,
figsize=SOCIAL_FIGSIZE,
animation_axis_values=labels,
# 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.
colorbar=ColorBar(
label_color="white",
tick_color="white",
label="2m temperature (\u00b0C)",
ticks_spacing=5,
),
)
apply_blank_canvas(glyph.ax, facecolor="black")
glyph.add_reference_map(style="dark", extent=[west, south, east, north])
# figsize= passed to .plot() is only a hint cleopatra overrides from the data's
# own aspect ratio, so re-force the exact size for a consistent canvas across
# all three social showcases.
glyph.fig.set_size_inches(*SOCIAL_FIGSIZE)
glyph.fig.set_dpi(150)
# The default colorbar axes sits close enough to the figure's right edge that
# its rotated label runs past the canvas -- shifting only the colorbar's own
# axes left (figure-fraction) leaves the map axes, and everything drawn on
# it, completely untouched.
cbar_pos = glyph.cbar.ax.get_position()
glyph.cbar.ax.set_position(
[cbar_pos.x0 - 0.025, cbar_pos.y0, cbar_pos.width, cbar_pos.height]
)
# Both bake their position from the figure's CURRENT size, so they must come
# after set_dpi/set_size_inches -- and before save_animation, which reads the
# figure as its final frame.
glyph.stamp_mark(str(LOGO), frac=0.18, corner="lower left")
glyph.stamp_watermark("earthlens", credit="github.com/serapeum-org/earthlens")
gif_path = OUT / "european_heatwave_summer_2026.gif"
mp4_path = gif_path.with_suffix(".mp4")
# 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.
glyph.save_animation(
str(mp4_path),
fps=8,
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=8)
plt.close("all")
encoded = base64.b64encode(gif_path.read_bytes()).decode()
HTML(
f'<img src="data:image/gif;base64,{encoded}" '
'alt="Europe daily 2m temperature, summer 2026" />'
)
Recap¶
Summer 2026 was not one heatwave but five, starting 24 May and still running by mid-August, with a station peak of 46.5 °C in Sicily and France's hottest summer on record. The ecmwf backend's single-request-plus-aggregate path made both a genuine daily time series and a daily animated map possible from one CDS queue wait — no monthly step anywhere in this notebook.
Try it yourself¶
- Zoom
EUROPEto a single country (e.g. France,lon_lim=[-5, 8], lat_lim=[42, 51]) for a tighter view of a specific heatwave. - Pair this with the
firmsbackend for the wildfire angle, orchc/CHIRPS precipitation for the drought side of the same summer. - See the ECMWF backend reference.