Skip to content

Comparing two datasets without regridding

This is the example the hub exists for.

Comparing a model run against a reanalysis normally starts with a day of grid work: find the two grids, pick a common target, choose a remapping scheme, generate weights, argue about conservative versus bilinear, and discover afterwards that the difference field has a suspicious pattern at the poles. The comparison itself is the easy part and it is never where the time goes.

Both stores below are HEALPix, nested, on the same sphere. Cell i at level 5 is the same patch of the planet in both files, by construction, because the grid is defined by the index rather than agreed by convention. So the grid work is not simplified here. It is absent.

The two stores

ECMWF's ERA5 reanalysis and the nextGEMS ICON cycle-4 run, from different institutions through different pipelines, both published at HEALPix level 5. One is monthly, the other daily.

import cartopy.crs as ccrs
import matplotlib.pyplot as plt
import numpy as np
import xarray as xr
from healpix_geo import nested

S3 = {"anon": True, "endpoint_url": "https://s3.waterpark.dkrz.de"}
ERA5 = "s3://reanalysis/healpix/era5/P1M/level_5.zarr"
MODEL = "s3://nextgems/healpix/ngc4008/P1D/level_5.zarr"

era5 = xr.open_zarr(ERA5, storage_options=S3, chunks=None)
model = xr.open_zarr(MODEL, storage_options=S3, chunks=None)

for name, store in (("ERA5", era5), ("ngc4008", model)):
    print(
        f"{name:8s} level {store.attrs.get('healpix_level', '7')}, "
        f"{store.sizes['cell']:6d} cells, "
        f"{store.sizes['time']:6d} time steps"
    )

assert era5.sizes["cell"] == model.sizes["cell"]

Three things have to line up

Variable, level and time. Only the middle one is a grid problem, and it is the one that turns out to be free.

Variable. Both of these happen to use CMOR names, so tas is tas in each. Do not count on it: the hub carries stores using ECMWF short names and stores using native model names, and no amount of clever indexing fixes a naming difference. That is a lookup table you write once.

Level. Equal, so there is nothing to do. Had the model been a level finer, model.coarsen(cell=4).mean() would have been the entire adjustment, as in the previous example.

Time. A monthly mean series and a daily mean series, both reduced to one annual mean. Neither reduction is a plain .mean("time"): months have different lengths, so the monthly series needs day weights, and the daily series needs a check that the year is actually complete.

YEAR = "2021"

era5_months = era5["tas"].sel(time=YEAR)
model_days = model["tas"].sel(time=YEAR)

days_in_month = era5_months["time"].dt.days_in_month
era5_mean = ((era5_months * days_in_month).sum("time") / days_in_month.sum()).load()
model_mean = model_days.mean("time").load()

print(f"{era5_months.sizes['time']} ERA5 months, {model_days.sizes['time']} model days")
print(
    f"month lengths {int(days_in_month.min())}-{int(days_in_month.max())}, "
    f"summing to {int(days_in_month.sum())}"
)

The comparison

And that is it. Two arrays of 12288 numbers each, describing the same 12288 patches of the Earth, so subtraction is defined. No weights file, no interpolation, no target grid to agree on, and nothing that needed to be told where the poles are.

bias = model_mean.values - era5_mean.values

print(f"mean bias  {bias.mean():+.2f} K")
print(f"rms  bias  {np.sqrt((bias**2).mean()):.2f} K")
print(f"worst cell {np.abs(bias).max():.2f} K")

The global mean bias is an unweighted mean over cells, and correct for the reason given in the equal area example.

The plot

level = era5.attrs["healpix_level"]
latitude = era5["latitude"].values
_, rings = np.unique(np.round(latitude, 8), return_inverse=True)
ring_lat = np.array([latitude[rings == r][0] for r in range(rings.max() + 1)])
zonal_bias = np.array([bias[rings == r].mean() for r in range(rings.max() + 1)])


def rasterize(values, nlon=1080, nlat=540):
    lon = np.linspace(-180, 180, nlon)
    lat = np.linspace(90, -90, nlat)
    mesh_lon, mesh_lat = np.meshgrid(lon, lat)
    cells = nested.lonlat_to_healpix(mesh_lon.ravel(), mesh_lat.ravel(), level)
    return np.asarray(values)[cells.astype("int64")].reshape(mesh_lat.shape), lon, lat


vmin = float(min(era5_mean.values.min(), model_mean.values.min()))
vmax = float(max(era5_mean.values.max(), model_mean.values.max()))
span = float(np.percentile(np.abs(bias), 99))

fig = plt.figure(figsize=(12, 6.6), layout="constrained")

for index, (title, values) in enumerate(
    [
        (f"ERA5 {YEAR}", era5_mean.values),
        (f"nextGEMS ngc4008 {YEAR}", model_mean.values),
    ],
    start=1,
):
    raster, lon, lat = rasterize(values)
    ax = fig.add_subplot(2, 2, index, projection=ccrs.Robinson())
    ax.coastlines(linewidth=0.3, color="0.3")
    ax.set_global()
    mesh = ax.pcolormesh(
        lon,
        lat,
        raster,
        cmap="RdYlBu_r",
        vmin=vmin,
        vmax=vmax,
        shading="auto",
        transform=ccrs.PlateCarree(),
    )
    ax.set_title(title, fontsize=10)
    fig.colorbar(mesh, ax=ax, shrink=0.72, pad=0.02, label="K")

raster, lon, lat = rasterize(bias)
ax = fig.add_subplot(2, 2, 3, projection=ccrs.Robinson())
ax.coastlines(linewidth=0.3, color="0.3")
ax.set_global()
mesh = ax.pcolormesh(
    lon,
    lat,
    raster,
    cmap="RdBu_r",
    vmin=-span,
    vmax=span,
    shading="auto",
    transform=ccrs.PlateCarree(),
)
ax.set_title("model minus reanalysis", fontsize=10)
fig.colorbar(mesh, ax=ax, shrink=0.72, pad=0.02, label="K")

ax_zonal = fig.add_subplot(2, 2, 4)
ax_zonal.plot(zonal_bias, ring_lat, color="#c62828", lw=1.6)
ax_zonal.axvline(0.0, color="0.4", lw=0.8)
ax_zonal.set_yticks(np.arange(-90, 91, 30))
ax_zonal.set_ylim(-90, 90)
ax_zonal.set_xlabel("bias [K]")
ax_zonal.set_ylabel("latitude [deg]")
ax_zonal.set_title("zonal mean bias", fontsize=10)
ax_zonal.grid(alpha=0.3)

fig.suptitle(
    f"{YEAR} annual mean tas, ngc4008 against ERA5, level {level}, "
    "no regridding anywhere",
    fontsize=12,
)

fig.savefig("08_cross_dataset.png", dpi=110, bbox_inches="tight", facecolor="white")

Comparing two datasets without regridding

Comparing two datasets without regridding

What is left to be careful about

The grid stopped being a problem, which means the remaining problems are the interesting ones, and they are all scientific:

  • The two tas fields are not the same thing. ERA5's is a diagnostic from a reanalysis with data assimilation; the model's is a diagnostic from a free-running simulation. A free run has no reason to reproduce a particular year's weather, so day-to-day agreement is not expected and only the climatology is worth reading.
  • One year is a short climatology. The bias field contains a real model bias plus one realisation of internal variability, and the figure cannot tell you which is which. Widen YEAR before drawing any conclusion from it.
  • Sea ice and orography dominate the extremes. The largest values in the difference map are where the two datasets disagree about what is at the surface, not where they disagree about the atmosphere. That is worth masking before quoting a number.

Those are questions about climate, not about file formats, which is the point.


Run it yourself: examples/08_cross_dataset.py