diff --git a/Changelog.rst b/Changelog.rst index cc4bfd912..3eba038bb 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -1,3 +1,13 @@ +Version NEXTVERSION +---------------- + +**2026-??-??** + +* Support for HEALPix grids + (https://github.com/NCAS-CMS/cfdm/issues/370) + +---- + Version 1.13.0.0 ---------------- diff --git a/README.md b/README.md index e9d4af6a4..1f22fa988 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The ``cfdm`` package can: * write and append field and domain constructs to netCDF and Zarr v3 datasets on disk, * read, write, and manipulate UGRID mesh topologies, +* read, write, and manipulate HEALPix grids, * read, write, and create coordinates defined by geometry cells, * read and write netCDF4 string data-type variables, * read, write, and create netCDF and CDL datasets containing diff --git a/cfdm/constructs.py b/cfdm/constructs.py index c8fa90d14..a667533f8 100644 --- a/cfdm/constructs.py +++ b/cfdm/constructs.py @@ -297,7 +297,7 @@ def _del_construct(self, key, default=ValueError()): # -------------------------------------------------------- # Since a cell method construct was deleted, check to see # if it was for climatological time, and if so reset the - # climatology status of approriate coordinate constructs. + # climatology status of appropriate coordinate constructs. # -------------------------------------------------------- qualifiers = out.qualifiers() if "within" in qualifiers or "over" in qualifiers: @@ -553,7 +553,7 @@ def _equals_domain_axis( return True def _set_climatology(self, cell_methods=None, coordinates=None): - """Set the climatology flag on approriate coordinate constructs. + """Set the climatology flag on coordinate constructs. The setting is based on the cell method constructs. diff --git a/cfdm/data/data.py b/cfdm/data/data.py index 55b855eee..0da4138a8 100644 --- a/cfdm/data/data.py +++ b/cfdm/data/data.py @@ -6621,6 +6621,8 @@ def rechunk( {{balance: `bool`, optional}} + {{inplace: `bool`, optional}} + :Returns: `Data` or `None` @@ -6833,8 +6835,8 @@ def reshape(self, *shape, merge_chunks=True, limit=None, inplace=False): """Change the shape of the data without changing its values. It assumes that the array is stored in row-major order, and - only allows for reshapings that collapse or merge dimensions - like ``(1, 2, 3, 4) -> (1, 6, 4)`` or ``(64,) -> (4, 4, 4)``. + only allows for reshapings that collapse or merge dimensions, + e.g. ``(1, 2, 3, 4) -> (1, 6, 4)`` or ``(64,) -> (4, 4, 4)``. .. versionadded:: (cfdm) 1.11.2.0 diff --git a/cfdm/examplefield.py b/cfdm/examplefield.py index ffcf7f26d..3bed78d0a 100644 --- a/cfdm/examplefield.py +++ b/cfdm/examplefield.py @@ -1,10 +1,12 @@ +import numpy as np + from .cfdmimplementation import implementation from .functions import CF _implementation = implementation() # The number of example fields -_n_example_fields = 12 +_n_example_fields = 14 def example_field(n, _implementation=_implementation): @@ -57,6 +59,17 @@ def example_field(n, _implementation=_implementation): ``11`` Discrete sampling geometry (DSG) "trajectory" features. + + ``12`` A global HEALPix grid with "nested" indexing + scheme at refinement level 1. The field's + area-weighted global latitude-longitude means are + equal to those of example field ``13``. + + ``13`` A global HEALPix Multi-Order Coverage grid with + "nuniq" indexing scheme representing refinement + levels 1 and 2. The field's area-weighted global + latitude-longitude means are equal to those of + example field ``12``. ====== ================================================== See the examples for details. @@ -228,6 +241,26 @@ def example_field(n, _implementation=_implementation): : longitude(cf_role=trajectory_id(1), ncdim%trajectory(4)) = [[0.0, ..., 0.31]] degree_east : cf_role=trajectory_id(cf_role=trajectory_id(1)) = [flight1] + >>> print(cfdm.example_field(12)) + Field: air_temperature (ncvar%tas) + ---------------------------------- + Data : air_temperature(time(2), healpix_index(48)) K + Cell methods : time(2): mean area: mean + Dimension coords: time(2) = [2025-06-16 00:00:00, 2025-07-16 12:00:00] proleptic_gregorian + : height(1) = [1.5] m + : healpix_index(healpix_index(48)) = [0, ..., 47] + Coord references: grid_mapping_name:healpix + + >>> print(cfdm.example_field(13)) + Field: air_temperature (ncvar%tas) + ---------------------------------- + Data : air_temperature(time(2), healpix_index(60)) K + Cell methods : time(2): mean area: mean + Dimension coords: time(2) = [2025-06-16 00:00:00, 2025-07-16 12:00:00] proleptic_gregorian + : height(1) = [1.5] m + Auxiliary coords: healpix_index(healpix_index(60)) = [64, ..., 63] + Coord references: grid_mapping_name:healpix + """ # For safety given the private second argument which we might not # document, otherwise a user gets an obscure error if they tried, say: @@ -244,7 +277,9 @@ def example_field(n, _implementation=_implementation): CellConnectivity = _implementation.get_class("CellConnectivity") CellMeasure = _implementation.get_class("CellMeasure") CellMethod = _implementation.get_class("CellMethod") + CoordinateConversion = _implementation.get_class("CoordinateConversion") CoordinateReference = _implementation.get_class("CoordinateReference") + Datum = _implementation.get_class("Datum") DimensionCoordinate = _implementation.get_class("DimensionCoordinate") DomainAncillary = _implementation.get_class("DomainAncillary") DomainAxis = _implementation.get_class("DomainAxis") @@ -259,6 +294,8 @@ def example_field(n, _implementation=_implementation): mesh_id = "f51e5aa5e2b0439f9fae4f04e51556f7" + field = None + if n == 0: f = Field() @@ -5290,12 +5327,544 @@ def example_field(n, _implementation=_implementation): # # field data axes f.set_data_axes(("domainaxis0", "domainaxis1")) + elif n == 12: + # field: air_temperature + field = Field() + field.set_properties( + { + "Conventions": "CF-1.12", + "standard_name": "air_temperature", + "units": "K", + "units_metadata": "temperature: on_scale", + } + ) + field.nc_set_variable("tas") + data = Data( + [ + [ + 291.5, + 293.5, + 285.3, + 286.3, + 286.2, + 289.6, + 285.6, + 285.5, + 287.1, + 285.5, + 291.5, + 285.2, + 285.0, + 291.1, + 287.9, + 290.9, + 288.6, + 291.6, + 289.6, + 289.0, + 294.0, + 293.1, + 291.5, + 288.3, + 289.6, + 285.3, + 285.4, + 286.9, + 294.3, + 289.0, + 294.5, + 286.3, + 288.3, + 287.2, + 285.7, + 291.7, + 290.1, + 291.1, + 286.8, + 286.0, + 291.1, + 292.2, + 285.7, + 288.8, + 285.8, + 290.8, + 287.0, + 290.0, + ], + [ + 294.2, + 287.7, + 294.6, + 289.5, + 289.2, + 293.0, + 286.8, + 287.8, + 285.2, + 294.0, + 288.1, + 293.5, + 289.6, + 292.5, + 290.0, + 290.5, + 292.9, + 290.7, + 293.6, + 288.3, + 293.5, + 294.0, + 288.7, + 292.0, + 292.9, + 289.5, + 286.8, + 288.3, + 292.6, + 290.3, + 290.8, + 290.4, + 287.7, + 289.9, + 288.4, + 294.7, + 291.4, + 294.7, + 287.6, + 286.5, + 291.4, + 293.0, + 288.8, + 288.8, + 292.3, + 293.7, + 290.1, + 285.9, + ], + ], + units="K", + dtype="f8", + ) + field.set_data(data) + # + # domain_axis: ncdim%time + c = DomainAxis() + c.set_size(2) + c.nc_set_dimension("time") + field.set_construct(c, key="domainaxis0", copy=False) + # + # domain_axis: ncdim%cell + c = DomainAxis() + c.set_size(48) + c.nc_set_dimension("cell") + field.set_construct(c, key="domainaxis1", copy=False) + # + # domain_axis: ncdim%height + c = DomainAxis() + c.set_size(1) + c.nc_set_dimension("height") + field.set_construct(c, key="domainaxis2", copy=False) + # + # dimension_coordinate: healpix_index + c = DimensionCoordinate() + c.set_properties({"standard_name": "healpix_index"}) + c.nc_set_variable("healpix_index") + data = Data(np.arange(48, dtype="int32")) + c.set_data(data) + field.set_construct( + c, axes=("domainaxis1",), key="dimensioncoordinate2", copy=False + ) + # + # dimension_coordinate: time + c = DimensionCoordinate() + c.set_properties( + { + "standard_name": "time", + "calendar": "proleptic_gregorian", + "units": "days since 2025-06-01", + } + ) + c.nc_set_variable("time") + data = Data( + [15.0, 45.5], + units="days since 2025-06-01", + calendar="proleptic_gregorian", + dtype="f4", + ) + c.set_data(data) + b = Bounds() + b.nc_set_variable("time_bounds") + data = Data( + [[0.0, 30.0], [30.0, 61.0]], + units="days since 2025-06-01", + calendar="proleptic_gregorian", + dtype="f4", + ) + b.set_data(data) + c.set_bounds(b) + field.set_construct( + c, axes=("domainaxis0",), key="dimensioncoordinate0", copy=False + ) + # + # dimension_coordinate: height + c = DimensionCoordinate() + c.set_properties({"standard_name": "height", "units": "m"}) + c.nc_set_variable("height") + data = Data([1.5], units="m", dtype="f4") + c.set_data(data) + field.set_construct( + c, axes=("domainaxis2",), key="dimensioncoordinate1", copy=False + ) + # + # coordinate_reference: grid_mapping_name:healpix + c = CoordinateReference() + c.nc_set_variable("healpix") + c.set_coordinates({"dimensioncoordinate2"}) + d = Datum() + d.set_parameters({"earth_radius": 6371000}) + c.set_datum(d) + f = CoordinateConversion() + f.set_parameters( + { + "indexing_scheme": "nested", + "refinement_level": 1, + "grid_mapping_name": "healpix", + } + ) + c.set_coordinate_conversion(f) + field.set_construct(c) + # + # cell_method: mean + c = CellMethod() + c.set_method("mean") + c.set_axes(("domainaxis0",)) + field.set_construct(c) + # + # cell_method: mean + c = CellMethod() + c.set_method("mean") + c.set_axes(("area",)) + field.set_construct(c) + # + # field data axes + field.set_data_axes(("domainaxis0", "domainaxis1")) + elif n == 13: + # field: air_temperature + field = Field() + field.set_properties( + { + "Conventions": "CF-1.12", + "standard_name": "air_temperature", + "units": "K", + "units_metadata": "temperature: on_scale", + } + ) + field.nc_set_variable("tas") + data = Data( + [ + [ + 291.6, + 291.7, + 291.4, + 291.3, + 293.6, + 293.7, + 293.4, + 293.3, + 285.4, + 285.5, + 285.2, + 285.1, + 286.4, + 286.5, + 286.2, + 286.1, + 286.2, + 289.6, + 285.6, + 285.5, + 287.1, + 285.5, + 291.5, + 285.2, + 285.0, + 291.1, + 287.9, + 290.9, + 288.6, + 291.6, + 289.6, + 289.0, + 294.0, + 293.1, + 291.5, + 288.3, + 289.6, + 285.3, + 285.4, + 286.9, + 294.3, + 289.0, + 294.5, + 286.3, + 288.3, + 287.2, + 285.7, + 291.7, + 290.1, + 291.1, + 286.8, + 286.0, + 291.1, + 292.2, + 285.7, + 288.8, + 285.8, + 290.8, + 287.0, + 290.0, + ], + [ + 294.3, + 294.4, + 294.1, + 294.0, + 287.8, + 287.9, + 287.6, + 287.5, + 294.7, + 294.8, + 294.5, + 294.4, + 289.6, + 289.7, + 289.4, + 289.3, + 289.2, + 293.0, + 286.8, + 287.8, + 285.2, + 294.0, + 288.1, + 293.5, + 289.6, + 292.5, + 290.0, + 290.5, + 292.9, + 290.7, + 293.6, + 288.3, + 293.5, + 294.0, + 288.7, + 292.0, + 292.9, + 289.5, + 286.8, + 288.3, + 292.6, + 290.3, + 290.8, + 290.4, + 287.7, + 289.9, + 288.4, + 294.7, + 291.4, + 294.7, + 287.6, + 286.5, + 291.4, + 293.0, + 288.8, + 288.8, + 292.3, + 293.7, + 290.1, + 285.9, + ], + ], + units="K", + dtype="f8", + ) + field.set_data(data) + # + # domain_axis: ncdim%time + c = DomainAxis() + c.set_size(2) + c.nc_set_dimension("time") + field.set_construct(c, key="domainaxis0", copy=False) + # + # domain_axis: ncdim%cell + c = DomainAxis() + c.set_size(60) + c.nc_set_dimension("cell") + field.set_construct(c, key="domainaxis1", copy=False) + # + # domain_axis: ncdim%height + c = DomainAxis() + c.set_size(1) + c.nc_set_dimension("height") + field.set_construct(c, key="domainaxis2", copy=False) + # + # auxiliary_coordinate: healpix_index + c = AuxiliaryCoordinate() + c.set_properties({"standard_name": "healpix_index"}) + c.nc_set_variable("healpix_index") + data = Data( + [ + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + ], + dtype="i4", + ) + c.set_data(data) + field.set_construct( + c, axes=("domainaxis1",), key="auxiliarycoordinate0", copy=False + ) + # + # dimension_coordinate: time + c = DimensionCoordinate() + c.set_properties( + { + "standard_name": "time", + "calendar": "proleptic_gregorian", + "units": "days since 2025-06-01", + } + ) + c.nc_set_variable("time") + data = Data( + [15.0, 45.5], + units="days since 2025-06-01", + calendar="proleptic_gregorian", + dtype="f4", + ) + c.set_data(data) + b = Bounds() + b.nc_set_variable("time_bounds") + data = Data( + [[0.0, 30.0], [30.0, 61.0]], + units="days since 2025-06-01", + calendar="proleptic_gregorian", + dtype="f4", + ) + b.set_data(data) + c.set_bounds(b) + field.set_construct( + c, axes=("domainaxis0",), key="dimensioncoordinate0", copy=False + ) + # + # dimension_coordinate: height + c = DimensionCoordinate() + c.set_properties({"standard_name": "height", "units": "m"}) + c.nc_set_variable("height") + data = Data([1.5], units="m", dtype="f4") + c.set_data(data) + field.set_construct( + c, axes=("domainaxis2",), key="dimensioncoordinate1", copy=False + ) + # + # coordinate_reference: grid_mapping_name:healpix + c = CoordinateReference() + c.nc_set_variable("healpix") + c.set_coordinates({"auxiliarycoordinate0"}) + d = Datum() + d.set_parameters({"earth_radius": 6371000}) + c.set_datum(d) + f = CoordinateConversion() + f.set_parameters( + { + "indexing_scheme": "nuniq", + "grid_mapping_name": "healpix", + } + ) + c.set_coordinate_conversion(f) + field.set_construct(c) + # + # cell_method: mean + c = CellMethod() + c.set_method("mean") + c.set_axes(("domainaxis0",)) + field.set_construct(c) + # + # cell_method: mean + c = CellMethod() + c.set_method("mean") + c.set_axes(("area",)) + field.set_construct(c) + # + # field data axes + field.set_data_axes(("domainaxis0", "domainaxis1")) else: raise ValueError( "Must select an example construct with an integer argument " f"between 0 and {_n_example_fields - 1} inclusive. Got {n!r}" ) + if field is not None: + f = field + return f @@ -5317,7 +5886,6 @@ def example_fields(*n, _func=example_field): ====== ================================================== ``0`` Cell method and dimension coordinate metadata constructs. - ``1`` Cell method, dimension coordinate, auxiliary coordinate, cell measure, coordinate reference, domain ancillary and field ancillary metadata @@ -5350,6 +5918,17 @@ def example_fields(*n, _func=example_field): ``11`` Discrete sampling geometry (DSG) "trajectory" features. + + ``12`` A global HEALPix grid with "nested" indices at + refinement level 1. The field's area-weighted + global latitude-longitude means are equal to those + of example field ``13``. + + ``13`` A global HEALPix Multi-Order Coverage grid with + "nuniq" indices representing refinement levels 1 + and 2. The field's area-weighted global + latitude-longitude means are equal to those of + example field ``12``. ====== ================================================== If no individual field constructs are selected then all @@ -5382,7 +5961,9 @@ def example_fields(*n, _func=example_field): , , , - ] + + , + ] >>> cfdm.example_fields(7, 1) [, @@ -5463,6 +6044,13 @@ def example_domain(n, _func=example_field): ``11`` Discrete sampling geometry (DSG) "trajectory" features. + + ``12`` A global HEALPix grid with "nested" indexing + scheme at refinement level 1. + + ``13`` A global HEALPix Multi-Order Coverage grid with + "nuniq" indexing scheme representing refinement + levels 1 and 2. ====== ================================================== See the examples for details. @@ -5581,5 +6169,17 @@ def example_domain(n, _func=example_field): : longitude(cf_role=trajectory_id(1), ncdim%trajectory(4)) = [[0.0, ..., 0.31]] degree_east : cf_role=trajectory_id(cf_role=trajectory_id(1)) = [flight1] + >>> print(cfdm.example_domain(12)) + Dimension coords: time(2) = [2025-06-16 00:00:00, 2025-07-16 12:00:00] proleptic_gregorian + : height(1) = [1.5] m + : healpix_index(healpix_index(48)) = [0, ..., 47] + Coord references: grid_mapping_name:healpix + + >>> print(cfdm.example_domain(13)) + Dimension coords: time(2) = [2025-06-16 00:00:00, 2025-07-16 12:00:00] proleptic_gregorian + : height(1) = [1.5] m + Auxiliary coords: healpix_index(healpix_index(60)) = [64, ..., 63] + Coord references: grid_mapping_name:healpix + """ return _func(n).get_domain() diff --git a/cfdm/read_write/netcdf/netcdfread.py b/cfdm/read_write/netcdf/netcdfread.py index ed2a25710..886bbfe66 100644 --- a/cfdm/read_write/netcdf/netcdfread.py +++ b/cfdm/read_write/netcdf/netcdfread.py @@ -263,6 +263,7 @@ def cf_coordinate_reference_coordinates(self): "latitude", "longitude", ), + "healpix": ("healpix_index", "latitude", "longitude"), "atmosphere_ln_pressure_coordinate": ( "atmosphere_ln_pressure_coordinate", ), diff --git a/cfdm/test/test_groups.py b/cfdm/test/test_groups.py index 09c34d5d5..5bb7b2fb4 100644 --- a/cfdm/test/test_groups.py +++ b/cfdm/test/test_groups.py @@ -171,7 +171,6 @@ def test_groups(self): # ------------------------------------------------------------ name = "grid_latitude" g.construct(name).bounds.nc_set_variable_groups(["forecast"]) - grouped_file = "grouped_file.nc" cfdm.write(g, grouped_file) nc = netCDF4.Dataset(grouped_file, "r") @@ -305,7 +304,6 @@ def test_groups_geometry(self): g.nc_set_component_variable("interior_ring", "interior_ring") g.nc_set_component_variable_groups("interior_ring", ["forecast"]) - grouped_file = "grouped_file.nc" cfdm.write(g, grouped_file) # Check that the variable is in the right group diff --git a/setup.py b/setup.py index e9d5fd2be..b2d781194 100755 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ def _get_version(): * create new field and domain constructs in memory, * write and append field and domain constructs to netCDF and Zarr v3 datasets on disk, * read, write, and manipulate UGRID mesh topologies, +* read, write, and manipulate HEALPix grids, * read, write, and create coordinates defined by geometry cells, * read and write netCDF4 string data-type variables, * read, write, and create netCDF and CDL datasets containing hierarchical groups,