Studying the June 2026 European heat wave with earthlens¶
In mid-to-late June 2026 a heat dome parked over Western Europe — daytime highs near 39 °C in
Madrid, then the heat rolling northeast into Paris and northern Italy over the following days. This
notebook shows how the earthlens facade lets you build a
remote-sensing picture of that event from several providers through one uniform API.
We work the way an analyst actually would — synoptic first, then zoom:
- A continental air-temperature map of the whole heat dome.
- Let the data pick the hottest cities (rather than guessing) — a ranking across major capitals.
- Zoom into the top three with Sentinel-2 true colour + 1 km land-surface temperature to see the urban heat island.
- A time series showing the ridge march south-to-north, reaching the Netherlands last.
- A city-scale look at the Randstad, then the heat's human-health cost — an ozone episode.
- A test of the antecedent-drought hypothesis, plus pointers to the exposure layers.
Every layer is the same EarthLens(...).download() call — only the data_source key and a few
arguments change.
The heat-wave toolbox inside earthlens¶
earthlens registers ~50 backends. Most are irrelevant to a heat wave, and two temperature imagers
are geographically wrong for Europe (goes → the Americas, jaxa/himawari → Asia-Pacific). The
keys that matter here:
| Heat-wave layer | Provider key(s) | Used below |
|---|---|---|
| Air temperature (reanalysis) | gee (ERA5-Land), ecmwf, amazon-s3 |
✅ |
| Satellite land-surface temperature | gee (MODIS), eumetsat, usgs-landsat, cdse |
✅ |
| Co-occurring drought | drought (Copernicus EDO), chc (SPI/SPEI) |
✅ |
| Human-health effect (air quality) | openaq, eea-aq |
✅ |
| Consequences (fire, WBGT stress) | firms, chc |
discover |
| Exposure (people, built-up, admin) | worldpop, ghsl, admin |
discover |
The live layers below use gee and drought; the rest are reachable exactly the same way.
Setup¶
pyramids supplies Dataset — it reads a GeoTIFF, converts its values (Dataset.apply), reads a value
at a point (Dataset.sample), and plots it (Dataset.plot). earthlens supplies the EarthLens
facade. No helper functions: every cell reads top-to-bottom.
import base64
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from cleopatra.glyphs.gridded.array_glyph import FrameLabel
from cleopatra.styling.colors import DATA_STYLES, resolve_colormap
from cleopatra.styling.styles import apply_blank_canvas
from IPython.display import HTML
from pyramids.dataset import Dataset
from pyramids.dataset.collection import DatasetCollection
from pyramids.plot import ColorBar
from earthlens.core import EarthLens
Pin the event¶
The heat wave ran roughly 17–26 June 2026, peaking over Iberia around the 22nd. We fix the window, a continental box, a list of candidate cities to rank, and 1° zoom boxes for the eventual focus cities — so the whole notebook is deterministic.
START, END, PEAK = "2026-06-17", "2026-06-26", "20260622"
EUROPE = dict(lat_lim=[35.0, 58.0], lon_lim=[-10.0, 27.0])
CANDIDATES = {
"Madrid": (40.42, -3.70),
"Seville": (37.39, -5.99),
"Barcelona": (41.39, 2.17),
"Lisbon": (38.72, -9.14),
"Paris": (48.85, 2.35),
"London": (51.51, -0.13),
"Milan": (45.46, 9.19),
"Rome": (41.90, 12.50),
"Berlin": (52.52, 13.40),
"Vienna": (48.21, 16.37),
"Zurich": (47.37, 8.54),
"Athens": (37.98, 23.73),
"Amsterdam": (52.37, 4.90),
"Rotterdam": (51.92, 4.48),
"Utrecht": (52.09, 5.12),
}
CITY_BOXES = {
"Madrid": dict(lat_lim=[40.1, 40.7], lon_lim=[-4.0, -3.4]),
"Paris": dict(lat_lim=[48.6, 49.1], lon_lim=[2.0, 2.7]),
"Milan": dict(lat_lim=[45.2, 45.7], lon_lim=[9.0, 9.5]),
}
# The three Randstad cities, which the dome reached last (peak ~26 Jun).
DUTCH_BOXES = {
"Amsterdam": dict(lat_lim=[52.30, 52.42], lon_lim=[4.80, 5.00]),
"Rotterdam": dict(lat_lim=[51.87, 51.97], lon_lim=[4.40, 4.55]),
"Utrecht": dict(lat_lim=[52.05, 52.15], lon_lim=[5.05, 5.20]),
}
OUT = Path("out") / "heatwave"
OUT.mkdir(parents=True, exist_ok=True)
START, END
Step 1 — Discover the providers¶
EarthLens.DataSources is the live registry of every backend key. Before writing a download, confirm
the heat-wave providers from the table above are all registered.
wanted = [
"gee",
"ecmwf",
"chc",
"drought",
"firms",
"eea-aq",
"worldpop",
"ghsl",
"admin",
]
pd.Series({k: k in EarthLens.DataSources for k in wanted}, name="registered")
EarthLens.list_datasets(<key>) lists a backend's catalogue. The Climate Hazards Center (chc) ships heat-stress products — WBGT and the CHIRTS daily maximum-temperature / heat-index series — over anonymous FTP, no credentials.
chc_ids = EarthLens.list_datasets("chc")
[d for d in chc_ids if any(k in d for k in ("wbgt", "chirtsdaily", "heat"))]
EarthLens.describe_dataset(<key>, <id>) returns the typed catalogue row. Here is the MODIS daily LST product we will zoom with — note the band name, unit (kelvin) and 0.02 scale factor.
mod11 = EarthLens.describe_dataset("gee", "MODIS/061/MOD11A1")
pd.DataFrame(
[(b.id, b.units, b.scale, b.description) for b in mod11.bands.values()],
columns=["band", "units", "scale", "description"],
).head()
Step 2 — Credentials for Google Earth Engine¶
The temperature layers come from Earth Engine, which needs a service account. The notebook reads
the account e-mail and JSON key path from the GEE_SERVICE_ACCOUNT / GEE_SERVICE_KEY environment
variables — set both before running. The air-quality step (Step 8) additionally reads a free OpenAQ key
from OPENAQ_API_KEY. (drought needs no credentials.)
import os
SERVICE_ACCOUNT = os.environ["GEE_SERVICE_ACCOUNT"]
SERVICE_KEY = os.environ["GEE_SERVICE_KEY"]
Step 3 — The heat dome (continental air temperature)¶
Start synoptic. ERA5-Land — ECMWF's reanalysis of near-surface air temperature — is hosted on Earth
Engine, so we fetch it through the same gee key and sidestep the Copernicus CDS queue. One daily-mean
temperature_2m GeoTIFF per day over the whole continent.
eu_job = EarthLens(
data_source="gee",
dataset="ECMWF/ERA5_LAND/DAILY_AGGR",
variables=["temperature_2m"],
start=START,
end=END,
temporal_resolution="daily",
path=OUT / "europe",
export_via="url",
**EUROPE,
)
eu_job.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
eu_paths = eu_job.download(progress_bar=False)
print(f"{len(eu_paths)} daily continental tiles")
ERA5-Land temperature is in kelvin (fill 0); we mask non-positive fills and subtract 273.15. Map the peak day.
peak_path = next(p for p in eu_paths if p.stem.endswith(PEAK))
dome = Dataset.read_file(peak_path)
dome = dome.apply(lambda kelvin: np.where(kelvin > 200, kelvin - 273.15, np.nan))
fig, ax = plt.subplots(figsize=(8, 6))
dome.plot(
band=0,
exclude_value=np.nan,
ax=ax,
fig=fig,
cmap="inferno",
colorbar=ColorBar(label="T2m (°C)"),
)
ax.set_title("ERA5-Land daily-mean 2 m air temperature — 22 Jun 2026")
ax.set_xlabel("lon")
ax.set_ylabel("lat")
plt.tight_layout()
plt.show()
The hot lobe sits squarely over Iberia and France — a daily mean above 30 °C (the days topped 39 °C, and crucially the nights stayed hot). Southeast Europe is comparatively mild: this is a Western-European event, which is why we let the data, not intuition, choose the focus cities next.
The dome, day by day¶
A single day only hints at the story — the dome builds, peaks, then migrates. cleopatra
(≥ 0.24) ships style= presets on plot()/animate(): named colour recipes vendored straight
from ECMWF's own Magics palettes (Apache-2.0, generated from ECMWF's palettes.json), keyed by the
literal GRIB shortName — "2t" is 2 m temperature. pyramids forwards style= straight through
Dataset/DatasetCollection, so no raw cleopatra calls are needed. Reusing the ten daily tiles already
downloaded above, animate the whole window with the genuine ECMWF colour scale, and dress it with the same
add_reference_map(style="ecmwf-dark") look the showcase notebooks use.
celsius_dir = OUT / "europe_celsius"
celsius_dir.mkdir(parents=True, exist_ok=True)
celsius_paths = []
for path in sorted(eu_paths):
field = Dataset.read_file(path)
field = field.apply(lambda kelvin: np.where(kelvin > 200, kelvin - 273.15, np.nan))
celsius_path = celsius_dir / path.name
field.to_file(str(celsius_path))
celsius_paths.append(celsius_path)
labels = [
pd.to_datetime(p.stem.split("_")[-1]).strftime("%b %d") for p in sorted(eu_paths)
]
# The ECMWF "2t" 2 m-temperature palette (cleopatra's temperature_2m preset),
# stretched to a fixed -10..50 degC range so the heat dome lands in the gradient
# band instead of saturating the palette's flat ends.
t2m_cmap = resolve_colormap(next(iter(DATA_STYLES["temperature_2m"].values()))["cmap"])
cube = DatasetCollection.from_files(celsius_paths)
glyph = cube.plot(cmap=t2m_cmap, vmin=-10, vmax=50, figsize=(8, 7.6))
apply_blank_canvas(glyph.ax, facecolor="black") # the ECMWF/CAMS dark-animation look
west, east = EUROPE["lon_lim"]
south, north = EUROPE["lat_lim"]
# frame_label's location is in the SAME degree coordinates as add_reference_map's
# extent below, so the label lands correctly once that extent is applied.
glyph.animate(
labels,
interval=400,
cmap=t2m_cmap,
vmin=-10,
vmax=50,
frame_label=FrameLabel(location=[west, north + 2], color="white"),
)
glyph.add_reference_map(style="dark", extent=[west, south, east, north])
dome_mp4 = OUT / "heat_dome_2t.mp4"
glyph.save_animation(str(dome_mp4), fps=2, crf=26)
plt.close("all")
encoded = base64.b64encode(dome_mp4.read_bytes()).decode()
HTML(
f'<video src="data:video/mp4;base64,{encoded}" width="700" autoplay loop muted playsinline controls></video>'
)
Watch the magenta core build over Iberia, then stretch north through France and into Germany — the same "peak walks north with the calendar" story from Step 6, now visible directly instead of only in the transect chart. The Alps stay a cold navy island throughout (elevation, not the dome), and the small gaps that blend into the black background are ERA5-Land's own coverage edge, not a rendering artefact.
Step 4 — Let the data choose the cities¶
Rather than guess which cities to study, sample the continental tiles at a dozen major capitals and take each city's hottest day in the window. The ranking picks the focus cities for us.
cities = pd.DataFrame(
{
"x": [lon for lat, lon in CANDIDATES.values()],
"y": [lat for lat, lon in CANDIDATES.values()],
},
index=list(CANDIDATES),
)
records = []
for path in sorted(eu_paths):
field = Dataset.read_file(path)
sampled = field.sample(cities) # shape (1 band, N cities)
kelvin = sampled.ravel()
celsius = np.where(kelvin > 200, kelvin - 273.15, np.nan) # mask the 0 K sea/fill
day = pd.to_datetime(path.stem.split("_")[-1])
for city, temp in zip(cities.index, celsius):
records.append({"date": day, "city": city, "t2m_C": float(temp)})
city_temps = pd.DataFrame(records)
peaks = city_temps.groupby("city")["t2m_C"].max().sort_values(ascending=False)
peaks.round(1)
ax = peaks.plot.bar(figsize=(9, 4), color="#c1440e")
ax.set_title("Window-peak daily-mean 2 m air temperature by city — 17–26 Jun 2026")
ax.set_ylabel("peak T2m (°C)")
ax.set_xlabel("")
plt.xticks(rotation=45, ha="right")
ax.grid(axis="y", alpha=0.3)
plt.tight_layout()
plt.show()
Paris, Madrid and Milan top the ranking (Seville just behind; Barcelona's coastal pixel is masked) — the Iberia → France → N-Italy footprint of the heat dome. Those three become our zoom targets.
Step 5 — Zoom in: the cities and their heat¶
Now the fun part — the actual cities. We pair a Sentinel-2 true-colour image of each focus city
(a cloud-screened median composite of the visible bands B4/B3/B2) with its land surface
temperature — the 1 km skin temperature MODIS measures directly. The true colour shows the built
fabric; the LST reveals the urban heat island, where that dark, dry fabric bakes far hotter than the
air.
city_rgb = {}
for name, box in CITY_BOXES.items():
job = EarthLens(
data_source="gee",
dataset="COPERNICUS/S2_SR_HARMONIZED",
variables=["B4", "B3", "B2"],
start=START,
end=END,
temporal_resolution="raw",
reducer="median",
scale=50.0,
path=OUT / "rgb" / name,
export_via="url",
**box,
)
job.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
paths = job.download(progress_bar=False)
city_rgb[name] = paths[0]
{k: v.name for k, v in city_rgb.items()}
city_lst = {}
for name, box in CITY_BOXES.items():
job = EarthLens(
data_source="gee",
dataset="MODIS/061/MOD11A1",
variables=["LST_Day_1km"],
start=START,
end=END,
temporal_resolution="raw",
reducer="max",
scale=1000.0,
path=OUT / "lst" / name,
export_via="url",
**box,
)
job.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
paths = job.download(progress_bar=False)
city_lst[name] = paths[0]
{k: v.name for k, v in city_lst.items()}
Top row: true colour. Bottom row: a peak-heat MODIS LST composite — the max over the window, so cloud-gapped days fall away — converted from raw counts (≥ 7500, ×0.02 into kelvin) to °C.
fig, axes = plt.subplots(2, 3, figsize=(15, 9), constrained_layout=True)
for col, name in enumerate(CITY_BOXES):
true_colour = Dataset.read_file(city_rgb[name])
true_colour.plot(
rgb_options={"rgb": [0, 1, 2], "surface_reflectance": 3000},
ax=axes[0, col],
fig=fig,
)
axes[0, col].set_title(f"{name} — true colour")
axes[0, col].set_xlabel("lon")
axes[0, col].set_ylabel("lat")
lst = Dataset.read_file(city_lst[name])
lst = lst.apply(lambda dn: np.where(dn >= 7500, dn * 0.02 - 273.15, np.nan))
lst.plot(
band=0,
exclude_value=np.nan,
ax=axes[1, col],
fig=fig,
cmap="inferno",
colorbar=ColorBar(label="LST (°C)"),
)
axes[1, col].set_title(f"{name} — MODIS LST")
axes[1, col].set_xlabel("lon")
axes[1, col].set_ylabel("lat")
plt.show()
Each city core glows well above 40 °C at the surface — far hotter than the ~30 °C daily-mean air temperature — and lining the true-colour image up against the LST makes the urban heat island obvious: the hottest pixels sit over the dense, dark, built-up centre and dry ground, while parks, rivers and irrigated land beside them stay markedly cooler.
Step 6 — Watch the heat dome migrate¶
Because we sampled every daily tile at every city, we already have the time series. We plot a clean south-to-north transect — Madrid (40°N), Paris (49°N), Amsterdam (52°N) — to watch the ridge march poleward.
transect = ["Madrid", "Paris", "Amsterdam"]
wide = city_temps[city_temps.city.isin(transect)].pivot(
index="date", columns="city", values="t2m_C"
)
ax = wide[transect].plot(marker="o", figsize=(9, 4))
ax.set_title("Daily-mean 2 m air temperature — the heat dome marches SW → NE")
ax.set_ylabel("T2m (°C)")
ax.set_xlabel("date")
ax.grid(alpha=0.3)
plt.tight_layout()
plt.show()
The peak walks north with the calendar: Madrid hottest around the 22nd, Paris overtaking it by the 24th–25th, and Amsterdam only catching up on the 26th — the last day of the window. That lag is the heat wave propagating across the continent, and it is why the Netherlands, on the dome's northern edge, is worth a closer look next.
Step 7 — The heat reaches the Netherlands¶
The dome's northern edge swept over the Randstad late in the window. We zoom into Amsterdam, Rotterdam and Utrecht with the same peak-heat MODIS LST composite.
dutch_lst = {}
for name, box in DUTCH_BOXES.items():
job = EarthLens(
data_source="gee",
dataset="MODIS/061/MOD11A1",
variables=["LST_Day_1km"],
start=START,
end=END,
temporal_resolution="raw",
reducer="max",
scale=1000.0,
path=OUT / "lst_nl" / name,
export_via="url",
**box,
)
job.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
paths = job.download(progress_bar=False)
dutch_lst[name] = paths[0]
{k: v.name for k, v in dutch_lst.items()}
fig, axes = plt.subplots(1, 3, figsize=(15, 4.6), constrained_layout=True)
for ax, (name, path) in zip(axes, dutch_lst.items()):
lst = Dataset.read_file(path)
lst = lst.apply(lambda dn: np.where(dn >= 7500, dn * 0.02 - 273.15, np.nan))
lst.plot(
band=0,
exclude_value=np.nan,
ax=ax,
fig=fig,
cmap="inferno",
colorbar=ColorBar(label="LST (°C)"),
)
ax.set_title(f"{name} — MODIS LST")
ax.set_xlabel("lon")
ax.set_ylabel("lat")
plt.show()
Surface temperatures in the low-to-mid 30s °C across all three Randstad cities — cooler than Iberia's peak, but this is the dome's poleward extreme, arriving days after Madrid. The hottest pixels again sit over the dense city fabric, with the surrounding water and polders staying markedly cooler.
Step 8 — The heat's effect on people: an ozone episode¶
Satellites image the environment; the clearest human-health effect of a heat wave shows up in the
ground network instead. Stagnant, hot, sunny air cooks traffic and industrial emissions into
ground-level ozone — the photochemical smog behind heat-wave health advisories, when the elderly,
children and people with asthma are told to limit outdoor activity. We pull daily ozone from the
OpenAQ ground stations over the Netherlands (a tabular DataFrame, not a raster). OpenAQ needs a
free key, read from the OPENAQ_API_KEY environment variable.
ozone_request = EarthLens(
data_source="openaq",
variables=["o3"],
temporal_resolution="daily",
start=START,
end=END,
lat_lim=[51.5, 52.6],
lon_lim=[4.0, 5.5],
path=OUT / "ozone",
max_locations=25,
)
ozone = ozone_request.download(progress_bar=False)
print(f"{ozone.station_id.nunique()} stations, {len(ozone)} daily ozone readings")
Reduce the stations to a daily regional mean, and pull the Randstad air temperature from the city_temps table we already built, so we can plot ozone against the heat.
ozone["date"] = (
pd.to_datetime(ozone["datetime_utc"]).dt.tz_localize(None).dt.normalize()
)
o3_daily = ozone.groupby("date")["value"].mean()
nl_temp = (
city_temps[city_temps.city.isin(["Amsterdam", "Rotterdam", "Utrecht"])]
.groupby("date")["t2m_C"]
.mean()
)
o3_daily.round(0)
fig, ax1 = plt.subplots(figsize=(9, 4.5))
ax1.bar(o3_daily.index, o3_daily.values, color="#8c6bb1", alpha=0.85)
ax1.axhline(100, color="#555", ls="--", lw=1)
ax1.set_ylabel("daily-mean O$_3$ (µg/m³)")
ax1.set_xlabel("date")
ax1.set_title("Netherlands — ground-level ozone tracks the heat")
ax1.legend(["WHO 8 h guideline (100)", "daily-mean ozone"], loc="upper left")
ax2 = ax1.twinx()
ax2.plot(nl_temp.index, nl_temp.values, color="#c1440e", marker="o")
ax2.set_ylabel("2 m air temperature (°C)", color="#c1440e")
ax2.legend(["air temperature"], loc="upper right")
plt.tight_layout()
plt.show()
Ground-level ozone more than tripled — from ~30 µg/m³ before the event to ~120 µg/m³ on 23–25 June —
rising and falling with the air temperature and peaking exactly as the dome sat over the Netherlands.
Daily-mean concentrations climbed past the WHO 100 µg/m³ 8-hour guideline level, at which health
authorities issue smog advisories. This is the tangible human cost of the heat — and, unlike the
thermal maps above, it comes from OpenAQ's ground network, the honest place to measure it. (For
wildfires, the other heat consequence, see the firms backend.)
Step 9 — Did drought amplify the heat?¶
Heat and drought often reinforce each other: dry ground can't evaporate, so incoming energy goes
straight into heating the surface. Worth testing rather than assuming. The drought backend serves
the Copernicus European Drought Observatory (EDO) — no credentials. We pull its short-term
Standardized Precipitation Index (edo-spaST, a categorical anomaly product; class 0 = near-normal)
over Iberia for two windows: the winter before the event and the event window itself.
spi = {}
for tag, (s, e) in {
"Feb 2026": ("2026-02-01", "2026-02-12"),
"Jun 2026": ("2026-06-01", "2026-06-12"),
}.items():
request = EarthLens(
data_source="edo",
dataset="edo-spaST",
variables=[],
start=s,
end=e,
lat_lim=[36.0, 48.0],
lon_lim=[-10.0, 6.0],
path=OUT / "spi",
)
paths = request.download(progress_bar=False)
spi[tag] = paths[-1]
{k: v.name for k, v in spi.items()}
The two panels share a colour scale so they are directly comparable. (The backend now clips each EDO raster to the requested box — it previously returned a full −180…180 strip because the Copernicus server ignores the longitude subset.)
fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for i, (ax, (tag, path)) in enumerate(zip(axes, spi.items())):
# SPI is already in class units (no conversion), so plot the raster straight.
spi_map = Dataset.read_file(path)
spi_map.plot(
band=0,
ax=ax,
fig=fig,
cmap="cividis",
vmin=0,
vmax=4,
add_colorbar=(
i == len(spi) - 1
), # one shared bar (both panels share the 0–4 scale)
colorbar=ColorBar(label="SPI class (0 = near-normal)"),
)
ax.set_title(f"EDO short-term SPI — {tag}")
ax.set_xlabel("lon")
ax.set_ylabel("lat")
plt.tight_layout()
plt.show()
Iberia carried widespread non-zero SPI anomaly classes in February, but by the June event window the index had relaxed to near-normal (class 0) almost everywhere. So the observations reject the antecedent-drought hypothesis for this particular heat wave — it was driven mainly by an atmospheric heat ridge, not by parched soils. That is exactly the kind of assumption a remote-sensing layer lets you check. (The richer EDO indicators — soil-moisture anomaly, combined drought indicator — lag by weeks and weren't yet published for mid-2026.)
Step 10 — Who is exposed?¶
The final layer is impact — overlaying the hot zone on where people and cities are. earthlens
exposes three exposure backends, reached exactly like the ones above; each returns population rasters,
built-up-area rasters, or admin polygons you can clip to the same boxes and zonal-average against the
temperature maps.
exposure = ["worldpop", "ghsl", "admin"]
pd.Series(
{k: EarthLens.DataSources[k].__name__ for k in exposure}, name="backend_class"
)
Takeaway¶
From one facade you reconstructed the June 2026 Western-European heat wave, synoptic to local:
gee→ ERA5-Land — the continental heat dome over Iberia and France.- A data-driven ranking — the observations, not a guess, chose Madrid / Paris / Milan.
gee→ Sentinel-2 true colour + MODIS LST — the cities themselves paired with their 1 km surface heat (> 40 °C), making the urban heat island plain.- A migration time series — the ridge marching SW → NE, reaching the Netherlands (Amsterdam / Rotterdam / Utrecht) last, on the final day.
openaq→ ground-level ozone — the heat's human-health cost: an ozone episode over the Netherlands that tripled with the temperature and passed the WHO guideline.drought→ EDO SPI — a winter-vs-event comparison that tested (and rejected) the antecedent-drought hypothesis for this event.worldpop/ghsl/admin— the exposure layers to turn temperature into impact.
Every layer used the same EarthLens(data_source=..., start=..., end=..., lat_lim=..., lon_lim=...)
→ .download() pattern. To extend: swap the key — firms for wildfire hot-spots, eea-aq for
EEA-validated air quality, chc for the WBGT human-heat-stress index, or eumetsat for 15-minute
Meteosat imagery of the same days. Discover any of them with EarthLens.list_datasets(<key>) and
EarthLens.describe_dataset(<key>, <id>).