CMEMS SSH products — along-track, SWOT, and binned¶
A side-by-side tour of the Copernicus Marine sea-surface-height family: six L3 along-track missions (Sentinel-3A/3B, Sentinel-6A, Jason follow-on H2B, CryoSat-2, Saral/AltiKa, SWOT nadir), then their gridded reductions via
xrtoolz.interpolate.bin_2d, and finally a comparison against two L4 reanalyses — the standard all-sat product and the SWOT-aware SWOS demo.
Window: 2026-01-01 → 2026-01-15 (15 days), Gulf Stream
[-70°, -50°] × [30°, 45°]. The 15-day window keeps the notebook quick to re-execute while still capturing several passes per mission across the eddying jet.
Notes on the data path. CMEMS now serves L3 along-track as a long-format observations table (sqlite-backed), so we use copernicusmarine.read_dataframe for L3 and open_dataset for the L4 grids. The standalone SWOT KaRIn L3 wide-swath product is not in the CMEMS catalog; the closest stand-in is the SWOS L4 demo (cmems_obs-sl_glo_phy-ssh_nrt_demo-allsat-swos-l4-duacs-0.125deg_P1D-i), which fuses SWOT KaRIn observations into the all-sat constellation.
Credentials live in the project-root .env:
COPERNICUSMARINE_SERVICE_USERNAME=...
COPERNICUSMARINE_SERVICE_PASSWORD=...
1. Setup¶
from __future__ import annotations
import warnings
from dataclasses import dataclass
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
from dotenv import load_dotenv
import copernicusmarine as cm
from xrtoolz.interpolate import Grid, bin_2d
warnings.filterwarnings('ignore', category=FutureWarning)
load_dotenv() # picks up CMEMS creds from project-root .env
/home/azureuser/localfiles/xrtoolz/.venv/lib/python3.13/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
True
# Region + window (15 days over the Gulf Stream)
BBOX = dict(
minimum_longitude=-70.0, maximum_longitude=-50.0,
minimum_latitude=30.0, maximum_latitude=45.0,
)
TIME = dict(start_datetime='2026-01-01', end_datetime='2026-01-15')
GRID = Grid.from_bounds(
lon_bnds=(BBOX['minimum_longitude'], BBOX['maximum_longitude']),
lat_bnds=(BBOX['minimum_latitude'], BBOX['maximum_latitude']),
resolution=0.25,
)
GRID.lon.shape, GRID.lat.shape
((81,), (61,))
2. Multi-mission L3 along-track¶
Six conventional-altimeter / nadir missions, each pulled with cm.read_dataframe, then concatenated in long format. The dataframe has one row per (time, longitude, latitude, variable) tuple — we keep variable == 'sla_filtered' (the operational along-track SLA used by DUACS for L4 mapping).
ALONGTRACK = {
'S3A': 'cmems_obs-sl_glo_phy-ssh_nrt_s3a-l3-duacs_PT1S',
'S3B': 'cmems_obs-sl_glo_phy-ssh_nrt_s3b-l3-duacs_PT1S',
'S6A': 'cmems_obs-sl_glo_phy-ssh_nrt_s6a-hr-l3-duacs_PT1S',
'H2B': 'cmems_obs-sl_glo_phy-ssh_nrt_h2b-l3-duacs_PT1S',
'C2N': 'cmems_obs-sl_glo_phy-ssh_nrt_c2n-l3-duacs_PT1S',
'AL': 'cmems_obs-sl_glo_phy-ssh_nrt_al-l3-duacs_PT1S',
'SWOT': 'cmems_obs-sl_glo_phy-ssh_nrt_swon-l3-duacs_PT1S',
}
def fetch_alongtrack(mission: str, dataset_id: str) -> pd.DataFrame:
df = cm.read_dataframe(
dataset_id=dataset_id,
**BBOX, **TIME,
disable_progress_bar=True,
)
df = df[df['variable'] == 'sla_filtered'].copy()
df['mission'] = mission
return df[['mission', 'time', 'longitude', 'latitude', 'value', 'platform_id']]
frames = {m: fetch_alongtrack(m, did) for m, did in ALONGTRACK.items()}
tracks = pd.concat(frames.values(), ignore_index=True)
tracks.groupby('mission').size().sort_values(ascending=False)
INFO - 2026-04-28T09:52:45Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:52:45Z - Selected dataset part: "default"
INFO - 2026-04-28T09:52:57Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:52:57Z - Selected dataset part: "default"
INFO - 2026-04-28T09:53:12Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:53:12Z - Selected dataset part: "default"
INFO - 2026-04-28T09:53:22Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:53:22Z - Selected dataset part: "default"
INFO - 2026-04-28T09:53:35Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:53:35Z - Selected dataset part: "default"
INFO - 2026-04-28T09:53:47Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:53:47Z - Selected dataset part: "default"
INFO - 2026-04-28T09:53:57Z - Selected dataset version: "202311"
INFO - 2026-04-28T09:53:57Z - Selected dataset part: "default"
mission S6A 5728 S3A 5562 S3B 5517 C2N 5307 H2B 5235 AL 4990 SWOT 4839 dtype: int64
Track maps¶
One panel per mission; colour is filtered SLA (m).
fig, axes = plt.subplots(2, 4, figsize=(13, 6.5), sharex=True, sharey=True)
vmax = float(np.nanpercentile(np.abs(tracks['value']), 95))
for ax, (m, df) in zip(axes.ravel(), frames.items(), strict=False):
sc = ax.scatter(df['longitude'], df['latitude'], c=df['value'],
s=0.6, cmap='RdBu_r', vmin=-vmax, vmax=vmax)
ax.set_title(f'{m} (n={len(df):,})', fontsize=10)
ax.set_xlim(BBOX['minimum_longitude'], BBOX['maximum_longitude'])
ax.set_ylim(BBOX['minimum_latitude'], BBOX['maximum_latitude'])
axes.ravel()[-1].axis('off')
fig.colorbar(sc, ax=axes, shrink=0.7, label='filtered SLA (m)')
fig.suptitle('CMEMS L3 along-track SLA — Gulf Stream, 2026-01-01 → 2026-01-15', y=1.02)
plt.show()
Zoom: how thin are the nadir tracks vs SWOT (KaRIn)?¶
A small sub-region ([-65°, -57°] × [34°, 40°]) where the individual passes are visible as thin lines — each conventional altimeter, including SWOT-nadir, has the same ~6–7 km nadir footprint. The right panel shows the SWOS L4 demo over the same sub-region: the 2D field is what KaRIn buys you, even after the operational mapping smooths the swath onto a regular grid. Compare the line-thinness on the left to the filled 2D field on the right.
if 'ds_swos' not in globals():
ds_swos = cm.open_dataset(
dataset_id='cmems_obs-sl_glo_phy-ssh_nrt_demo-allsat-swos-l4-duacs-0.125deg_P1D-i',
**BBOX, **TIME,
)
ZOOM = dict(lon_min=-65.0, lon_max=-57.0, lat_min=34.0, lat_max=40.0)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# left: per-mission nadir tracks, color by mission
palette = plt.cm.tab10(np.linspace(0, 1, len(frames)))
ax = axes[0]
for color, (m, df) in zip(palette, frames.items(), strict=False):
sub = df[
df['longitude'].between(ZOOM['lon_min'], ZOOM['lon_max'])
& df['latitude'].between(ZOOM['lat_min'], ZOOM['lat_max'])
]
ax.scatter(sub['longitude'], sub['latitude'], s=2, color=color,
label=f'{m} (n={len(sub)})')
ax.set_xlim(ZOOM['lon_min'], ZOOM['lon_max'])
ax.set_ylim(ZOOM['lat_min'], ZOOM['lat_max'])
ax.set_xlabel('lon'); ax.set_ylabel('lat')
ax.set_title('L3 nadir tracks — colored by mission')
ax.legend(loc='upper right', fontsize=7, markerscale=2.5, framealpha=0.9)
ax.set_aspect('equal')
# right: SWOS L4 demo over the same zoom
ax = axes[1]
swos_zoom = ds_swos['sla'].mean('time').sel(
longitude=slice(ZOOM['lon_min'], ZOOM['lon_max']),
latitude=slice(ZOOM['lat_min'], ZOOM['lat_max']),
)
swos_zoom.plot.pcolormesh(
ax=ax, cmap='RdBu_r', vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'},
)
ax.set_title('SWOS L4 demo — KaRIn-fused 2D field')
ax.set_aspect('equal')
fig.suptitle(
'Same sub-region: nadir tracks (left) vs SWOT-aware L4 (right)',
y=1.02,
)
plt.tight_layout()
plt.show()
INFO - 2026-04-28T09:54:09Z - Selected dataset version: "202511"
INFO - 2026-04-28T09:54:09Z - Selected dataset part: "default"
3. SWOT — what makes it different¶
SWOT carries both a conventional nadir altimeter (the swon stream above) and the wide-swath KaRIn interferometer that maps a ~120 km swath at ~2 km posting. The nadir stream is part of the standard L3 along-track family; the wide-swath product is not in the CMEMS catalog yet (it ships separately via AVISO and the JPL PO.DAAC). Here we show the SWOT nadir track on its own and compare it to the SWOT-aware L4 SWOS demo, which assimilates KaRIn into the all-sat L4.
swot_nadir = frames['SWOT']
fig, ax = plt.subplots(figsize=(8, 5))
sc = ax.scatter(swot_nadir['longitude'], swot_nadir['latitude'],
c=swot_nadir['value'], s=2, cmap='RdBu_r', vmin=-vmax, vmax=vmax)
ax.set_title(f'SWOT nadir (swon) — {len(swot_nadir):,} obs over 15 days')
ax.set_xlabel('lon'); ax.set_ylabel('lat')
fig.colorbar(sc, ax=ax, label='filtered SLA (m)')
plt.show()
# Optional: pull the SWOS L4 demo (SWOT KaRIn fused into the all-sat L4)
ds_swos = cm.open_dataset(
dataset_id='cmems_obs-sl_glo_phy-ssh_nrt_demo-allsat-swos-l4-duacs-0.125deg_P1D-i',
**BBOX, **TIME,
)
list(ds_swos.data_vars), dict(ds_swos.sizes)
INFO - 2026-04-28T09:54:16Z - Selected dataset version: "202511"
INFO - 2026-04-28T09:54:16Z - Selected dataset part: "default"
([np.str_('adt'),
np.str_('err_sla'),
np.str_('err_ugosa'),
np.str_('err_vgosa'),
np.str_('flag_ice'),
np.str_('sla'),
np.str_('ugos'),
np.str_('ugosa'),
np.str_('vgos'),
np.str_('vgosa')],
{'time': 15, 'latitude': 120, 'longitude': 160})
Single-day comparison: one nadir track vs one SWOT pass¶
Pick a single day in the window and plot each mission's full coverage for that day. All conventional altimeters — including SWOT-nadir — look the same: thin 1 Hz traces along the satellite ground track. What makes SWOT genuinely different is the wide-swath KaRIn instrument it carries alongside the nadir, which the nadir-only L3 stream does not expose. The right panel shows the SWOS L4 SLA for the same day with the SWOT-nadir trace overlaid in black: that 2D background field is what KaRIn buys (here laundered through OI on its way into a regular grid).
DAY = '2026-01-08'
missions_to_show = ['S3A', 'S6A', 'AL', 'SWOT']
day_frames = {
m: frames[m][frames[m]['time'].astype(str).str.startswith(DAY)]
for m in missions_to_show
}
fig, axes = plt.subplots(1, 2, figsize=(13, 5.2))
# left: each mission's full single-day track within the bbox
ax = axes[0]
palette = {'S3A': 'tab:blue', 'S6A': 'tab:orange',
'AL': 'tab:green', 'SWOT': 'tab:red'}
for m in missions_to_show:
df = day_frames[m]
ax.scatter(df['longitude'], df['latitude'], s=4,
color=palette[m], label=f'{m} (n={len(df)})')
ax.set_xlim(BBOX['minimum_longitude'], BBOX['maximum_longitude'])
ax.set_ylim(BBOX['minimum_latitude'], BBOX['maximum_latitude'])
ax.set_xlabel('lon'); ax.set_ylabel('lat')
ax.set_title(f'L3 nadir tracks on {DAY} — all four missions are 1 Hz nadir')
ax.legend(loc='upper right', fontsize=8, markerscale=2.5, framealpha=0.9)
ax.set_aspect('equal')
# right: SWOS L4 SLA for that day with SWOT-nadir trace overlaid
ax = axes[1]
swos_day = ds_swos['sla'].sel(time=DAY)
swos_day.plot.pcolormesh(
ax=ax, cmap='RdBu_r', vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'},
)
swot_day = day_frames['SWOT']
ax.scatter(swot_day['longitude'], swot_day['latitude'],
s=4, color='black', label=f'SWOT-nadir (n={len(swot_day)})')
ax.set_title(f'SWOS L4 (KaRIn-fused) on {DAY} + SWOT-nadir overlay')
ax.set_xlabel('lon'); ax.set_ylabel('lat')
ax.legend(loc='upper right', fontsize=8, markerscale=2.5, framealpha=0.9)
ax.set_aspect('equal')
fig.suptitle(
'A nadir is a nadir — what KaRIn adds is the 2D field beneath it',
y=1.02,
)
plt.tight_layout()
plt.show()
4. Bin each mission onto a common 0.25° grid¶
bin_2d aggregates a long-format DataArray onto a regular Grid via scipy.stats.binned_statistic_2d. We bin each mission's 15-day filtered SLA stream as a mean and the multi-mission stack as a count to show sampling density.
def to_dataarray(df: pd.DataFrame) -> xr.DataArray:
"""Promote a long-format frame to a 1-D xr.DataArray with lon/lat coords."""
return xr.DataArray(
df['value'].to_numpy(),
dims=('obs',),
coords={
'lon': ('obs', df['longitude'].to_numpy()),
'lat': ('obs', df['latitude'].to_numpy()),
},
name='sla_filtered',
)
binned = {
m: bin_2d(to_dataarray(df), GRID, statistic='mean')
for m, df in frames.items()
}
stack_mean = bin_2d(to_dataarray(tracks), GRID, statistic='mean')
stack_count = bin_2d(to_dataarray(tracks), GRID, statistic='count')
stack_mean
<xarray.DataArray 'sla_filtered' (lat: 61, lon: 81)> Size: 40kB
array([[0.069 , 0.031 , 0.03216667, ..., 0.11150001, 0.12680001,
0.1175 ],
[0.09675 , 0.05511111, 0.0484 , ..., 0.0873077 , 0.09225 ,
0.09942858],
[0.09460001, 0.08347059, 0.1112 , ..., 0.06236364, 0.099 ,
0.10711111],
...,
[ nan, nan, nan, ..., nan, 0.18083334,
0.12 ],
[ nan, nan, nan, ..., 0.10625 , 0.1926154 ,
nan],
[ nan, nan, nan, ..., 0.18583334, 0.14525 ,
nan]], shape=(61, 81))
Coordinates:
* lat (lat) float64 488B 30.0 30.25 30.5 30.75 ... 44.25 44.5 44.75 45.0
* lon (lon) float64 648B -70.0 -69.75 -69.5 -69.25 ... -50.5 -50.25 -50.0Per-mission binned mean SLA¶
Same colour scale as the scatter panels.
fig, axes = plt.subplots(2, 4, figsize=(13, 6.5), sharex=True, sharey=True)
for ax, (m, da) in zip(axes.ravel(), binned.items(), strict=False):
da.plot.pcolormesh(ax=ax, x='lon', y='lat', cmap='RdBu_r',
vmin=-vmax, vmax=vmax,
add_colorbar=False)
ax.set_title(f'{m} — binned mean SLA', fontsize=10)
axes.ravel()[-1].axis('off')
fig.suptitle('Per-mission binning to 0.25° (15-day mean of filtered SLA)', y=1.02)
plt.tight_layout()
plt.show()
Multi-mission stack: mean and sampling density¶
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))
stack_mean.plot.pcolormesh(
ax=axes[0], x='lon', y='lat',
cmap='RdBu_r', vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'mean filtered SLA (m)'})
axes[0].set_title(f'All-mission stack — binned mean (n={len(tracks):,})')
stack_count.plot.pcolormesh(
ax=axes[1], x='lon', y='lat',
cmap='magma',
cbar_kwargs={'label': '# observations / cell'})
axes[1].set_title('Sampling density (15 days)')
plt.tight_layout()
plt.show()
5. Sidebar — gridded L4 reanalyses for the same window¶
Two operational L4 grids over the same 15 days:
- NRT all-sat L4 at 0.125° — the standard DUACS optimal-interpolation product driven by the L3 along-track stack from §2.
- SWOS L4 demo at 0.125° — adds SWOT KaRIn observations to the same all-sat L4. The visual contrast against the NRT all-sat L4 is the closest thing to seeing the KaRIn signal without leaving CMEMS.
ds_l4 = cm.open_dataset(
dataset_id='cmems_obs-sl_glo_phy-ssh_nrt_allsat-l4-duacs-0.125deg_P1D',
**BBOX, **TIME,
)
list(ds_l4.data_vars), dict(ds_l4.sizes)
INFO - 2026-04-28T09:54:25Z - Selected dataset version: "202506"
INFO - 2026-04-28T09:54:25Z - Selected dataset part: "default"
([np.str_('adt'),
np.str_('err_sla'),
np.str_('err_ugosa'),
np.str_('err_vgosa'),
np.str_('flag_ice'),
np.str_('sla'),
np.str_('ugos'),
np.str_('ugosa'),
np.str_('vgos'),
np.str_('vgosa')],
{'time': 15, 'latitude': 120, 'longitude': 160})
sla_l4 = ds_l4['sla'].mean('time')
sla_swos = ds_swos['sla'].mean('time')
diff = sla_swos - sla_l4
fig, axes = plt.subplots(1, 3, figsize=(15, 4.2))
sla_l4.plot.pcolormesh( ax=axes[0], cmap='RdBu_r', vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'})
axes[0].set_title('NRT all-sat L4 (0.125°)')
sla_swos.plot.pcolormesh(ax=axes[1], cmap='RdBu_r', vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'})
axes[1].set_title('SWOS L4 demo — KaRIn fused (0.125°)')
dvmax = float(np.nanpercentile(np.abs(diff.values), 95))
diff.plot.pcolormesh( ax=axes[2], cmap='PuOr_r', vmin=-dvmax, vmax=dvmax,
cbar_kwargs={'label': 'Δ SLA (m)'})
axes[2].set_title('SWOS − all-sat')
fig.suptitle('15-day mean SLA — Gulf Stream', y=1.04)
plt.tight_layout()
plt.show()
6. Bin vs L4 — visual cross-check¶
Our naïve 0.25° binning of the L3 along-track stack against the operational L4 OI product, both as 15-day means. The L4 grid is smoother because it applies an explicit covariance model; the bin reflects raw sampling. The comparison is qualitative — not a validation.
sla_l4_25 = sla_l4.coarsen(latitude=2, longitude=2, boundary='trim').mean()
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
stack_mean.plot.pcolormesh(ax=axes[0], x='lon', y='lat', cmap='RdBu_r',
vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'})
axes[0].set_title('Multi-mission L3 → bin_2d (0.25°)')
sla_l4_25.plot.pcolormesh( ax=axes[1], cmap='RdBu_r',
vmin=-vmax, vmax=vmax,
cbar_kwargs={'label': 'SLA (m)'})
axes[1].set_title('NRT all-sat L4 (coarsened to 0.25°)')
plt.tight_layout()
plt.show()
Takeaways¶
- The L3 along-track family is observation-shaped: per-mission, 1 Hz nadir traces, served as a long-format dataframe — bin or interpolate to get a grid.
- SWOT-nadir (
swon) shows up as one more mission in that stack; the wide-swath KaRIn product lives outside CMEMS at present. - The SWOS L4 demo is the practical way to see the KaRIn fingerprint without changing data centers — its L4 differs from the all-sat L4 in the regions KaRIn happens to cover.
- The crude
bin_2dreduction recovers the broad SLA pattern but lacks the spatial smoothness of the OI L4 grid; binning is mainly useful as an inspection / sampling-density tool, not a replacement for the L4.