Sea-surface temperature (SST) observations from passive microwave (PMW) satellites contain gaps due to cloud cover, land, and sea-ice masking. Gap-filling with scattering-transform synthesis (FOSCAT) typically uses standard HEALPix, which assumes a perfect sphere.
The Earth is not a sphere -- it is an oblate ellipsoid best described by
WGS84. The healpix-geo and healpix-resample packages let us run
HEALPix on the WGS84 ellipsoid instead.
Research question: Does accounting for the WGS84 ellipsoid geometry when resampling SST data to HEALPix improve the accuracy of FOSCAT gap-filling compared to the standard spherical assumption?
Method:
Download L3S (gappy) and L4 (reference) SST from Copernicus Marine.
Resample both to HEALPix using (a) sphere and (b) WGS84 ellipsoid.
Run FOSCAT synthesis to fill gaps in L3S.
Compare RMSE of gap-filled vs L4 reference for each geometry.
import os
import warnings
import time
import json
import numpy as np
import healpy as hp
import matplotlib
import matplotlib.pyplot as plt
warnings.filterwarnings("ignore", category=DeprecationWarning)
warnings.filterwarnings("ignore", category=FutureWarning)Configuration¶
We use lower resolution and fewer synthesis steps in CI to keep runtime
short. Set CI_MODE=1 to activate.
DATE = "2026-04-01"
CI_MODE = os.environ.get("CI_MODE", "0") == "1"
# LEVEL is the GRID4EARTH 'depth' (nside = 2**LEVEL): nside=16 -> depth 4, nside=32 -> depth 5.
if CI_MODE:
NSIDE = 16
LEVEL = 4 # HEALPix level / GRID4EARTH depth for healpix-resample (2^level = NSIDE)
NSTEPS = 20
LMAX = 15
print("Running in CI mode (low resolution)")
else:
NSIDE = 32
LEVEL = 5
NSTEPS = 300
LMAX = 30
print("Running in full-resolution mode")
NPIX = hp.nside2npix(NSIDE)
print(f"NSIDE={NSIDE}, LEVEL={LEVEL}, NPIX={NPIX}, NSTEPS={NSTEPS}, LMAX={LMAX}")Running in full-resolution mode
NSIDE=32, LEVEL=5, NPIX=12288, NSTEPS=300, LMAX=30
Data Loading¶
We download two Copernicus Marine SST products:
L3S (Level 3 Super-collated): multi-sensor PMW daily, with gaps.
L4 (Level 4): gap-free optimally-interpolated analysis (our reference).
import copernicusmarine
def load_sst_data(date_str):
"""Download L3S and L4 SST for a single day."""
date_start = date_str
date_end = date_str
# L3S -- gappy PMW observations
ds_l3s = copernicusmarine.open_dataset(
dataset_id="cmems_obs-sst_glo_phy_l3s_pmw_P1D-m",
variables=["sea_surface_temperature"],
minimum_longitude=-180,
maximum_longitude=180,
minimum_latitude=-90,
maximum_latitude=90,
start_datetime=f"{date_start}T00:00:00",
end_datetime=f"{date_end}T23:59:59",
)
# L4 -- gap-free reference
ds_l4 = copernicusmarine.open_dataset(
dataset_id="cmems_obs-sst_glo_phy-temp_nrt_P1D-m",
variables=["analysed_sst"],
minimum_longitude=-180,
maximum_longitude=180,
minimum_latitude=-90,
maximum_latitude=90,
start_datetime=f"{date_start}T00:00:00",
end_datetime=f"{date_end}T23:59:59",
)
return ds_l3s, ds_l4
ds_l3s, ds_l4 = load_sst_data(DATE)
print(f"L3S shape: {ds_l3s['sea_surface_temperature'].shape}")
print(f"L4 shape: {ds_l4['analysed_sst'].shape}")INFO - 2026-06-28T06:44:25Z - Selected dataset version: "202311"
INFO - 2026-06-28T06:44:25Z - Selected dataset part: "default"
WARNING - 2026-06-28T06:44:25Z - Some of your subset selection [-90.0, 90.0] for the latitude dimension exceed the dataset coordinates [-79.875, 79.875]
WARNING - 2026-06-28T06:44:25Z - Some of your subset selection [-180.0, 180.0] for the longitude dimension exceed the dataset coordinates [-179.875, 179.875]
INFO - 2026-06-28T06:44:30Z - Selected dataset version: "202603"
INFO - 2026-06-28T06:44:30Z - Selected dataset part: "default"
WARNING - 2026-06-28T06:44:30Z - Some of your subset selection [-90.0, 90.0] for the latitude dimension exceed the dataset coordinates [-89.875, 89.875]
WARNING - 2026-06-28T06:44:30Z - Some of your subset selection [-180.0, 180.0] for the longitude dimension exceed the dataset coordinates [-179.875, 179.875]
L3S shape: (1, 640, 1440)
L4 shape: (1, 720, 1440)
Regrid L3S to L4 grid if needed¶
The two products may be on different grids. We regrid L3S onto the L4 lat/lon grid using nearest-neighbour interpolation so that both share the same 2-D structure before HEALPix resampling.
import xarray as xr
sst_l4_2d = ds_l4["analysed_sst"].isel(time=0).values # (lat, lon)
mask_l4 = ~np.isnan(sst_l4_2d)
# Check if grids match — handle both lat/lon and latitude/longitude naming
lat_name_l3s = "lat" if "lat" in ds_l3s.dims else "latitude"
lon_name_l3s = "lon" if "lon" in ds_l3s.dims else "longitude"
lat_name_l4 = "lat" if "lat" in ds_l4.dims else "latitude"
lon_name_l4 = "lon" if "lon" in ds_l4.dims else "longitude"
l3s_lat = ds_l3s[lat_name_l3s].values
l4_lat = ds_l4[lat_name_l4].values
l3s_lon = ds_l3s[lon_name_l3s].values
l4_lon = ds_l4[lon_name_l4].values
if l3s_lat.shape != l4_lat.shape or not np.allclose(l3s_lat, l4_lat, atol=0.01):
print("Grids differ -- regridding L3S to L4 grid via interp")
ds_l3s_regrid = ds_l3s.interp({lat_name_l3s: l4_lat, lon_name_l3s: l4_lon}, method="nearest")
sst_l3s_2d = ds_l3s_regrid["sea_surface_temperature"].isel(time=0).values
else:
print("Grids match")
sst_l3s_2d = ds_l3s["sea_surface_temperature"].isel(time=0).values
print(f"L3S 2D: {sst_l3s_2d.shape}, NaN fraction: {np.isnan(sst_l3s_2d).mean():.2%}")
print(f"L4 2D: {sst_l4_2d.shape}, NaN fraction: {np.isnan(sst_l4_2d).mean():.2%}")Grids differ -- regridding L3S to L4 grid via interp
L3S 2D: (720, 1440), NaN fraction: 74.18%
L4 2D: (720, 1440), NaN fraction: 45.56%
Resampling to HEALPix¶
We use healpix_resample.GroupByResampler which supports both standard
spherical HEALPix and WGS84-ellipsoid HEALPix via the ellipsoid
parameter.
from healpix_resample import GroupByResampler
def resample_to_healpix(data_2d, mask_2d, lat, lon, ellipsoid="sphere"):
"""
Resample a 2-D lat/lon array to HEALPix.
Parameters
----------
data_2d : ndarray (nlat, nlon)
SST values; NaN where missing.
mask_2d : ndarray (nlat, nlon)
Boolean ocean mask (True = ocean).
lat, lon : 1-D arrays
Latitude and longitude vectors.
ellipsoid : str
"sphere" or "WGS84".
Returns
-------
hp_map : ndarray (npix,)
HEALPix map with NaN for empty cells.
"""
lon_grid, lat_grid = np.meshgrid(lon, lat)
valid = mask_2d & ~np.isnan(data_2d)
lon_deg = lon_grid[valid].ravel()
lat_deg = lat_grid[valid].ravel()
values = data_2d[valid].ravel()
resampler = GroupByResampler(
lon_deg=lon_deg,
lat_deg=lat_deg,
level=LEVEL,
reduce="mean",
ellipsoid=ellipsoid,
)
result = resampler.resample(values)
# Build full-size map
hp_map = np.full(NPIX, np.nan)
hp_map[result.cell_ids] = result.cell_data
return hp_mapFOSCAT Synthesis¶
We define a function that:
Resamples L4 and L3S to HEALPix with the chosen ellipsoid.
Identifies ocean, observed, and cloud-gap cells.
Computes a spherical-harmonics baseline for the gaps.
Runs FOSCAT scattering-transform synthesis to fill the gaps.
Evaluates RMSE against the L4 reference on the gap cells.
import foscat.scat_cov as sc
import foscat.Synthesis as synth
def run_foscat(ellipsoid="sphere"):
"""
Full gap-filling pipeline for a given ellipsoid.
Returns
-------
result : dict
Keys: ellipsoid, rmse_mk, time_s, hp_l4, hp_l3s, hp_filled, gap_mask
"""
print(f"\n{'='*60}")
print(f" Running FOSCAT with ellipsoid = {ellipsoid}")
print(f"{'='*60}")
t0 = time.time()
# --- 1. Resample to HEALPix ---
lat_name = "lat" if "lat" in ds_l4.dims else "latitude"
lon_name = "lon" if "lon" in ds_l4.dims else "longitude"
lat = ds_l4[lat_name].values
lon = ds_l4[lon_name].values
hp_l4 = resample_to_healpix(sst_l4_2d, mask_l4, lat, lon, ellipsoid=ellipsoid)
hp_l3s = resample_to_healpix(sst_l3s_2d, mask_l4, lat, lon, ellipsoid=ellipsoid)
ocean = ~np.isnan(hp_l4)
observed = ~np.isnan(hp_l3s)
clouds = ocean & ~observed # gap cells (ocean but not observed)
n_ocean = ocean.sum()
n_obs = observed.sum()
n_gap = clouds.sum()
print(f" Ocean cells: {n_ocean}")
print(f" Observed cells: {n_obs}")
print(f" Gap cells: {n_gap} ({100*n_gap/n_ocean:.1f}%)")
# --- 2. Spherical-harmonics baseline for gaps ---
ocean_mean = np.nanmean(hp_l4[ocean])
hp_start = hp_l3s.copy()
# Fill NaN with ocean mean for alm computation
hp_for_alm = np.where(np.isnan(hp_start), ocean_mean, hp_start)
alm = hp.map2alm(hp_for_alm, lmax=LMAX)
baseline = hp.alm2map(alm, NSIDE, verbose=False)
# Use baseline to fill gap cells as initial guess
hp_start[clouds] = baseline[clouds]
# Set non-ocean cells to ocean mean (not 0 — SST is ~270-304 K)
hp_start[~ocean] = ocean_mean
hp_start[np.isnan(hp_start)] = ocean_mean
print(f" Spherical-harmonics baseline computed (LMAX={LMAX})")
# --- 3. FOSCAT synthesis ---
# Prepare L4 reference: fill non-ocean with ocean mean
hp_l4_clean = hp_l4.copy()
hp_l4_clean[~ocean | np.isnan(hp_l4_clean)] = ocean_mean
scat_op = sc.funct(NORIENT=4, KERNELSZ=3, all_type="float32", silent=True)
data = hp_start.reshape(1, NPIX).astype(np.float32)
mask_ocean = ocean.reshape(1, NPIX).astype(np.float32)
# Reference from L4 gap-free product
ref, sref = scat_op.eval(
hp_l4_clean.reshape(1, NPIX).astype(np.float32),
mask=mask_ocean, calc_var=True
)
def The_loss(x, scat_operator, args):
ref, mask, sref = args[0], args[1], args[2]
learn = scat_operator.eval(x, mask=mask)
loss = scat_operator.reduce_mean(scat_operator.square((ref - learn) / sref))
return loss
mask_const = scat_op.backend.constant(scat_op.backend.bk_cast(mask_ocean))
loss = synth.Loss(The_loss, scat_op, ref, mask_const, sref)
sy = synth.Synthesis([loss])
mask_clouds_t = scat_op.backend.bk_cast(clouds.reshape(1, NPIX).astype(np.float32))
omap = sy.run(scat_op.backend.bk_cast(data), EVAL_FREQUENCY=max(NSTEPS//10, 1),
grd_mask=mask_clouds_t, NUM_EPOCHS=NSTEPS, do_lbfgs=True)
hp_filled = np.array(omap).ravel() if not hasattr(omap, 'numpy') else omap.numpy().ravel()
# Mask land cells as NaN for clean visualisation
hp_filled[~ocean] = np.nan
elapsed = time.time() - t0
# --- 4. RMSE on gap cells ---
diff = hp_filled[clouds] - hp_l4[clouds]
rmse = np.sqrt(np.nanmean(diff ** 2))
rmse_mk = rmse * 1000 # convert K to mK
print(f" RMSE on gaps: {rmse_mk:.1f} mK")
print(f" Elapsed time: {elapsed:.1f} s")
return {
"ellipsoid": ellipsoid,
"rmse_mk": float(rmse_mk),
"time_s": float(elapsed),
"n_ocean": int(n_ocean),
"n_observed": int(n_obs),
"n_gaps": int(n_gap),
"hp_l4": hp_l4,
"hp_l3s": hp_l3s,
"hp_filled": hp_filled,
"gap_mask": clouds,
}Run both geometries¶
r_sphere = run_foscat("sphere")
r_wgs84 = run_foscat("WGS84")
============================================================
Running FOSCAT with ellipsoid = sphere
============================================================
Ocean cells: 8902
Observed cells: 6099
Gap cells: 2803 (31.5%)
Spherical-harmonics baseline computed (LMAX=30)
/tmp/ipykernel_2305/3396244428.py:47: HealpyDeprecationWarning: "verbose" was deprecated in version 1.15.0 and will be removed in a future version.
baseline = hp.alm2map(alm, NSIDE, verbose=False)
/home/runner/micromamba/envs/fiesta-sst-healpix/lib/python3.11/site-packages/foscat/BkTorch.py:1329: UserWarning: The given NumPy array is not writable, and PyTorch does not support non-writable tensors. This means writing to this tensor will result in undefined behavior. You may want to copy the array to protect its data or make it writable before converting it to a tensor. This type of warning will be suppressed for the rest of this program. (Triggered internally at /home/conda/feedstock_root/build_artifacts/libtorch_1781053922290/work/torch/csrc/utils/tensor_numpy.cpp:213.)
x = self.backend.from_numpy(x).to(self.torch_device)
/home/runner/micromamba/envs/fiesta-sst-healpix/lib/python3.11/site-packages/foscat/BkTorch.py:902: UserWarning: Sparse invariant checks are implicitly disabled. Memory errors (e.g. SEGFAULT) will occur when operating on a sparse tensor which violates the invariants, but checks incur performance overhead. To silence this warning, explicitly opt in or out. See `torch.sparse.check_sparse_tensor_invariants.__doc__` for guidance. (Triggered internally at /home/conda/feedstock_root/build_artifacts/libtorch_1781053922290/work/aten/src/ATen/Context.cpp:760.)
return self.backend.sparse_coo_tensor(indice, w, dense_shape).coalesce().to_sparse_csr().to(self.torch_device)
/home/runner/micromamba/envs/fiesta-sst-healpix/lib/python3.11/site-packages/foscat/BkTorch.py:902: UserWarning: Sparse CSR tensor support is in beta state. If you miss a functionality in the sparse tensor support, please submit a feature request to https://github.com/pytorch/pytorch/issues. (Triggered internally at /home/conda/feedstock_root/build_artifacts/libtorch_1781053922290/work/aten/src/ATen/SparseCsrTensorImpl.cpp:49.)
return self.backend.sparse_coo_tensor(indice, w, dense_shape).coalesce().to_sparse_csr().to(self.torch_device)
initialise down 32
initialise down 16
initialise down 8
initialise down 4
initialise down 2
Total number of loss 1
Itt 0 L= 72.8 ( 72.8 ) 0.209s
Itt 30 L= 1.37 ( 1.37 ) 7.276s
Itt 60 L= 0.343 ( 0.343 ) 7.777s
Itt 90 L= 0.115 ( 0.115 ) 6.866s
Itt 120 L= 0.0511 ( 0.0511 ) 6.948s
Itt 150 L= 0.0333 ( 0.0333 ) 6.676s
Itt 180 L= 0.0246 ( 0.0246 ) 6.990s
Itt 210 L= 0.0199 ( 0.0199 ) 7.235s
Itt 240 L= 0.0172 ( 0.0172 ) 6.893s
Itt 270 L= 0.0157 ( 0.0157 ) 6.868s
Itt 300 L= 0.0148 ( 0.0148 ) 6.960s
Final Loss 0.014753496274352074
RMSE on gaps: 1029.5 mK
Elapsed time: 71.8 s
============================================================
Running FOSCAT with ellipsoid = WGS84
============================================================
Ocean cells: 8902
Observed cells: 6095
Gap cells: 2807 (31.5%)
Spherical-harmonics baseline computed (LMAX=30)
initialise down 32
initialise down 16
initialise down 8
initialise down 4
initialise down 2
Total number of loss 1
Itt 0 L= 28.9 ( 28.9 ) 0.221s
Itt 30 L= 0.158 ( 0.158 ) 7.304s
Itt 60 L= 0.0271 ( 0.0271 ) 6.754s
Itt 90 L= 0.0172 ( 0.0172 ) 6.592s
Itt 120 L= 0.0161 ( 0.0161 ) 6.300s
Itt 150 L= 0.0155 ( 0.0155 ) 6.581s
Itt 180 L= 0.0143 ( 0.0143 ) 6.391s
Itt 210 L= 0.0141 ( 0.0141 ) 6.372s
Itt 240 L= 0.014 ( 0.014 ) 6.301s
Final Loss 0.014027728699147701
RMSE on gaps: 980.0 mK
Elapsed time: 61.7 s
Results comparison¶
print("\n" + "=" * 60)
print(" Results Summary")
print("=" * 60)
print(f"{'Geometry':<12} {'RMSE (mK)':>10} {'Time (s)':>10} {'Gaps':>8}")
print("-" * 44)
print(f"{'sphere':<12} {r_sphere['rmse_mk']:>10.1f} {r_sphere['time_s']:>10.1f} {r_sphere['n_gaps']:>8d}")
print(f"{'WGS84':<12} {r_wgs84['rmse_mk']:>10.1f} {r_wgs84['time_s']:>10.1f} {r_wgs84['n_gaps']:>8d}")
print("-" * 44)
diff_mk = r_sphere["rmse_mk"] - r_wgs84["rmse_mk"]
if diff_mk > 0:
print(f"WGS84 improves RMSE by {diff_mk:.1f} mK ({100*diff_mk/r_sphere['rmse_mk']:.1f}%)")
elif diff_mk < 0:
print(f"Sphere is better by {-diff_mk:.1f} mK ({100*(-diff_mk)/r_wgs84['rmse_mk']:.1f}%)")
else:
print("No difference between sphere and WGS84")
============================================================
Results Summary
============================================================
Geometry RMSE (mK) Time (s) Gaps
--------------------------------------------
sphere 1029.5 71.8 2803
WGS84 980.0 61.7 2807
--------------------------------------------
WGS84 improves RMSE by 49.6 mK (4.8%)
Save results¶
results_dict = {
"date": DATE,
"nside": NSIDE,
"level": LEVEL,
"nsteps": NSTEPS,
"lmax": LMAX,
"ci_mode": CI_MODE,
"sphere": {
"rmse_mk": r_sphere["rmse_mk"],
"time_s": r_sphere["time_s"],
"n_ocean": r_sphere["n_ocean"],
"n_observed": r_sphere["n_observed"],
"n_gaps": r_sphere["n_gaps"],
},
"wgs84": {
"rmse_mk": r_wgs84["rmse_mk"],
"time_s": r_wgs84["time_s"],
"n_ocean": r_wgs84["n_ocean"],
"n_observed": r_wgs84["n_observed"],
"n_gaps": r_wgs84["n_gaps"],
},
"diff_mk": float(diff_mk),
}
results_path = os.path.join("results", "comparison_results.json")
os.makedirs("results", exist_ok=True)
with open(results_path, "w") as f:
json.dump(results_dict, f, indent=2)
print(f"Results saved to {results_path}")Results saved to results/comparison_results.json
HEALPix maps¶
Visualise the input SST (with cloud gaps), the FOSCAT gap-filled results for both sphere and WGS84 geometries, and the L4 reference.
# Common color range from L4 reference
vmin = np.nanmin(r_sphere["hp_l4"][r_sphere["gap_mask"]])
vmax = np.nanmax(r_sphere["hp_l4"][r_sphere["gap_mask"]])
maps = [
(r_sphere["hp_l3s"], "L3S SST (with cloud gaps)"),
(r_sphere["hp_filled"], f"FOSCAT Sphere — RMSE = {r_sphere['rmse_mk']:.1f} mK"),
(r_wgs84["hp_filled"], f"FOSCAT WGS84 — RMSE = {r_wgs84['rmse_mk']:.1f} mK"),
(r_sphere["hp_l4"], "L4 Reference (gap-free)"),
]
for data, title in maps:
hp.mollview(data, title=title, cmap="RdYlBu_r",
min=vmin, max=vmax, nest=True, unit="K")
plt.savefig(os.path.join("results", f"map_{title.split()[0].lower()}.png"),
dpi=150, bbox_inches="tight")
plt.show()



Comparison bar chart¶
fig, ax = plt.subplots(figsize=(6, 4))
labels = ["Sphere", "WGS84"]
rmses = [r_sphere["rmse_mk"], r_wgs84["rmse_mk"]]
colors = ["#4878CF", "#D65F5F"]
bars = ax.bar(labels, rmses, color=colors, width=0.5, edgecolor="black", linewidth=0.8)
for bar, val in zip(bars, rmses):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 0.5,
f"{val:.1f}",
ha="center",
va="bottom",
fontsize=12,
fontweight="bold",
)
ax.set_ylabel("RMSE (mK)", fontsize=12)
ax.set_title(f"SST Gap-Filling RMSE: Sphere vs WGS84\n{DATE}, NSIDE={NSIDE}", fontsize=13)
ax.set_ylim(0, max(rmses) * 1.25)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
fig.tight_layout()
chart_path = os.path.join("results", "comparison_rmse.png")
fig.savefig(chart_path, dpi=150)
plt.show()
print(f"Chart saved to {chart_path}")
Chart saved to results/comparison_rmse.png
Credits¶
Jean-Marc Delouis -- FOSCAT scattering-transform synthesis, healpix-geo, and healpix-resample packages. Reference: Delouis et al. (2022), Astronomy & Astrophysics, DOI:10.1051/0004-6361/202244566
Anne Fouilloux -- LifeWatch ERIC, experiment design and SST application.
FIESTA-OSCARS -- Funding and project framework.
Note: Running this notebook requires valid Copernicus Marine Service
credentials. Set COPERNICUSMARINE_SERVICE_USERNAME and
COPERNICUSMARINE_SERVICE_PASSWORD as environment variables, or
configure via copernicusmarine login.
- Delouis, J.-M., Allys, E., Gauvrit, E., & Boulanger, F. (2022). Non-Gaussian modelling and statistical denoising of Planck dust polarisation full-sky maps using scattering transforms. Astronomy & Astrophysics, 668, A122. 10.1051/0004-6361/202244566