UXarray for Advanced HEALPix Analysis & Visualization¶
In this section, you’ll learn:¶
- Using the
uxarray
package to perform advanced analysis operators over HEALPix data such as non-conservative zonal means, etc.
Related Documentation¶
- UXarray homepage
- Working with HEALPix data - UXarray documentation
- UXarray overview - Unstructured Grids Visualization Cookbook
- Data visualization with UXarray - Unstructured Grids Visualization Cookbook
- Cross-sections - UXarray documentation
- Intake Cookbook
Prerequisites¶
Concepts | Importance | Notes |
---|---|---|
UXarray | Necessary | |
HEALPix overview | Necessary |
Time to learn: 30 minutes
import cartopy.crs as ccrs
import intake
import uxarray as ux
Open data catalog¶
Let us open the online catalog from the WCRP’s Digital Earths Global Hackathon 2025 catalog repository using intake
and read the output of the ICON
run d3hp003
, which is stored in the HEALPix format:
cat_url = "https://digital-earths-global-hackathon.github.io/catalog/catalog.yaml"
cat = intake.open_catalog(cat_url)
model_run = cat.online.icon_d3hp003
We can look into the highest possible resolution level allowed in this dataset at zoom level = 9 as Xarray.Dataset
:
ds = model_run(zoom=9, time="P1D").to_dask()
/home/runner/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/intake_xarray/base.py:21: FutureWarning: The return type of `Dataset.dims` will be changed to return a set of dimension names in future, in order to be more consistent with `DataArray.dims`. To access a mapping from dimension names to lengths, please use `Dataset.sizes`.
'dims': dict(self._ds.dims),
Create UXarray Datasets from HEALPix¶
We can use UXarray’s from_healpix
API as follows to open a HEALPix grid from xarray.Dataset
:
uxds = ux.UxDataset.from_healpix(ds)
uxds
Data variable of interest¶
Then let us pick a variable, the surface temperature, from the dataset, which will give us an uxarray.UxDataArray
:
uxda = uxds["ts"]
uxda
Global mean and plot¶
Computing the global surface temperature mean (at the first timestep) and also having a quick plot of it would be a good idea to have as references to compare the upcoming analyses & visualizations to them:
print(
"Global surface temperature average on ", uxda.time[0].values, ": ", uxda.isel(time=0).mean().values, " K"
)
Global surface temperature average on 2020-01-02T00:00:00.000000000 : 286.99234 K
%%time
projection = ccrs.Robinson()
uxda.isel(time=0).plot(
projection=projection,
cmap="inferno",
features=["borders", "coastline"],
title="Global surface temperature (Polygon raster)",
width=700,
)
Rasterized point plots¶
When working with a higher-resolution dataset at a global scale, it’s not always practical to render each cell as a polygon. Instead, we can rasterize the center of each pixel.
%%time
projection = ccrs.Robinson()
# Controls the size of each pixel (smaller value leads to larger pixels)
pixel_ratio = 0.5
uxda.isel(time=0).plot.points(
projection=projection,
rasterize=True,
dynamic=False,
width=1000,
height=500,
pixel_ratio=pixel_ratio,
cmap="inferno",
title=f"Global surface temperature (Point raster), pixel_ratio={pixel_ratio}",
)
If we decrease the size of each pixel (by setting the pixel ratio to a higher value), we can start to see missing values, which is due to a lower density of points near the poles, leading to some pixels not containing any of our original points.
Because of this, it’s useful to try a few pixel_ratio
values and see which one works best for your given resolution.
projection = ccrs.Robinson()
# Controls the size of each pixel (smaller value leads to larger pixels)
pixel_ratio = 2.0
uxda.isel(time=0).plot.points(
projection=projection,
rasterize=True,
dynamic=False,
width=1000,
height=500,
pixel_ratio=pixel_ratio,
cmap="inferno",
title=f"Global surface temperature (Point raster) with a bad pixel size selection, pixel_ratio={pixel_ratio}",
)
Cross-sections¶
We can look at constant latitude/longitude cross-sections of an uxarray.UxDataArray
:
boulder_lat = 40.0190
# With fine resolutions like zoom level of 9, it is visually hard to observe the cross-sections,
# so we will use a zoom level of 4 for a better visualization
uxda_coarse = ux.UxDataset.from_healpix(model_run(zoom=4, time="P1D").to_dask())["ts"]
uxda_coarse.uxgrid.face_node_connectivity
uxda_lat = uxda_coarse.cross_section.constant_latitude(boulder_lat)
uxda_lat
import geoviews.feature as gf
uxda_lat.isel(time=0).plot(
rasterize=False,
projection=projection,
global_extent=True,
cmap="inferno",
clim=(220, 310),
features=["coastline"],
title=f"Global surface temperature cross-section at {boulder_lat} degrees latitude",
width=700,
) * gf.grid(projection=projection)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[11], line 3
1 import geoviews.feature as gf
----> 3 uxda_lat.isel(time=0).plot(
4 rasterize=False,
5 projection=projection,
6 global_extent=True,
7 cmap="inferno",
8 clim=(220, 310),
9 features=["coastline"],
10 title=f"Global surface temperature cross-section at {boulder_lat} degrees latitude",
11 width=700,
12 ) * gf.grid(projection=projection)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/uxarray/plot/accessor.py:329, in UxDataArrayPlotAccessor.__call__(self, **kwargs)
326 def __call__(self, **kwargs) -> Any:
327 if self._uxda._face_centered():
328 # polygons for face-centered data
--> 329 return self.polygons(**kwargs)
330 else:
331 # points for node and edge centered data
332 return self.points(**kwargs)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/uxarray/plot/accessor.py:423, in UxDataArrayPlotAccessor.polygons(self, periodic_elements, backend, engine, rasterize, dynamic, projection, xlabel, ylabel, *args, **kwargs)
414 kwargs["clabel"] = self._uxda.name
416 gdf = self._uxda.to_geodataframe(
417 periodic_elements=periodic_elements,
418 projection=kwargs.get("projection"),
419 engine=engine,
420 project=False,
421 )
--> 423 return gdf.hvplot.polygons(
424 c=self._uxda.name if self._uxda.name is not None else "var",
425 rasterize=rasterize,
426 dynamic=dynamic,
427 xlabel=xlabel,
428 ylabel=ylabel,
429 *args,
430 **kwargs,
431 )
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/hvplot/plotting/core.py:1388, in hvPlotTabular.polygons(self, x, y, c, **kwds)
1358 def polygons(self, x=None, y=None, c=None, **kwds):
1359 """
1360 Polygon plot for geopandas dataframes.
1361
(...) 1386 to learn more about its parameters and options.
1387 """
-> 1388 return self(x, y, c=c, kind='polygons', **kwds)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/hvplot/plotting/core.py:95, in hvPlotBase.__call__(self, x, y, kind, **kwds)
92 plot = self._get_converter(x, y, kind, **kwds)(kind, x, y)
93 return pn.panel(plot, **panel_dict)
---> 95 return self._get_converter(x, y, kind, **kwds)(kind, x, y)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/hvplot/converter.py:2064, in HoloViewsConverter.__call__(self, kind, x, y)
2061 obj = gv.project(obj, projection=self.output_projection)
2063 if not (self.datashade or self.rasterize or self.downsample):
-> 2064 layers = self._apply_layers(obj)
2065 layers = _transfer_opts_cur_backend(layers)
2066 return layers
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/hvplot/converter.py:2211, in HoloViewsConverter._apply_layers(self, obj)
2205 if feature_obj is None:
2206 raise ValueError(
2207 f'Feature {feature!r} was not recognized, must be one of '
2208 "'borders', 'coastline', 'lakes', 'land', 'ocean', "
2209 "'rivers' and 'states'."
2210 )
-> 2211 feature_obj = feature_obj.clone()
2212 if isinstance(self.features, dict):
2213 scale = self.features[feature]
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/geoviews/element/geo.py:158, in _Element.clone(self, data, shared_data, new_type, *args, **overrides)
156 if 'crs' not in overrides and (not new_type or isinstance(new_type, _Element)):
157 overrides['crs'] = self.crs
--> 158 return super().clone(data, shared_data, new_type,
159 *args, **overrides)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/holoviews/core/dimension.py:607, in LabelledData.clone(self, data, shared_data, new_type, link, *args, **overrides)
605 # Apply name mangling for __ attribute
606 pos_args = getattr(self, '_' + type(self).__name__ + '__pos_params', [])
--> 607 return clone_type(data, *args, **{k:v for k,v in settings.items()
608 if k not in pos_args})
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/geoviews/element/geo.py:187, in Feature.__init__(self, data, kdims, vdims, **params)
185 if not isinstance(data, cFeature):
186 raise TypeError(f'{type(data).__name__} data has to be an cartopy Feature type')
--> 187 super().__init__(data, kdims=kdims, vdims=vdims, **params)
File ~/micromamba/envs/healpix-cookbook-dev/lib/python3.13/site-packages/geoviews/element/geo.py:145, in _Element.__init__(self, data, kdims, vdims, **kwargs)
143 supplied_crs = kwargs.get('crs', None)
144 if supplied_crs and crs and crs != supplied_crs:
--> 145 raise ValueError('Supplied coordinate reference '
146 'system must match crs of the data.')
147 elif crs:
148 kwargs['crs'] = crs
ValueError: Supplied coordinate reference system must match crs of the data.
Let’s also look at the mean of the cross-section:
print(
f"Mean at {boulder_lat} degrees lat (Boulder, CO, USA): {uxda_lat.mean().values} K"
)
Latitude interval¶
uxda_lat_interval = uxda_coarse.cross_section.constant_latitude_interval(
[boulder_lat - 15, boulder_lat + 15]
)
uxda_lat_interval.isel(time=0).plot(
rasterize=False,
projection=projection,
global_extent=True,
cmap="inferno",
clim=(220, 310),
features=["coastline"],
title=f"Global surface temperature cross-section at the latitude interval [{boulder_lat-5},{boulder_lat+5}] degrees",
width=700,
) * gf.grid(projection=projection)
print(
f"Mean at the latitude interval of [{boulder_lat-5},{boulder_lat+5}] degrees (-/+15 degrees Boulder, CO, USA): {uxda_lat_interval.mean().values} K"
)
Non-conservative zonal mean¶
Calculating the zonal mean is easy by providing the latitude range between -90 and 90 degrees with a step size in degrees:
%%time
zonal_mean_ts = uxda.isel(time=0).zonal_mean(lat=(-90, 90, 5))
(
uxda.isel(time=0).plot(
cmap="inferno",
# periodic_elements="split",
height=300,
width=600,
colorbar=False,
ylim=(-90, 90),
)
+ zonal_mean_ts.plot.line(
x="ts_zonal_mean",
y="latitudes",
height=300,
width=180,
ylabel="",
ylim=(-90, 90),
xlim=(220, 310),
# xticks=[220, 250, 280, 310],
yticks=[-90, -45, 0, 45, 90],
grid=True,
)
).opts(title="Temperature and its Zonal means at every 5 degree latitude")
Remapping¶
Now, we will be looking into one of many possible use cases where remapping would be helpful.
The data set we have been using in this section so far belongs to the newer ICON simulation, icon_d3hp003
, while there is an older simulation as well, icon_ngc4008
, in the same catalog. They are both stored in the HEALPix format in this case, but for most of the model intercomparison workflows in general, they might not be even so. UXarray would still be helpful to remap of those model outputs to other and then make comparisons since it can support several most commonly used unstructrued grid formats.
In this particular case, we still have some use for UXarray’s remapping such that the newer simulation has the zoom = 9 as the maximum available resolution, while the older one has zoom = 10 available. Unfortunately at zoom = 10 though, there is no actual data simulated for ts
, the surface temperature. If there was, we could remap the newer simulation’s output to that one, so we could have both of them at zoom = 10, and then we could look into the difference between them for instance. Let’s pretend the highest zoom-level in the newer data is zoom = 8 then, and remap that one into the older simulation’s grid.
Let’s start with opening the older simulation run first:
model_run_older = cat.online.icon_ngc4008
ds_older = model_run_older(zoom=8, time="P1D").to_dask()
uxds_older = ux.UxDataset.from_healpix(ds_older)
uxds_older
Plot the older simulation for reference¶
Let’s have a quick look at how the global surface temperature looks like in the older simulation’s output:
uxds_older["ts"].isel(time=0).plot(
projection=projection,
cmap="inferno",
features=["borders", "coastline"],
title="Global surface temperature (zoom = 8) - Older Simulation",
width=700,
)
Visually there does not seem to be a huge difference between this and the newer simulation’s output we had plotted in the very beginning.
Remap the old simulation output to the newer one¶
Let’s start remapping! For that, we will use the uxgrid
of the newer simulation output as the destination grid and use an inverse distance weighted implementation:
%%time
uxda_older_remapped = uxds_older["ts"].isel(time=0).remap.inverse_distance_weighted(
uxds.uxgrid, k=3, remap_to="face centers", coord_type="cartesian"
)
Plot the difference between the old and newer simulations¶
Now that we have the older and newer model outputs on the same grid, let’s look at the surface temperature differences between the two:
(uxda.isel(time=0) - uxda_older_remapped).plot(
projection=projection,
cmap="RdBu_r",
features=["borders", "coastline"],
title="Global surface temperature difference between older and newer simulations (zoom = 9)",
clim=(-25,25),
width=700,
)