Skip to content

Commit dd89dd0

Browse files
dcherianclaude
andauthored
Add reduced gaussian grid mapping support (#613)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2850ccd commit dd89dd0

3 files changed

Lines changed: 348 additions & 14 deletions

File tree

cf_xarray/accessor.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -589,7 +589,44 @@ 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-
if cf_name == "healpix":
592+
if cf_name == "reduced_gaussian":
593+
# pyproj does not recognize "reduced_gaussian" as a grid mapping name,
594+
# but the grid uses geographic (lat/lon) coordinates on a sphere or
595+
# spheroid. Build a geographic CRS from the earth shape parameters.
596+
crs = pyproj.CRS.from_json_dict(
597+
{
598+
"$schema": "https://proj.org/schemas/v0.6/projjson.schema.json",
599+
"type": "GeographicCRS",
600+
"name": "Reduced Gaussian Grid",
601+
"datum": {
602+
"type": "GeodeticReferenceFrame",
603+
"name": "Unknown",
604+
"ellipsoid": {
605+
"name": "Custom",
606+
"semi_major_axis": var.attrs.get("semi_major_axis", 6371229.0),
607+
"semi_minor_axis": var.attrs.get("semi_minor_axis", 6371229.0),
608+
},
609+
},
610+
"coordinate_system": {
611+
"subtype": "ellipsoidal",
612+
"axis": [
613+
{
614+
"name": "Latitude",
615+
"abbreviation": "lat",
616+
"direction": "north",
617+
"unit": "degree",
618+
},
619+
{
620+
"name": "Longitude",
621+
"abbreviation": "lon",
622+
"direction": "east",
623+
"unit": "degree",
624+
},
625+
],
626+
},
627+
}
628+
)
629+
elif cf_name == "healpix":
593630
# pyproj does not recognize "healpix" as a grid mapping name,
594631
# but the grid uses geographic (lat/lon) coordinates on a sphere.
595632
# Build a geographic CRS from the earth_radius parameter.
@@ -640,19 +677,46 @@ def _create_grid_mapping(
640677
# """
641678
if not coordinates and len(grid_mapping_dict) == 1:
642679
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":
647-
xname, yname = "grid_longitude", "grid_latitude"
648-
elif crs.is_geographic:
649-
xname, yname = "longitude", "latitude"
650-
elif crs.is_projected:
651-
xname, yname = "projection_x_coordinate", "projection_y_coordinate"
652-
653-
x = apply_mapper(_get_with_standard_name, ds, xname, error=False, default=[[]])
654-
y = apply_mapper(_get_with_standard_name, ds, yname, error=False, default=[[]])
655-
coordinates = list(itertools.chain(x, y))
680+
# For HEALPix grids, the primary coordinate is the pixel index.
681+
coords_found = apply_mapper(
682+
_get_with_standard_name, ds, "healpix_index", error=False, default=[[]]
683+
)
684+
coordinates = list(itertools.chain(coords_found))
685+
elif cf_name == "reduced_gaussian":
686+
# For reduced gaussian grids, the primary coordinate is the grid
687+
# point index. For compressed subsets, also look for the gather
688+
# variable (with compress attribute).
689+
idx_coords = apply_mapper(
690+
_get_with_standard_name,
691+
ds,
692+
"reduced_gaussian_index",
693+
error=False,
694+
default=[[]],
695+
)
696+
coordinates = list(itertools.chain(idx_coords))
697+
# Also find any compress/gather variable that references
698+
# the detected index coordinate(s)
699+
for vname in ds.coords:
700+
if "compress" in ds[vname].attrs:
701+
compress_target = ds[vname].attrs["compress"]
702+
if any(c in compress_target for c in coordinates):
703+
if vname not in coordinates:
704+
coordinates.append(vname)
705+
else:
706+
if crs.to_cf().get("grid_mapping_name") == "rotated_latitude_longitude":
707+
xname, yname = "grid_longitude", "grid_latitude"
708+
elif crs.is_geographic:
709+
xname, yname = "longitude", "latitude"
710+
elif crs.is_projected:
711+
xname, yname = "projection_x_coordinate", "projection_y_coordinate"
712+
713+
x = apply_mapper(
714+
_get_with_standard_name, ds, xname, error=False, default=[[]]
715+
)
716+
y = apply_mapper(
717+
_get_with_standard_name, ds, yname, error=False, default=[[]]
718+
)
719+
coordinates = list(itertools.chain(x, y))
656720

657721
return GridMapping(name=cf_name, crs=crs, array=da, coordinates=tuple(coordinates))
658722

cf_xarray/datasets.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,3 +861,145 @@ def encoded_point_dataset():
861861
{"geometry": "geometry_container"},
862862
)
863863
return ds
864+
865+
866+
# --- Reduced Gaussian Grid test fixtures ---
867+
# A tiny O2 octahedral reduced gaussian grid with 4 latitudes and 40 total points.
868+
869+
870+
def _create_reduced_gaussian_global():
871+
"""Create a small O2 reduced gaussian grid dataset (full global)."""
872+
lat = np.array([59.44, 19.47, -19.47, -59.44])
873+
pl = np.array([8, 12, 12, 8], dtype=np.int32)
874+
total = int(np.sum(pl)) # 40
875+
876+
rng = np.random.default_rng(42)
877+
temp_data = rng.standard_normal((1, 1, total)).astype(np.float32)
878+
879+
ds = xr.Dataset(
880+
{
881+
"air_temperature": xr.DataArray(
882+
temp_data,
883+
dims=["time", "height", "reduced_gaussian_index"],
884+
attrs={
885+
"grid_mapping": "reduced_gaussian",
886+
"coordinates": "reduced_gaussian_index",
887+
"standard_name": "air_temperature",
888+
"units": "K",
889+
},
890+
),
891+
"reduced_gaussian": xr.DataArray(
892+
np.int32(0),
893+
attrs={
894+
"grid_mapping_name": "reduced_gaussian",
895+
"grid_subtype": "octahedral",
896+
"points_per_latitude": "pl",
897+
"latitude_dimension": "lat",
898+
"semi_major_axis": 6371229.0,
899+
"semi_minor_axis": 6371229.0,
900+
},
901+
),
902+
},
903+
coords={
904+
"lat": xr.DataArray(
905+
lat,
906+
dims=["lat"],
907+
attrs={"standard_name": "latitude", "units": "degrees_north"},
908+
),
909+
"pl": xr.DataArray(
910+
pl,
911+
dims=["lat"],
912+
attrs={"long_name": "number of points per latitude"},
913+
),
914+
"reduced_gaussian_index": xr.DataArray(
915+
np.arange(total, dtype=np.int32),
916+
dims=["reduced_gaussian_index"],
917+
attrs={"standard_name": "reduced_gaussian_index"},
918+
),
919+
"time": xr.DataArray(
920+
[0.0], dims=["time"], attrs={"units": "hours since 2024-01-01"}
921+
),
922+
"height": xr.DataArray([2.0], dims=["height"], attrs={"units": "m"}),
923+
},
924+
)
925+
return ds
926+
927+
928+
def _create_reduced_gaussian_land():
929+
"""Create a small O2 reduced gaussian grid with compression by gathering (land subset)."""
930+
global_ds = _create_reduced_gaussian_global()
931+
land_indices = np.array([2, 5, 10, 15, 20, 25, 30, 35], dtype=np.int32)
932+
933+
rng = np.random.default_rng(43)
934+
temp_data = rng.standard_normal((1, 1, len(land_indices))).astype(np.float32)
935+
936+
ds = xr.Dataset(
937+
{
938+
"air_temperature": xr.DataArray(
939+
temp_data,
940+
dims=["time", "height", "grid_points"],
941+
attrs={
942+
"grid_mapping": "reduced_gaussian",
943+
"coordinates": "time height reduced_gaussian_index",
944+
},
945+
),
946+
"reduced_gaussian": global_ds["reduced_gaussian"],
947+
},
948+
coords={
949+
"lat": global_ds["lat"],
950+
"pl": global_ds["pl"],
951+
"reduced_gaussian_index": global_ds["reduced_gaussian_index"],
952+
"grid_points": xr.DataArray(
953+
land_indices,
954+
dims=["grid_points"],
955+
attrs={"compress": "reduced_gaussian_index"},
956+
),
957+
"time": global_ds["time"],
958+
"height": global_ds["height"],
959+
},
960+
)
961+
return ds
962+
963+
964+
def _create_reduced_gaussian_region():
965+
"""Create a small O2 reduced gaussian grid with compression (regional subset)."""
966+
global_ds = _create_reduced_gaussian_global()
967+
region_indices = np.array([8, 9, 10, 11, 12, 13, 14], dtype=np.int32)
968+
969+
rng = np.random.default_rng(44)
970+
temp_data = rng.standard_normal((1, 1, len(region_indices))).astype(np.float32)
971+
972+
ds = xr.Dataset(
973+
{
974+
"air_temperature": xr.DataArray(
975+
temp_data,
976+
dims=["time", "height", "grid_points"],
977+
attrs={
978+
"grid_mapping": "reduced_gaussian",
979+
"coordinates": "time height reduced_gaussian_index",
980+
},
981+
),
982+
"reduced_gaussian": global_ds["reduced_gaussian"],
983+
},
984+
coords={
985+
"lat": global_ds["lat"],
986+
"pl": global_ds["pl"],
987+
"reduced_gaussian_index": global_ds["reduced_gaussian_index"],
988+
"grid_points": xr.DataArray(
989+
region_indices,
990+
dims=["grid_points"],
991+
attrs={
992+
"standard_name": "reduced_gaussian_index",
993+
"compress": "reduced_gaussian_index",
994+
},
995+
),
996+
"time": global_ds["time"],
997+
"height": global_ds["height"],
998+
},
999+
)
1000+
return ds
1001+
1002+
1003+
reduced_gaussian_global_ds = _create_reduced_gaussian_global()
1004+
reduced_gaussian_land_ds = _create_reduced_gaussian_land()
1005+
reduced_gaussian_region_ds = _create_reduced_gaussian_region()

cf_xarray/tests/test_accessor.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,6 +1264,132 @@ def test_grid_mappings_coordinates_attribute():
12641264
)
12651265

12661266

1267+
@requires_pyproj
1268+
def test_reduced_gaussian_grid_mapping_global():
1269+
"""Test GridMapping integration for a full reduced gaussian grid."""
1270+
from ..datasets import reduced_gaussian_global_ds as ds
1271+
1272+
# Grid mapping discovery
1273+
assert ds.cf.grid_mapping_names == {"reduced_gaussian": ["reduced_gaussian"]}
1274+
1275+
# CF indexing returns the grid mapping variable
1276+
gm_var = ds.cf["reduced_gaussian"]
1277+
assert gm_var.attrs["grid_mapping_name"] == "reduced_gaussian"
1278+
assert gm_var.attrs["grid_subtype"] == "octahedral"
1279+
1280+
# Grid mapping propagation to data variables
1281+
da = ds.cf["air_temperature"]
1282+
assert "reduced_gaussian" in da.coords
1283+
1284+
# grid_mapping_name property
1285+
assert da.cf.grid_mapping_name == "reduced_gaussian"
1286+
1287+
# .cf.grid_mappings property returns valid GridMapping
1288+
gms = ds.cf.grid_mappings
1289+
assert len(gms) == 1
1290+
gm = gms[0]
1291+
assert gm.name == "reduced_gaussian"
1292+
assert gm.crs is not None
1293+
assert gm.crs.is_geographic
1294+
assert gm.array.name == "reduced_gaussian"
1295+
assert gm.array.shape == () # scalar variable
1296+
assert isinstance(gm.coordinates, tuple)
1297+
# Should detect reduced_gaussian_index via standard_name
1298+
assert "reduced_gaussian_index" in gm.coordinates
1299+
1300+
# DataArray grid_mappings should also work
1301+
da_gms = da.cf.grid_mappings
1302+
assert len(da_gms) == 1
1303+
assert da_gms[0].name == "reduced_gaussian"
1304+
assert da_gms[0].crs.is_geographic
1305+
1306+
# Repr should include reduced_gaussian
1307+
assert "reduced_gaussian" in ds.cf.__repr__()
1308+
1309+
1310+
@requires_pyproj
1311+
def test_reduced_gaussian_grid_mapping_land():
1312+
"""Test GridMapping for a land-only subset using CF compress/gather."""
1313+
from ..datasets import reduced_gaussian_land_ds as ds
1314+
1315+
# Grid mapping discovery
1316+
assert ds.cf.grid_mapping_names == {"reduced_gaussian": ["reduced_gaussian"]}
1317+
1318+
# Grid mapping propagation
1319+
da = ds.cf["air_temperature"]
1320+
assert "reduced_gaussian" in da.coords
1321+
1322+
# .cf.grid_mappings property
1323+
gms = ds.cf.grid_mappings
1324+
assert len(gms) == 1
1325+
gm = gms[0]
1326+
assert gm.name == "reduced_gaussian"
1327+
assert gm.crs.is_geographic
1328+
assert gm.array.attrs["grid_subtype"] == "octahedral"
1329+
1330+
# Coordinates should include the compress/gather variable
1331+
assert "grid_points" in gm.coordinates
1332+
1333+
# Verify the dataset structure: data on grid_points, with compress attribute
1334+
assert "grid_points" in ds.dims
1335+
assert "compress" in ds.grid_points.attrs
1336+
assert ds.grid_points.attrs["compress"] == "reduced_gaussian_index"
1337+
1338+
1339+
@requires_pyproj
1340+
def test_reduced_gaussian_grid_mapping_region():
1341+
"""Test GridMapping for a regional subset using CF compress/gather."""
1342+
from ..datasets import reduced_gaussian_region_ds as ds
1343+
1344+
# Grid mapping discovery
1345+
assert ds.cf.grid_mapping_names == {"reduced_gaussian": ["reduced_gaussian"]}
1346+
1347+
# Grid mapping propagation
1348+
da = ds.cf["air_temperature"]
1349+
assert "reduced_gaussian" in da.coords
1350+
1351+
# .cf.grid_mappings property
1352+
gms = ds.cf.grid_mappings
1353+
assert len(gms) == 1
1354+
gm = gms[0]
1355+
assert gm.name == "reduced_gaussian"
1356+
assert gm.crs.is_geographic
1357+
1358+
# Coordinates should include both reduced_gaussian_index and grid_points
1359+
assert "reduced_gaussian_index" in gm.coordinates
1360+
assert "grid_points" in gm.coordinates
1361+
1362+
# Verify compress structure
1363+
assert "grid_points" in ds.dims
1364+
assert ds.grid_points.attrs["compress"] == "reduced_gaussian_index"
1365+
1366+
# CRS earth parameters should match the grid mapping variable
1367+
gm_attrs = ds.reduced_gaussian.attrs
1368+
# The CRS should be built from semi_major/minor_axis
1369+
assert gm.crs.ellipsoid is not None
1370+
assert gm.crs.ellipsoid.semi_major_metre == gm_attrs["semi_major_axis"]
1371+
1372+
1373+
@requires_pyproj
1374+
def test_reduced_gaussian_crs_properties():
1375+
"""Test that the CRS built for reduced_gaussian has correct properties."""
1376+
from ..datasets import reduced_gaussian_global_ds as ds
1377+
1378+
gm = ds.cf.grid_mappings[0]
1379+
crs = gm.crs
1380+
1381+
# Must be geographic (lat/lon on a sphere)
1382+
assert crs.is_geographic
1383+
assert not crs.is_projected
1384+
1385+
# Earth shape parameters from the grid mapping variable
1386+
assert crs.ellipsoid.semi_major_metre == 6371229.0
1387+
assert crs.ellipsoid.semi_minor_metre == 6371229.0
1388+
1389+
# Should not have an EPSG code (custom sphere)
1390+
assert crs.to_epsg() is None
1391+
1392+
12671393
@requires_pyproj
12681394
def test_bad_grid_mapping_attribute():
12691395
ds = rotds.copy(deep=False)
@@ -1310,6 +1436,8 @@ def test_healpix_grid_mapping():
13101436
assert gm.array.name == "healpix"
13111437
assert gm.array.shape == () # scalar variable
13121438
assert isinstance(gm.coordinates, tuple)
1439+
# Should detect healpix_index via standard_name
1440+
assert "healpix_index" in gm.coordinates
13131441

13141442
# DataArray grid_mappings should also work
13151443
da_gms = da.cf.grid_mappings

0 commit comments

Comments
 (0)