Skip to content

A map of one month

The first thing anybody wants from a dataset is to look at it. On a HEALPix store that takes one more step than on a lon/lat grid, because the data has no lon/lat axes to hand pcolormesh and no two-dimensional shape at all. A field is a flat vector over cells, and the geometry lives in the cell index rather than in the array shape.

This example turns that vector into a picture, and shows the one mistake everybody makes on the way.

Opening the store

Nothing here is HEALPix-specific. It is a public Zarr store on S3, so an anonymous client and an endpoint URL are the whole of the access story.

chunks=None gives plain lazy Zarr-backed arrays that slice in the size of the selection, rather than dask arrays whose chunk grid is built eagerly from the global shape. At these levels either would work. At the regional levels it is the only thing that works, so it is the habit worth having.

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

URL = "s3://reanalysis/healpix/era5/P1M/level_7.zarr"
S3 = {"anon": True, "endpoint_url": "https://s3.waterpark.dkrz.de"}

ds = xr.open_zarr(URL, storage_options=S3, chunks=None)
print(ds)

The shape is the surprise

The field has a time axis and a cell axis, and that is all. There is no lat, no lon, and no second horizontal dimension to unroll into.

The level and the cell count determine each other exactly, through npix = 12 * 4**level, so the store only has to record one of them. It records both, in the CF grid mapping attributes, along with the ordering scheme.

level = ds.attrs["healpix_level"]
assert ds.attrs["healpix_order"] == "nested", "everything below assumes nested"

field = ds["tas"].sel(time="2021-07").squeeze()

print(f"level {level}, nside {2**level}, {field.sizes['cell']} cells")
print(f"one monthly map is {field.nbytes / 1e6:.1f} MB")

Cells to pixels

To draw it, sample the map onto a raster: walk over the lon/lat points you want to colour, ask HEALPix which cell each one falls into, and read that cell's value. The lookup is arithmetic rather than a search, so a raster of a million points costs nothing.

The nested/ring trap

HEALPix has two ways of numbering cells, ring and nested, and they are different permutations of the same map. Waterpark stores are nested throughout, and healpix_geo.nested is the matching namespace. Read a nested store with a ring indexer and nothing raises: you get a plausible looking map with the data shuffled into the wrong places. If a field looks like noise where it should look like a planet, this is why, and the assertion above is cheap insurance.

lon = np.linspace(-180, 180, 1440)
lat = np.linspace(90, -90, 720)
mesh_lon, mesh_lat = np.meshgrid(lon, lat)

cells = nested.lonlat_to_healpix(mesh_lon.ravel(), mesh_lat.ravel(), level)
raster = field.values[cells.astype("int64")].reshape(mesh_lat.shape)

That is the whole of it. Five lines, no interpolation weights, and no remapping step.

The plot

fig, ax = plt.subplots(
    figsize=(10, 5.2), subplot_kw={"projection": ccrs.Robinson()}
)
ax.coastlines(linewidth=0.4, color="0.25")
ax.set_global()

mesh = ax.pcolormesh(
    lon, lat, raster, cmap="RdYlBu_r", shading="auto",
    transform=ccrs.PlateCarree(),
)
fig.colorbar(
    mesh, ax=ax, orientation="horizontal", pad=0.04, shrink=0.7,
    label="2 m temperature [K]",
)
ax.set_title("ERA5 monthly mean tas, July 2021, HEALPix level 7")

fig.savefig("01_first_map.png", dpi=110, bbox_inches="tight", facecolor="white")

A map of one month

A map of one month

Nearest neighbour, on purpose

The raster is finer than the level 7 grid, so each cell is drawn with its true boundary rather than smoothed across its neighbours. At this level you have to look closely to see it; at level 4 the diamonds are obvious, and they change orientation between the equatorial belt and the polar caps.

Smoothing them away is a choice you should have to make on purpose, and healpix_geo.nested.bilinear_interpolation gives you the weights when you want it. The honest first look is the one above: it shows the resolution you actually have rather than the resolution your screen has.

Picking a level

This store is a pyramid. The same field is written at every level, and changing one number in the URL changes how much you download:

level cells one monthly field
4 3 072 25 KB
5 12 288 98 KB
7 196 608 1.6 MB
8 786 432 6.3 MB

Level 7 is used here because the figure is a map and detail is the point. The examples that read hundreds of time steps use level 4 instead, and say so. Matching the level to the question is the single biggest lever you have over how long an analysis takes.


Run it yourself: examples/01_first_map.py