Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

SST Gap-Filling: Does the WGS84 Ellipsoid Improve Results?

Authors
Affiliations
LifeWatch ERIC
CNRS / APC

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:

  1. Download L3S (gappy) and L4 (reference) SST from Copernicus Marine.

  2. Resample both to HEALPix using (a) sphere and (b) WGS84 ellipsoid.

  3. Run FOSCAT synthesis to fill gaps in L3S.

  4. 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_map

FOSCAT Synthesis

We define a function that:

  1. Resamples L4 and L3S to HEALPix with the chosen ellipsoid.

  2. Identifies ocean, observed, and cloud-gap cells.

  3. Computes a spherical-harmonics baseline for the gaps.

  4. Runs FOSCAT scattering-transform synthesis to fill the gaps.

  5. 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()
<Figure size 850x540 with 2 Axes>
<Figure size 850x540 with 2 Axes>
<Figure size 850x540 with 2 Axes>
<Figure size 850x540 with 2 Axes>

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}")
<Figure size 600x400 with 1 Axes>
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.

References
  1. 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