CMEMS SSS products — SMOS L3 (asc/des) and MULTIOBS L4¶
Sea-surface salinity is the family with the least gridded depth in CMEMS: no per-mission gridded products other than SMOS (the dedicated salinity satellite) split by orbit direction, and a single multi-mission L4 derived from SMOS + SMAP + Argo. The notebook walks the SMOS asc/des → MULTIOBS L4 path so you can see how much smoothing the operational mapping does.
Region & window: Gulf Stream
[-70°, -50°] × [30°, 45°], 2026-01-01 → 2026-01-15 (same as the SSH notebook for cross-family consistency).
Credentials live in the project-root .env (COPERNICUSMARINE_SERVICE_USERNAME / ..._PASSWORD).
1. Setup¶
from __future__ import annotations
import warnings
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from dotenv import load_dotenv
import copernicusmarine as cm
from xrtoolz.interpolate import Grid
warnings.filterwarnings('ignore', category=FutureWarning)
warnings.filterwarnings('ignore', category=RuntimeWarning)
load_dotenv()
/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
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')
# Common 0.25° reference grid for cross-product comparison.
GRID = Grid.from_bounds(
lon_bnds=(BBOX['minimum_longitude'], BBOX['maximum_longitude']),
lat_bnds=(BBOX['minimum_latitude'], BBOX['maximum_latitude']),
resolution=0.25,
)
ref = xr.Dataset(coords={'longitude': GRID.lon, 'latitude': GRID.lat})
GRID.lon.shape, GRID.lat.shape
((81,), (61,))
def to_common_grid(da: xr.DataArray, *, ref: xr.Dataset = ref) -> xr.DataArray:
"""Interpolate a 2D DataArray (with lat/lon coords) onto the 0.25° reference grid.
Source resolutions in this family vary (0.05°, 0.1°, 0.25°, 4 km, 300 m); we put
everything on a single 0.25° grid so cross-product panels share a coordinate system.
"""
return da.interp(
latitude=ref['latitude'],
longitude=ref['longitude'],
method='linear',
)
2. SMOS L3 — ascending vs descending¶
SMOS flies a sun-synchronous orbit; ascending and descending passes hit the same scene at different local times of day. CMEMS publishes the two as separate L3 products at 0.25°. Both are NaN-heavy daily fields — swath edges, RFI flagging, ice.
L3_SMOS = {
'asc': 'cmems_obs-mob_glo_phy-sss_mynrt_smos-asc_P1D',
'des': 'cmems_obs-mob_glo_phy-sss_mynrt_smos-des_P1D',
}
ds_smos = {
label: cm.open_dataset(dataset_id=did, **BBOX, **TIME)
for label, did in L3_SMOS.items()
}
ds_smos['asc']
INFO - 2026-04-28T10:51:45Z - Selected dataset version: "202411"
INFO - 2026-04-28T10:51:45Z - Selected dataset part: "default"
INFO - 2026-04-28T10:51:50Z - Selected dataset version: "202411"
INFO - 2026-04-28T10:51:50Z - Selected dataset part: "default"
<xarray.Dataset> Size: 4MB
Dimensions: (time: 15, latitude: 75,
longitude: 100)
Coordinates:
* time (time) datetime64[ns] 120B 202...
* latitude (latitude) float64 600B 30.0 ....
* longitude (longitude) float64 800B -69.8...
Data variables:
Mean_Acq_Time (time, latitude, longitude) datetime64[ns] 900kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Sea_Surface_Salinity (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Sea_Surface_Salinity_Error (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Sea_Surface_Salinity_QC (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Sea_Surface_Salinity_Rain_Corrected (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Sea_Surface_Salinity_Rain_Corrected_Error (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
X_Swath (time, latitude, longitude) float32 450kB dask.array<chunksize=(15, 75, 100), meta=np.ndarray>
Attributes: (12/25)
Conventions: CF-1.8
DOI: 10.12770/12dba510-cd71-4d4f-9fc1-9cc027d128b0
DOI_link: https://doi.org/10.12770/12dba510-cd71-4d4f-9f...
citation: CATDS (2024). CATDS-PDC L3OS 2Q - Debiased dai...
contact: servicedesk.cmems@mercator-ocean.eu
creation_date: 2025/02/24
... ...
publication: In addition to properly cite this dataset, it ...
references: https://doi.org/10.12770/12dba510-cd71-4d4f-9f...
source: CATDS L3OS coast correction
srid: EPSG:6933
title: SMOS CATDS Qualified (L2Q) Sea Surface Salinit...
copernicusmarine_version: 2.4.0DAY = '2026-01-08'
vmin, vmax = 33.5, 37.5 # PSU
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
for ax, (label, ds) in zip(axes, ds_smos.items(), strict=False):
sss = ds['Sea_Surface_Salinity'].sel(time=DAY, method='nearest').squeeze()
sss.plot.pcolormesh(ax=ax, cmap='viridis', vmin=vmin, vmax=vmax,
cbar_kwargs={'label': 'SSS (PSU)'})
cov = float(np.isfinite(sss).mean())
ax.set_title(f'SMOS {label} on {DAY} — coverage: {cov:.0%}')
plt.tight_layout()
plt.show()
15-day SMOS asc + des composites¶
Means across the window — the per-orbit bias is small in this region but visible at the edges of swath cusps.
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
for ax, (label, ds) in zip(axes, ds_smos.items(), strict=False):
ds['Sea_Surface_Salinity'].mean('time').plot.pcolormesh(
ax=ax, cmap='viridis', vmin=vmin, vmax=vmax,
cbar_kwargs={'label': 'SSS (PSU)'},
)
ax.set_title(f'SMOS {label} — 15-day mean')
plt.tight_layout()
plt.show()
3. MULTIOBS L4 — daily multi-mission analysis¶
Brings SMOS asc + des, SMAP, and Argo float surface SSS into a single 0.125° daily field with surface density (sos, dos).
ds_l4 = cm.open_dataset(
dataset_id='cmems_obs-mob_glo_phy-sss_nrt_multi_P1D',
**BBOX, **TIME,
)
list(ds_l4.data_vars), dict(ds_l4.sizes)
INFO - 2026-04-28T10:52:02Z - Selected dataset version: "202311"
INFO - 2026-04-28T10:52:02Z - Selected dataset part: "default"
([np.str_('dos'),
np.str_('dos_error'),
np.str_('sea_ice_fraction'),
np.str_('sos'),
np.str_('sos_error')],
{'time': 15, 'depth': 1, 'latitude': 120, 'longitude': 160})
fig, axes = plt.subplots(1, 2, figsize=(11, 4.4))
ds_l4['sos'].sel(time=DAY, method='nearest').squeeze().plot.pcolormesh(
ax=axes[0], cmap='viridis', vmin=vmin, vmax=vmax,
cbar_kwargs={'label': 'SSS (PSU)'},
)
axes[0].set_title(f'MULTIOBS L4 SSS on {DAY}')
ds_l4['dos'].sel(time=DAY, method='nearest').squeeze().plot.pcolormesh(
ax=axes[1], cmap='cividis',
cbar_kwargs={'label': 'σ (kg/m³)'},
)
axes[1].set_title(f'MULTIOBS L4 surface density on {DAY}')
plt.tight_layout()
plt.show()
4. Cross-product comparison at 0.25°¶
Three products on the common reference grid: SMOS asc / SMOS des / MULTIOBS L4. The L4 will look much smoother — that's OI doing its job.
products_25 = {
'SMOS asc': to_common_grid(ds_smos['asc']['Sea_Surface_Salinity'].mean('time')),
'SMOS des': to_common_grid(ds_smos['des']['Sea_Surface_Salinity'].mean('time')),
'MULTIOBS L4': to_common_grid(ds_l4['sos'].mean('time').squeeze()),
}
fig, axes = plt.subplots(1, 3, figsize=(15, 4.4), sharex=True, sharey=True)
for ax, (label, da) in zip(axes, products_25.items(), strict=False):
da.plot.pcolormesh(ax=ax, cmap='viridis', vmin=vmin, vmax=vmax,
add_colorbar=False)
cov = float(np.isfinite(da).mean())
ax.set_title(f'{label}\ncoverage: {cov:.0%}')
fig.suptitle('15-day mean SSS — every product on the common 0.25° grid', y=1.04)
plt.tight_layout()
plt.show()
MULTIOBS minus SMOS-merged¶
Take the simple mean of the two SMOS streams as a poor-man's L3-merge and difference against the operational L4.
smos_merge = (products_25['SMOS asc'] + products_25['SMOS des']) / 2
diff = products_25['MULTIOBS L4'] - smos_merge
dvmax = float(np.nanpercentile(np.abs(diff.values), 95)) or 0.5
fig, ax = plt.subplots(figsize=(7, 4.5))
diff.plot.pcolormesh(ax=ax, cmap='RdBu_r', vmin=-dvmax, vmax=dvmax,
cbar_kwargs={'label': 'Δ SSS (PSU)'})
ax.set_title('MULTIOBS L4 − ½(SMOS asc + des), 15-day mean')
plt.tight_layout()
plt.show()
Takeaways¶
- SMOS is the only single-mission gridded SSS product on CMEMS — split into asc / des because the two have measurably different bias structure.
- MULTIOBS L4 is the only operational gap-free SSS field. Its smoothness comes from OI, not from a denser observation set: the satellite SSS constellation is genuinely sparse compared with SST/OC.
- For physical SSS gradients (Gulf Stream front, river plumes), L4 is the right product. For algorithm-development or DA where you want the actual obs uncertainty, you want SMOS L3 + SMAP L3 (the latter not on CMEMS — see issue #20).