Skip to content

Commit 2850ccd

Browse files
dcherianclaude
andauthored
Add HEALPix grid mapping support to GridMapping (#614)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 538cc9c commit 2850ccd

3 files changed

Lines changed: 154 additions & 2 deletions

File tree

cf_xarray/accessor.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,46 @@ def _create_grid_mapping(
589589
cf_name = var.attrs.get("grid_mapping_name", var_name)
590590

591591
# Create CRS from the grid mapping variable
592-
crs = pyproj.CRS.from_cf(var.attrs)
592+
if cf_name == "healpix":
593+
# pyproj does not recognize "healpix" as a grid mapping name,
594+
# but the grid uses geographic (lat/lon) coordinates on a sphere.
595+
# Build a geographic CRS from the earth_radius parameter.
596+
earth_radius = var.attrs.get("earth_radius", 6371229.0)
597+
crs = pyproj.CRS.from_json_dict(
598+
{
599+
"$schema": "https://proj.org/schemas/v0.6/projjson.schema.json",
600+
"type": "GeographicCRS",
601+
"name": "HEALPix Grid",
602+
"datum": {
603+
"type": "GeodeticReferenceFrame",
604+
"name": "Unknown",
605+
"ellipsoid": {
606+
"name": "Custom",
607+
"semi_major_axis": earth_radius,
608+
"semi_minor_axis": earth_radius,
609+
},
610+
},
611+
"coordinate_system": {
612+
"subtype": "ellipsoidal",
613+
"axis": [
614+
{
615+
"name": "Latitude",
616+
"abbreviation": "lat",
617+
"direction": "north",
618+
"unit": "degree",
619+
},
620+
{
621+
"name": "Longitude",
622+
"abbreviation": "lon",
623+
"direction": "east",
624+
"unit": "degree",
625+
},
626+
],
627+
},
628+
}
629+
)
630+
else:
631+
crs = pyproj.CRS.from_cf(var.attrs)
593632

594633
# Get associated coordinate variables, fallback to dimension names
595634
coordinates: list[Hashable] = grid_mapping_dict.get(var_name, [])
@@ -600,7 +639,11 @@ def _create_grid_mapping(
600639
# The appropriate values of the standard_name depend on the grid mapping and are given in Appendix F, Grid Mappings.
601640
# """
602641
if not coordinates and len(grid_mapping_dict) == 1:
603-
if crs.to_cf().get("grid_mapping_name") == "rotated_latitude_longitude":
642+
if cf_name == "healpix":
643+
# For HEALPix grids, coordinates are typically latitude/longitude
644+
# (if present), but pixel indices are the primary indexing mechanism.
645+
xname, yname = "longitude", "latitude"
646+
elif crs.to_cf().get("grid_mapping_name") == "rotated_latitude_longitude":
604647
xname, yname = "grid_longitude", "grid_latitude"
605648
elif crs.is_geographic:
606649
xname, yname = "longitude", "latitude"

cf_xarray/datasets.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,52 @@ def _create_inexact_bounds():
784784
np.zeros((10, 20)),
785785
{"standard_name": "projected_y_coordinate"},
786786
)
787+
# HEALPix dataset: refinement_level=1, nside=2, 12*nside^2 = 48 pixels, nested ordering
788+
healpix_ds = xr.Dataset(
789+
{
790+
"tas": xr.DataArray(
791+
np.random.default_rng(42).standard_normal((2, 48)).astype(np.float32),
792+
dims=["time", "healpix_index"],
793+
attrs={
794+
"standard_name": "air_temperature",
795+
"units": "K",
796+
"coordinates": "height",
797+
"grid_mapping": "healpix",
798+
"cell_methods": "time: mean area: mean",
799+
},
800+
),
801+
"healpix": xr.DataArray(
802+
np.int32(0),
803+
attrs={
804+
"grid_mapping_name": "healpix",
805+
"earth_radius": 6371000,
806+
"indexing_scheme": "nested",
807+
"refinement_level": 1,
808+
},
809+
),
810+
},
811+
coords={
812+
"time": xr.DataArray(
813+
[0.0, 1.0],
814+
dims=["time"],
815+
attrs={
816+
"standard_name": "time",
817+
"calendar": "proleptic_gregorian",
818+
"units": "days since 2025-06-01",
819+
},
820+
),
821+
"healpix_index": xr.DataArray(
822+
np.arange(48),
823+
dims=["healpix_index"],
824+
attrs={"standard_name": "healpix_index"},
825+
),
826+
"height": xr.DataArray(
827+
np.float32(2.0),
828+
attrs={"standard_name": "height", "units": "m"},
829+
),
830+
},
831+
)
832+
787833
except ImportError:
788834
pass
789835

cf_xarray/tests/test_accessor.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,69 @@ def test_bad_grid_mapping_attribute():
12851285
assert grid_mappings == () # No valid grid mappings since 'foo' doesn't exist
12861286

12871287

1288+
@requires_pyproj
1289+
def test_healpix_grid_mapping():
1290+
"""Test GridMapping integration for HEALPix grids."""
1291+
from ..datasets import healpix_ds as ds
1292+
1293+
# Grid mapping discovery
1294+
assert ds.cf.grid_mapping_names == {"healpix": ["healpix"]}
1295+
1296+
# Grid mapping propagation to data variables
1297+
da = ds.cf["air_temperature"]
1298+
assert "healpix" in da.coords
1299+
1300+
# grid_mapping_name property
1301+
assert da.cf.grid_mapping_name == "healpix"
1302+
1303+
# .cf.grid_mappings property on Dataset
1304+
gms = ds.cf.grid_mappings
1305+
assert len(gms) == 1
1306+
gm = gms[0]
1307+
assert gm.name == "healpix"
1308+
assert gm.crs is not None
1309+
assert gm.crs.is_geographic
1310+
assert gm.array.name == "healpix"
1311+
assert gm.array.shape == () # scalar variable
1312+
assert isinstance(gm.coordinates, tuple)
1313+
1314+
# DataArray grid_mappings should also work
1315+
da_gms = da.cf.grid_mappings
1316+
assert len(da_gms) == 1
1317+
assert da_gms[0].name == "healpix"
1318+
assert da_gms[0].crs.is_geographic
1319+
1320+
# Repr should include healpix
1321+
assert "healpix" in ds.cf.__repr__()
1322+
1323+
1324+
@requires_pyproj
1325+
def test_healpix_crs_properties():
1326+
"""Test that the CRS built for HEALPix has correct properties."""
1327+
from ..datasets import healpix_ds as ds
1328+
1329+
gm = ds.cf.grid_mappings[0]
1330+
crs = gm.crs
1331+
1332+
# Must be geographic (lat/lon on a sphere)
1333+
assert crs.is_geographic
1334+
assert not crs.is_projected
1335+
1336+
# Earth shape parameters from the grid mapping variable
1337+
assert crs.ellipsoid.semi_major_metre == 6371000
1338+
assert crs.ellipsoid.semi_minor_metre == 6371000
1339+
1340+
# Should not have an EPSG code (custom sphere)
1341+
assert crs.to_epsg() is None
1342+
1343+
# Verify HEALPix-specific attributes are preserved in the grid mapping array
1344+
gm_attrs = gm.array.attrs
1345+
assert gm_attrs["grid_mapping_name"] == "healpix"
1346+
assert gm_attrs["refinement_level"] == 1
1347+
assert gm_attrs["indexing_scheme"] == "nested"
1348+
assert gm_attrs["earth_radius"] == 6371000
1349+
1350+
12881351
def test_docstring() -> None:
12891352
assert "One of ('X'" in airds.cf.groupby.__doc__
12901353
assert "Time variable accessor e.g. 'T.month'" in airds.cf.groupby.__doc__

0 commit comments

Comments
 (0)