Fetches the independent SST record for the replication: ESA SST CCI Analysis v3.0 (Good & Embury 2024), DOI Good & Embury (2024).
This is the independent data half of the replication. Oliver et al. (2018) ran their satellite-era analysis on NOAA OI SST V2; SST_cci is produced by ESA CCI / UK Met Office with an independent optimal-estimation retrieval over (A)ATSR, AVHRR and SLSTR — a separate estimate of the same field, not a re-version of the same pipeline.
Why Copernicus Marine and not the CEDA archive¶
The dataset of record lives at CEDA, and its files are openly downloadable at
dap.ceda.ac.uk/neodc/eocis/... (~16.6 MB/day, so ~212 GB for 1982–2016).
The Copernicus Marine Service redistributes the same ESA SST CCI Analysis v3.0
as an ARCO/Zarr store (ESACCI-GLO-SST-L4-REP-OBS-SST), which supports
lazy chunked reads. That lets us average to the target grid while streaming
instead of mirroring 212 GB of daily files. Same product, better access path.
CMEMS also carries a
C3S-GLO-SST-L4-REP-OBS-SSTdataset in the same product. That is the Copernicus Climate Change Service variant — not the ESA CCI one this replication cites. Do not swap them.
Credentials. Copernicus Marine account (free) at
https://copernicusmarine login writes
~/.copernicusmarine/.copernicusmarine-credentials.
In CI, set COPERNICUSMARINE_SERVICE_USERNAME / _PASSWORD instead — the library
checks those before any config file, and login prompts and would hang —
see DOMAIN.md § Copernicus credentials in CI.
import json
import os
import time
from pathlib import Path
import copernicusmarine as cm
import numpy as np
import xarray as xr/home/ubuntu/openaire/marine-heatwave-replication/.pixi/envs/default/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
Configuration¶
TARGET_RES_DEG sets the analysis grid. Oliver et al. worked on the 0.25°
NOAA OISST grid; we area-average native 0.05° cells up to the target
rather than point-sampling them, so each analysis cell is an areal mean like
an OISST cell rather than a single sub-pixel value.
LON_BAND_STRIDE controls how much of the globe is read. The Zarr store is
chunked 64 cells wide in longitude, so bands are taken on those boundaries to
avoid reading a chunk to keep a sliver of it.
1→ every band → true global coverage, ~2.65 TB decoded (~10 h transfer).8→ every 8th band → 14 bands, 12.5% of longitudes (default).
A longitude-band sample is an unbiased estimator of an area-weighted global
mean, but it is a declared deviation from the paper’s full-field analysis
and must be reported as such in the Outcome. Set MHW_LON_BAND_STRIDE=1 for
the full-coverage run.
# Matches Oliver et al.'s satellite record. MHW_PERIOD_END exists only to smoke-test
# the pipeline cheaply; a real run must leave it unset so the full record is used.
PERIOD = ("1982-01-01", os.environ.get("MHW_PERIOD_END", "2016-12-31"))
TARGET_RES_DEG = float(os.environ.get("MHW_TARGET_RES_DEG", 1.0))
LON_BAND_STRIDE = int(os.environ.get("MHW_LON_BAND_STRIDE", 8))
DATASET_ID = "ESACCI-GLO-SST-L4-REP-OBS-SST"
NATIVE_RES_DEG = 0.05
COARSEN = int(round(TARGET_RES_DEG / NATIVE_RES_DEG)) # 20 for 1.0 deg
LON_CHUNK = 64 # longitude chunk width of the ARCO store
BAND_WIDTH = (LON_CHUNK // COARSEN) * COARSEN # largest multiple of COARSEN that fits
RAW_DIR = Path("../data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
OUT_PATH = RAW_DIR / f"sst_cci_{TARGET_RES_DEG:g}deg_stride{LON_BAND_STRIDE}.nc"
print(f"target grid : {TARGET_RES_DEG}° (coarsen {COARSEN}x{COARSEN} native cells)")
print(f"band stride : {LON_BAND_STRIDE} chunk(s); band width {BAND_WIDTH}/{LON_CHUNK} cells")
print(f"period : {PERIOD[0]} .. {PERIOD[1]}")
print(f"output : {OUT_PATH}")target grid : 1.0° (coarsen 20x20 native cells)
band stride : 1 chunk(s); band width 60/64 cells
period : 1982-01-01 .. 2016-12-31
output : ../data/raw/sst_cci_1deg_stride1.nc
Open the ARCO store (lazy)¶
ds = cm.open_dataset(
dataset_id=DATASET_ID,
variables=["analysed_sst"],
service="arco-time-series",
)
sst_all = ds["analysed_sst"].sel(time=slice(*PERIOD))
n_time, n_lat, n_lon = sst_all.shape
print(f"native cube: time={n_time}, lat={n_lat}, lon={n_lon}")
band_starts = list(range(0, n_lon - BAND_WIDTH + 1, LON_CHUNK * LON_BAND_STRIDE))
print(f"{len(band_starts)} longitude band(s) -> "
f"{len(band_starts) * BAND_WIDTH // COARSEN} analysis columns")INFO - 2026-08-15T10:36:30Z - Selected dataset version: "202603"
INFO - 2026-08-15T10:36:30Z - Selected dataset part: "default"
native cube: time=12784, lat=3600, lon=7200
112 longitude band(s) -> 336 analysis columns
Stream each band and average to the target grid¶
A single band at native resolution is ~23 GB, so it is never materialised. We rechunk to bounded blocks, let dask stream them, and keep only the coarsened result (a few tens of MB per band).
def fetch_band(start: int) -> xr.DataArray:
"""Read one longitude band lazily and area-average it to the target grid."""
band = sst_all.isel(longitude=slice(start, start + BAND_WIDTH))
# Bounded blocks: 1056 x 400 x BAND_WIDTH x 8 B ~= 216 MB per task.
band = band.chunk({"time": 1056, "latitude": 400, "longitude": BAND_WIDTH})
coarse = band.coarsen(
latitude=COARSEN, longitude=COARSEN, boundary="trim"
).mean()
return coarse.astype("float32")
# Each band is written to its own file as soon as it lands. At full global
# coverage the in-memory alternative would hold ~3 GB of bands and then double
# that during concat; this keeps peak memory flat and makes the download
# resumable — a band whose file already exists is skipped on re-run.
BAND_DIR = RAW_DIR / f"bands_{TARGET_RES_DEG:g}deg"
BAND_DIR.mkdir(parents=True, exist_ok=True)
t_start = time.time()
band_paths = []
n_fetched = 0 # bands actually downloaded in this run, excluding resumed ones
for i, start in enumerate(band_starts, 1):
band_path = BAND_DIR / f"band_{start:05d}.nc"
band_paths.append(band_path)
if band_path.exists():
print(f"[{i}/{len(band_starts)}] {band_path.name} exists — skipping", flush=True)
continue
t0 = time.time()
band = fetch_band(start).compute(scheduler="threads", num_workers=4)
band.name = "analysed_sst"
# Write to a temp name and rename atomically. If the process is killed
# mid-write, resume must not mistake a truncated file for a finished band.
tmp_path = band_path.with_suffix(".nc.tmp")
band.to_netcdf(tmp_path, encoding={"analysed_sst": {"zlib": True, "complevel": 4}})
os.replace(tmp_path, band_path)
lon0, lon1 = float(band.longitude[0]), float(band.longitude[-1])
n_fetched += 1
total = len(band_starts)
# Rate is per *fetched* band; bands skipped on resume cost ~0 s and would
# otherwise make the ETA far too optimistic.
remaining = sum(
1 for s in band_starts[i:] if not (BAND_DIR / f"band_{s:05d}.nc").exists()
)
eta = (time.time() - t_start) / n_fetched * remaining / 60
print(
f"[{i}/{total}] lon {lon0:8.2f}..{lon1:8.2f} "
f"shape={tuple(band.shape)} {time.time() - t0:5.0f}s "
f"(elapsed {(time.time() - t_start) / 60:5.1f} min, "
f"{remaining} left, ETA {eta:5.1f} min)",
flush=True,
)
del band
sst = xr.open_mfdataset(
[str(p) for p in band_paths], combine="by_coords", engine="netcdf4"
)["analysed_sst"].sortby("longitude")
print("assembled:", dict(sst.sizes))[1/112] band_00000.nc exists — skipping
[2/112] band_00064.nc exists — skipping
[3/112] band_00128.nc exists — skipping
[4/112] band_00192.nc exists — skipping
[5/112] band_00256.nc exists — skipping
[6/112] band_00320.nc exists — skipping
[7/112] band_00384.nc exists — skipping
[8/112] band_00448.nc exists — skipping
[9/112] band_00512.nc exists — skipping
[10/112] band_00576.nc exists — skipping
[11/112] band_00640.nc exists — skipping
[12/112] band_00704.nc exists — skipping
[13/112] band_00768.nc exists — skipping
[14/112] band_00832.nc exists — skipping
[15/112] band_00896.nc exists — skipping
[16/112] band_00960.nc exists — skipping
[17/112] band_01024.nc exists — skipping
[18/112] band_01088.nc exists — skipping
[19/112] band_01152.nc exists — skipping
[20/112] band_01216.nc exists — skipping
[21/112] band_01280.nc exists — skipping
[22/112] band_01344.nc exists — skipping
[23/112] band_01408.nc exists — skipping
[24/112] band_01472.nc exists — skipping
[25/112] band_01536.nc exists — skipping
[26/112] band_01600.nc exists — skipping
[27/112] band_01664.nc exists — skipping
[28/112] band_01728.nc exists — skipping
[29/112] band_01792.nc exists — skipping
[30/112] band_01856.nc exists — skipping
[31/112] band_01920.nc exists — skipping
[32/112] band_01984.nc exists — skipping
[33/112] band_02048.nc exists — skipping
[34/112] band_02112.nc exists — skipping
[35/112] band_02176.nc exists — skipping
[36/112] band_02240.nc exists — skipping
[37/112] band_02304.nc exists — skipping
[38/112] band_02368.nc exists — skipping
[39/112] band_02432.nc exists — skipping
[40/112] band_02496.nc exists — skipping
[41/112] band_02560.nc exists — skipping
[42/112] band_02624.nc exists — skipping
[43/112] band_02688.nc exists — skipping
[44/112] band_02752.nc exists — skipping
[45/112] band_02816.nc exists — skipping
[46/112] band_02880.nc exists — skipping
[47/112] band_02944.nc exists — skipping
[48/112] band_03008.nc exists — skipping
[49/112] band_03072.nc exists — skipping
[50/112] band_03136.nc exists — skipping
[51/112] band_03200.nc exists — skipping
[52/112] band_03264.nc exists — skipping
[53/112] band_03328.nc exists — skipping
[54/112] band_03392.nc exists — skipping
[55/112] band_03456.nc exists — skipping
[56/112] band_03520.nc exists — skipping
[57/112] band_03584.nc exists — skipping
[58/112] band_03648.nc exists — skipping
[59/112] band_03712.nc exists — skipping
[60/112] band_03776.nc exists — skipping
[61/112] band_03840.nc exists — skipping
[62/112] band_03904.nc exists — skipping
[63/112] band_03968.nc exists — skipping
[64/112] band_04032.nc exists — skipping
[65/112] band_04096.nc exists — skipping
[66/112] band_04160.nc exists — skipping
[67/112] band_04224.nc exists — skipping
[68/112] band_04288.nc exists — skipping
[69/112] band_04352.nc exists — skipping
[70/112] band_04416.nc exists — skipping
[71/112] band_04480.nc exists — skipping
[72/112] band_04544.nc exists — skipping
[73/112] band_04608.nc exists — skipping
[74/112] band_04672.nc exists — skipping
[75/112] band_04736.nc exists — skipping
[76/112] band_04800.nc exists — skipping
[77/112] band_04864.nc exists — skipping
[78/112] band_04928.nc exists — skipping
[79/112] band_04992.nc exists — skipping
[80/112] band_05056.nc exists — skipping
[81/112] band_05120.nc exists — skipping
[82/112] band_05184.nc exists — skipping
[83/112] band_05248.nc exists — skipping
[84/112] band_05312.nc exists — skipping
[85/112] band_05376.nc exists — skipping
[86/112] band_05440.nc exists — skipping
[87/112] band_05504.nc exists — skipping
[88/112] band_05568.nc exists — skipping
[89/112] band_05632.nc exists — skipping
[90/112] band_05696.nc exists — skipping
[91/112] band_05760.nc exists — skipping
[92/112] band_05824.nc exists — skipping
[93/112] band_05888.nc exists — skipping
[94/112] band_05952.nc exists — skipping
[95/112] band_06016.nc exists — skipping
[96/112] band_06080.nc exists — skipping
[97/112] band_06144.nc exists — skipping
[98/112] band_06208.nc exists — skipping
[99/112] band_06272.nc exists — skipping
[100/112] band_06336.nc exists — skipping
[101/112] band_06400.nc exists — skipping
[102/112] band_06464.nc exists — skipping
[103/112] band_06528.nc exists — skipping
[104/112] band_06592.nc exists — skipping
[105/112] band_06656.nc exists — skipping
[106/112] band_06720.nc exists — skipping
[107/112] band_06784.nc exists — skipping
[108/112] band_06848.nc exists — skipping
[109/112] band_06912.nc exists — skipping
[110/112] band_06976.nc exists — skipping
[111/112] band_07040.nc exists — skipping
[112/112] band_07104.nc exists — skipping
assembled: {'time': 12784, 'latitude': 180, 'longitude': 336}
Save¶
NetCDF with CF-style attributes — self-describing, language-agnostic, and the
format DOMAIN.md mandates over .npz for intermediate arrays.
sst.name = "analysed_sst"
sst.attrs.update(
units="kelvin",
long_name="Analysed sea surface temperature, area-averaged to target grid",
source_dataset=DATASET_ID,
source_doi="10.5285/4a9654136a7148e39b7feb56f8bb02d2",
source_product="SST_GLO_SST_L4_REP_OBSERVATIONS_010_024",
native_resolution_deg=NATIVE_RES_DEG,
target_resolution_deg=TARGET_RES_DEG,
aggregation="area mean over %dx%d native cells" % (COARSEN, COARSEN),
lon_band_stride=LON_BAND_STRIDE,
period_start=PERIOD[0],
period_end=PERIOD[1],
)
encoding = {"analysed_sst": {"zlib": True, "complevel": 4, "dtype": "float32"}}
sst.to_netcdf(OUT_PATH, encoding=encoding)
print(f"wrote {OUT_PATH} ({OUT_PATH.stat().st_size / 1e6:.0f} MB)")wrote ../data/raw/sst_cci_1deg_stride1.nc (1145 MB)
Source log¶
SOURCES = [
{
"name": "ESA SST CCI Analysis v3.0 (Level 4, global, daily)",
"doi": "10.5285/4a9654136a7148e39b7feb56f8bb02d2",
"url": "https://data.marine.copernicus.eu/product/SST_GLO_SST_L4_REP_OBSERVATIONS_010_024",
"dataset_id": DATASET_ID,
"access_route": "Copernicus Marine ARCO (arco-time-series); "
"dataset of record archived at CEDA",
"license": "Copernicus Marine Service / ESA CCI open licence",
"accessed_on": time.strftime("%Y-%m-%d"),
"period": list(PERIOD),
"target_resolution_deg": TARGET_RES_DEG,
"lon_band_stride": LON_BAND_STRIDE,
"n_bands": len(band_starts),
},
]
with open(RAW_DIR / "sources.json", "w") as f:
json.dump({"sources": SOURCES}, f, indent=2)
print(f"Logged {len(SOURCES)} source(s) to {RAW_DIR / 'sources.json'}")Logged 1 source(s) to ../data/raw/sources.json
- Good, S. A., & Embury, O. (2024). ESA Sea Surface Temperature Climate Change Initiative (SST_cci): Level 4 Analysis product, version 3.0. NERC EDS Centre for Environmental Data Analysis. 10.5285/4A9654136A7148E39B7FEB56F8BB02D2