From c44eacb2cabf85f5c3e1190740a5f0f855d1990e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 25 Jun 2026 16:28:52 +0100 Subject: [PATCH 01/43] dev --- cf/mixin/fielddomain.py | 76 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index f26b1c3de1..92d764a4e4 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2453,14 +2453,14 @@ def healpix_to_ugrid(self, cache=True, inplace=False): @_inplace_enabled(default=False) @_manage_log_level_via_verbosity def create_latlon_coordinates( - self, - one_d=True, - two_d=True, - pole_longitude=None, - overwrite=False, - cache=True, - inplace=False, - verbose=None, + self, + one_d=True, + two_d=True, + pole_longitude=None, + overwrite=False, + cache=True, + inplace=False, + verbose=None, ): """Create latitude and longitude coordinates. @@ -2670,8 +2670,66 @@ def create_latlon_coordinates( # -------------------------------------------------------- # 2-d lat/lon coordinates # -------------------------------------------------------- - pass # For now ... + proj = None + if identity == "grid_mapping_name:rotated_latitude_longitude": + key_x, coord_x = self.dimension_coordinate("grid_longitude", item=True) + key_y, coord_y = self.dimension_coordinate("grid_latitude", item=True) + + pole_lat = cr.coordinate_conversion.get_parameter("grid_north_pole_latitude") + pole_lon = cr.coordinate_conversion.get_parameter("grid_north_pole_longitude") + + # 3. Create a clean PyProj transformer under the hood to calculate the 2D matrix + import pyproj + + # Map the CF metadata explicitly to PROJ components + # The lon_0=180 parameter handles standard CF South-Pole shift convention + proj = pyproj.CRS( + proj="ob_tran", + o_proj="longlat", + o_lon_p=pole_lon, + o_lat_p=pole_lat, + lon_0=180, + ) + transformer = None + if proj is not None: + if latlon_cr: + pass + else: + # Default sphere + proj_latlon = pyproj.CRS(proj="longlat", ellps="sphere") + + transformer = pyproj.Transformer.from_crs( + proj, proj_latlon, always_xy=True + ) + + if transformer is not None: + # Meshgrid the raw data arrays from your 1D + # cf.Coordinate objects + lon_2d_mesh, lat_2d_mesh = np.meshgrid(coord_x.array, coord_y.array) + true_lon_2d, true_lat_2d = transformer.transform(lon_2d_mesh, lat_2d_mesh) + + # 4. Turn these 2D numpy arrays into proper CF + # Auxiliary Coordinates Identify the axis + # names/identifiers from your field to map dimensions + # properly + aux_lat = cf.AuxiliaryCoordinate( + data=cf.Data(true_lat_2d, "degrees_north"), + properties={"standard_name": "latitude"}, + ) + aux_lon = cf.AuxiliaryCoordinate( + data=cf.Data(true_lon_2d, "degrees_east"), + properties={"standard_name": "longitude"}, + ) + + # 5. Set the newly created 2D auxiliary coordinates back + # onto the Field Construct + axes = (self.get_data_axes(key_x)[0], self.get_data_axes(key_y)[0]) + lat_key = field.set_construct(aux_lat, axes=axes, copy=False) + lon_key = field.set_construct(aux_lon, axes=axes, copy=False) + + coords_created = lat_key is not None + # ------------------------------------------------------------ # Update the appropriate coordinate reference with any new # coordinate keys From 5e546a5ae8adb422d4a770100739bd6c96588fd0 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 26 Jun 2026 16:09:43 +0100 Subject: [PATCH 02/43] dev --- cf/__init__.py | 1 + cf/cfimplementation.py | 48 +--- cf/data/array/__init__.py | 1 + cf/data/array/h5netcdfarray.py | 21 +- cf/data/array/netcdf4array.py | 19 +- cf/data/array/pyfivearray.py | 21 +- cf/data/array/scipynetcdffilearray.py | 19 +- cf/data/array/umarray.py | 6 +- cf/data/array/zarrarray.py | 22 +- cf/mixin/fielddomain.py | 106 ++----- cf/mixin/latlon_utils.py | 391 ++++++++++++++++++++++++++ cf/read_write/read.py | 133 ++++----- cf/read_write/um/umread.py | 2 - 13 files changed, 526 insertions(+), 264 deletions(-) create mode 100644 cf/mixin/latlon_utils.py diff --git a/cf/__init__.py b/cf/__init__.py index ea65bc7997..6b47804e69 100644 --- a/cf/__init__.py +++ b/cf/__init__.py @@ -172,6 +172,7 @@ ScipyNetcdfFileArray, SubsampledArray, UMArray, + XnetcdfArray, ZarrArray, ) diff --git a/cf/cfimplementation.py b/cf/cfimplementation.py index cfa4ce156c..2132608555 100644 --- a/cf/cfimplementation.py +++ b/cf/cfimplementation.py @@ -41,6 +41,7 @@ RaggedIndexedContiguousArray, ScipyNetcdfFileArray, SubsampledArray, + XnetcdfArray, ZarrArray, ) from .functions import CF @@ -158,6 +159,7 @@ def set_construct(self, parent, construct, axes=None, copy=True, **kwargs): RaggedIndexedContiguousArray=RaggedIndexedContiguousArray, SubsampledArray=SubsampledArray, TiePointIndex=TiePointIndex, + XnetcdfArray=XnetcdfArray, ZarrArray=ZarrArray, ) @@ -174,51 +176,5 @@ def implementation(): `CFImplementation` A container for the CF data model implementation. - **Examples** - - >>> i = cf.implementation() - >>> i - - >>> i.classes() - {'AuxiliaryCoordinate': cf.auxiliarycoordinate.AuxiliaryCoordinate, - 'BoundsFromNodesArray': cf.data.array.boundsfromnodesarray.BoundsFromNodesArray, - 'CellConnectivity': cf.cellconnectivity.CellConnectivity, - 'CellConnectivityArray': cf.data.array.cellconnectivityarray.CellConnectivityArray, - 'CellMeasure': cf.cellmeasure.CellMeasure, - 'CellMethod': cf.cellmethod.CellMethod, - 'CoordinateReference': cf.coordinatereference.CoordinateReference, - 'DimensionCoordinate': cf.dimensioncoordinate.DimensionCoordinate, - 'Domain': cf.domain.Domain, - 'DomainAncillary': cf.domainancillary.DomainAncillary, - 'DomainAxis': cf.domainaxis.DomainAxis, - 'DomainTopology': cf.domaintopology.DomainTopology, - 'Field': cf.field.Field, - 'FieldAncillary': cf.fieldancillary.FieldAncillary, - 'Bounds': cf.bounds.Bounds, - 'InteriorRing': cf.interiorring.InteriorRing, - 'InterpolationParameter': cf.interpolationparameter.InterpolationParameter, - 'CoordinateConversion': cf.coordinateconversion.CoordinateConversion, - 'Datum': cf.datum.Datum, - 'List': cf.list.List, - 'Index': cf.index.Index, - 'Count': cf.count.Count, - 'NodeCountProperties': cf.nodecountproperties.NodeCountProperties, - 'PartNodeCountProperties': cf.partnodecountproperties.PartNodeCountProperties, - 'Data': cf.data.data.Data, - 'GatheredArray': cf.data.array.gatheredarray.GatheredArray, - 'H5netcdfArray': cf.data.array.h5netcdfarray.H5netcdfArray, - 'NetCDF4Array': cf.data.array.netcdf4array.NetCDF4Array, - 'ScipyNetcdfFileArray': cf.data.array.scipynetcdffilearray.ScipyNetcdfFileArray, - 'PointTopologyArray': , - 'PyfiveArray': cf.data.array.pyfivearray.PyfiveArray, - 'Quantization': cf.quantization.Quantization, - 'RaggedContiguousArray': cf.data.array.raggedcontiguousarray.RaggedContiguousArray, - 'RaggedIndexedArray': cf.data.array.raggedindexedarray.RaggedIndexedArray, - 'RaggedIndexedContiguousArray': cf.data.array.raggedindexedcontiguousarray.RaggedIndexedContiguousArray, - 'SubsampledArray': cf.data.array.subsampledarray.SubsampledArray, - 'TiePointIndex': cf.tiepointindex.TiePointIndex, - 'ZarrArray': cf.data.array.zarrarray.ZarrArray, - } - """ return _implementation.copy() diff --git a/cf/data/array/__init__.py b/cf/data/array/__init__.py index 2d0c03b3a2..fb06a4ef7b 100644 --- a/cf/data/array/__init__.py +++ b/cf/data/array/__init__.py @@ -14,4 +14,5 @@ from .raggedindexedcontiguousarray import RaggedIndexedContiguousArray from .subsampledarray import SubsampledArray from .umarray import UMArray +from .xnetcdfarray import XnetcdfArray from .zarrarray import ZarrArray diff --git a/cf/data/array/h5netcdfarray.py b/cf/data/array/h5netcdfarray.py index e54ceb4e13..fc97a8d1c5 100644 --- a/cf/data/array/h5netcdfarray.py +++ b/cf/data/array/h5netcdfarray.py @@ -1,16 +1,15 @@ -import cfdm - -from ...mixin_container import Container -from .mixin import ActiveStorageMixin - - -class H5netcdfArray( - ActiveStorageMixin, - Container, - cfdm.H5netcdfArray, -): +class H5netcdfArray: """A netCDF array accessed with `h5netcdf` using the `h5py` backend. .. versionadded:: 3.16.3 """ + + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" + + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." + ) diff --git a/cf/data/array/netcdf4array.py b/cf/data/array/netcdf4array.py index 49c2c05c50..8ec9f864f6 100644 --- a/cf/data/array/netcdf4array.py +++ b/cf/data/array/netcdf4array.py @@ -1,12 +1,11 @@ -import cfdm - -from ...mixin_container import Container -from .mixin import ActiveStorageMixin +class NetCDF4Array: + """A netCDF array accessed with `netCDF4`.""" + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" -class NetCDF4Array( - ActiveStorageMixin, - Container, - cfdm.NetCDF4Array, -): - """A netCDF array accessed with `netCDF4`.""" + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." + ) diff --git a/cf/data/array/pyfivearray.py b/cf/data/array/pyfivearray.py index 3d6972cbd8..f9835bde74 100644 --- a/cf/data/array/pyfivearray.py +++ b/cf/data/array/pyfivearray.py @@ -1,16 +1,15 @@ -import cfdm - -from ...mixin_container import Container -from .mixin import ActiveStorageMixin - - -class PyfiveArray( - ActiveStorageMixin, - Container, - cfdm.PyfiveArray, -): +class PyfiveArray: """A netCDF array accessed with `pyfive`. .. versionadded:: 3.20.0 """ + + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" + + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." + ) diff --git a/cf/data/array/scipynetcdffilearray.py b/cf/data/array/scipynetcdffilearray.py index 8643ced143..54cb21eabb 100644 --- a/cf/data/array/scipynetcdffilearray.py +++ b/cf/data/array/scipynetcdffilearray.py @@ -1,14 +1,15 @@ -import cfdm - -from ...mixin_container import Container - - -class ScipyNetcdfFileArray( - Container, - cfdm.ScipyNetcdfFileArray, -): +class ScipyNetcdfFileArray: """A netCDF-3 array accessed with `scipy.io.netcdf_file`. .. versionadded:: 3.20.0 """ + + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" + + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." + ) diff --git a/cf/data/array/umarray.py b/cf/data/array/umarray.py index 32944ab1d4..c097836d89 100644 --- a/cf/data/array/umarray.py +++ b/cf/data/array/umarray.py @@ -26,8 +26,7 @@ def __init__( mask=True, unpack=True, attributes=None, - storage_protocol=None, - storage_options=None, + filesystem=None, source=None, copy=True, ): @@ -117,8 +116,7 @@ def __init__( mask=mask, unpack=unpack, attributes=attributes, - storage_protocol=storage_protocol, - storage_options=storage_options, + filesystem=filesystem, source=source, copy=copy, ) diff --git a/cf/data/array/zarrarray.py b/cf/data/array/zarrarray.py index 2d3d8c784f..813839a725 100644 --- a/cf/data/array/zarrarray.py +++ b/cf/data/array/zarrarray.py @@ -1,15 +1,11 @@ -import cfdm - -from ...mixin_container import Container - -# Uncomment when we can use active storage on Zarr datasets: -# from .mixin import ActiveStorageMixin +class ZarrArray: + """A Zarr array accessed with `zarr`.""" + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" -class ZarrArray( - # Uncomment when we can use active storage on Zarr datasets: - # ActiveStorageMixin, - Container, - cfdm.ZarrArray, -): - """A Zarr array accessed with `zarr`.""" + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." + ) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 92d764a4e4..f3ec509c4c 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2453,14 +2453,14 @@ def healpix_to_ugrid(self, cache=True, inplace=False): @_inplace_enabled(default=False) @_manage_log_level_via_verbosity def create_latlon_coordinates( - self, - one_d=True, - two_d=True, - pole_longitude=None, - overwrite=False, - cache=True, - inplace=False, - verbose=None, + self, + one_d=True, + two_d=True, + pole_longitude=None, + overwrite=False, + cache=True, + inplace=False, + verbose=None, ): """Create latitude and longitude coordinates. @@ -2615,7 +2615,7 @@ def create_latlon_coordinates( # Remove a 'latitude_longitude' grid mapping (if there is one) # from the dictionary, saving it for later. - latlon_cr = coordinate_references.pop( + cr_latlon = coordinate_references.pop( "grid_mapping_name:latitude_longitude", None ) if not coordinate_references: @@ -2655,88 +2655,38 @@ def create_latlon_coordinates( # -------------------------------------------------------- # 1-d lat/lon coordinates # -------------------------------------------------------- - if identity == "grid_mapping_name:healpix": - # ---------------------------------------------------- - # HEALPix - # ---------------------------------------------------- - from ..healpix_utils import _healpix_create_latlon_coordinates + match identity: + case "grid_mapping_name:healpix": + # ------------------------------------------------ + # HEALPix + # ------------------------------------------------ + from ..healpix_utils import ( + _healpix_create_latlon_coordinates, + ) - lat_key, lon_key = _healpix_create_latlon_coordinates( - f, pole_longitude, cache - ) - coords_created = lat_key is not None + lat_key, lon_key = _healpix_create_latlon_coordinates( + f, pole_longitude, cache + ) + coords_created = lat_key is not None if two_d and not coords_created: # -------------------------------------------------------- # 2-d lat/lon coordinates # -------------------------------------------------------- - proj = None - if identity == "grid_mapping_name:rotated_latitude_longitude": - key_x, coord_x = self.dimension_coordinate("grid_longitude", item=True) - key_y, coord_y = self.dimension_coordinate("grid_latitude", item=True) - - pole_lat = cr.coordinate_conversion.get_parameter("grid_north_pole_latitude") - pole_lon = cr.coordinate_conversion.get_parameter("grid_north_pole_longitude") - - # 3. Create a clean PyProj transformer under the hood to calculate the 2D matrix - import pyproj - - # Map the CF metadata explicitly to PROJ components - # The lon_0=180 parameter handles standard CF South-Pole shift convention - proj = pyproj.CRS( - proj="ob_tran", - o_proj="longlat", - o_lon_p=pole_lon, - o_lat_p=pole_lat, - lon_0=180, - ) - - transformer = None - if proj is not None: - if latlon_cr: - pass - else: - # Default sphere - proj_latlon = pyproj.CRS(proj="longlat", ellps="sphere") - - transformer = pyproj.Transformer.from_crs( - proj, proj_latlon, always_xy=True - ) - - if transformer is not None: - # Meshgrid the raw data arrays from your 1D - # cf.Coordinate objects - lon_2d_mesh, lat_2d_mesh = np.meshgrid(coord_x.array, coord_y.array) - true_lon_2d, true_lat_2d = transformer.transform(lon_2d_mesh, lat_2d_mesh) - - # 4. Turn these 2D numpy arrays into proper CF - # Auxiliary Coordinates Identify the axis - # names/identifiers from your field to map dimensions - # properly - aux_lat = cf.AuxiliaryCoordinate( - data=cf.Data(true_lat_2d, "degrees_north"), - properties={"standard_name": "latitude"}, - ) - aux_lon = cf.AuxiliaryCoordinate( - data=cf.Data(true_lon_2d, "degrees_east"), - properties={"standard_name": "longitude"}, - ) - - # 5. Set the newly created 2D auxiliary coordinates back - # onto the Field Construct - axes = (self.get_data_axes(key_x)[0], self.get_data_axes(key_y)[0]) - lat_key = field.set_construct(aux_lat, axes=axes, copy=False) - lon_key = field.set_construct(aux_lon, axes=axes, copy=False) + from .latlon_utils import _create_2d_latlon_coordinates + lat_key, lon_key = _create_2d_latlon_coordinates( + f, cr, cr_latlon, cache=cache + ) coords_created = lat_key is not None - + # ------------------------------------------------------------ # Update the appropriate coordinate reference with any new # coordinate keys # ------------------------------------------------------------ if coords_created: - if latlon_cr is not None: - latlon_cr.set_coordinates((lat_key, lon_key)) + if cr_latlon is not None: + cr_latlon.set_coordinates((lat_key, lon_key)) else: cr.set_coordinates((lat_key, lon_key)) diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py new file mode 100644 index 0000000000..8ee77731ca --- /dev/null +++ b/cf/mixin/latlon_utils.py @@ -0,0 +1,391 @@ +"""2-d latitude/longitude coordinates functionality.""" + +import logging + +import numpy as np +from cfdm import is_log_level_info + +logger = logging.getLogger(__name__) + + +def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): + """Create 2-d latitude and longitude coordinates and bounds. + + When it is not possible to create latitude and longitude + coordinates, the reason why will be reported if the log level is + at ``2``/``'INFO'`` or higher. + + See CF Appendix F: Grid Mappings. + https://doi.org/10.5281/zenodo.14274886 + + .. versionadded:: NEXTVERSION + + :Parameters: + + f: `Field` or `Domain` + The Field or Domain containing the ??? grid, which will be + updated in-place. + + cr: `CoordinateReference` + The coordinate reference construct for the + non-latitude_longitude grid mapping. + + cr_latlon: `CoordinateReference` or `None` + The coordinate reference construct for the + latitude_longitude grid mapping, or `None` is there isn't + one. + + cache: `bool`, optional + If True (the default) then cache in memory the first and + last of any newly-created coordinates and bounds. This may + slightly slow down the coordinate creation process, but + may greatly speed up, and reduce the memory requirement + of, a future inspection of the coordinates and + bounds. Even when *cache* is True, new cached coordinate + values can only be created if the existing 1-d coordinates + themselves have cached first and last values. + + :Returns: + + (`str`, `str`) or (`None`, `None`) + The keys of the new 2-d latitude and longitude coordinate + constructs, in that order, or two `None`s if the 2-d + coordinates could not be created. + + """ + try: + import pyproj + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Must install the 'pyproj' library" + ) # pragma: no cover + + return (None, None) + + grid_mapping_name = cr.coordinate_conversion.get_parameter( + "grid_mapping_name", None + ) + if grid_mapping_name is None: + return (None, None) + + # ---------------------------------------------------------------- + # Get the source 1-d grid coordinates and axes + # ---------------------------------------------------------------- + one_d = _get_1d_coordinates(f, cr, grid_mapping_name) + if one_d is None: + return (None, None) + + # ---------------------------------------------------------------- + # Create the source grid mapping pyproj CRS + # ---------------------------------------------------------------- + match grid_mapping_name: + case "rotated_latitude_longitude": + proj_src = _rotated_latitude_longitude(cr) + case "healpix" | "reduced_gaussian": + raise ValueError( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}" + ) + case _: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}" + ) # pragma: no cover + + return (None, None) + + if proj_src is None: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates. " + f"Unable to create a pyproj.CRS object for {cr!r} from " + f"the grid mapping parameters: " + f"{cr.coordinate_conversion.parameters()!r}" + ) # pragma: no cover + + return (None, None) + + # ---------------------------------------------------------------- + # Create the target latitude_longitude pyproj CRS + # ---------------------------------------------------------------- + proj_latlon = _create_latitude_longitude_CRS(cr_latlon) + if proj_latlon is None: + return (None, None) + + # ---------------------------------------------------------------- + # Create the 2-d lat/lon coordinates from 1-d grid coordinates + # ---------------------------------------------------------------- + x = one_d["x"] + y = one_d["y"] + lon_2d_mesh, lat_2d_mesh = np.meshgrid(x.array, y.array) + + transformer = pyproj.Transformer.from_crs( + proj_src, proj_latlon, always_xy=True + ) + lon_2d, lat_2d = transformer.transform(lon_2d_mesh, lat_2d_mesh) + + # ---------------------------------------------------------------- + # Create the 2-d lat/lon bounds from 1-d grid coordinate bounds + # ---------------------------------------------------------------- + xb = x.get_bounds_data(None) + yb = y.get_bounds_data(None) + if xb is None and yb is None: + lat_2d_bounds = None + lon_2d_bounds = None + else: + xb = xb.array + yb = yb.array + xb = np.append(xb[:, 0], xb[-1, 1]) + yb = np.append(yb[:, 0], yb[-1, 1]) + + lon_2d_mesh, lat_2d_mesh = np.meshgrid(xb, yb) + + lon_2d_vertices, lat_2d_vertices = transformer.transform( + lon_2d_mesh, lat_2d_mesh + ) + + shape = (y.size, x.size, 4) + lat_2d_bounds = np.empty(shape, dtype=lat_2d.dtype) + lon_2d_bounds = np.empty(shape, dtype=lon_2d.dtype) + + lat_2d_bounds[..., 0] = lat_2d_vertices[:-1, :-1] + lon_2d_bounds[..., 0] = lon_2d_vertices[:-1, :-1] + + lat_2d_bounds[..., 1] = lat_2d_vertices[1:, :-1] + lon_2d_bounds[..., 1] = lon_2d_vertices[1:, :-1] + + lat_2d_bounds[..., 2] = lat_2d_vertices[1:, 1:] + lon_2d_bounds[..., 2] = lon_2d_vertices[1:, 1:] + + lat_2d_bounds[..., 3] = lat_2d_vertices[:-1, 1:] + lon_2d_bounds[..., 3] = lon_2d_vertices[:-1, 1:] + + lat_2d_bounds = f._Bounds(data=f._Data(lat_2d_bounds)) + lon_2d_bounds = f._Bounds(data=f._Data(lon_2d_bounds)) + + # ---------------------------------------------------------------- + # Add the 2-d lat/lon coordinates to the domain + # ---------------------------------------------------------------- + lat_2d = f._AuxiliaryCoordinate( + data=f._Data(lat_2d, "degrees_north"), + bounds=lat_2d_bounds, + properties={"standard_name": "latitude"}, + ) + lon_2d = f._AuxiliaryCoordinate( + data=f._Data(lon_2d, "degrees_east"), + bounds=lon_2d_bounds, + properties={"standard_name": "longitude"}, + ) + + axes = (one_d["axis_y"], one_d["axis_x"]) + + lat_key = f.set_construct(lat_2d, axes=axes, copy=False) + lon_key = f.set_construct(lon_2d, axes=axes, copy=False) + + return (lat_key, lon_key) + + +def _create_proj_CRS(kwargs, cr): + """Create a `pyproj.CRS` instance. + + .. versionadded:: NEXTVERSION + + :Parameters: + + kwargs: `dict` + A dictionary of keyword arguments for initialising the the + `pyproj.CRS` instance. + + cr: `CoordinateReference` + The coordinate reference construct from which *kwargs* was + derived. + + :Returns: + + `pyproj.CRS` or `None` + The created CRS, or `None` if one couldn't be created. + + """ + import pyproj + + # Remove `None` values + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + try: + proj = pyproj.CRS(**kwargs) + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad grid mapping parameters: " + f"{cr.coordinate_conversion.parameters()!r}" + ) # pragma: no cover + + return + + return proj + + +def _create_latitude_longitude_CRS(cr_latlon): + """Create a latitude_longitude `pyproj.CRS` instance. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr_latlon: `CoordinateReference` or `None` + The latitude_longitude coordinate reference construct from + which to create the CRS, or `None` if there isn't one. + + :Returns: + + `pyproj.CRS` or `None` + The created CRS, or `None` if one couldn't be created. + + """ + kwargs = {"proj": "longlat"} + + if cr_latlon is None: + kwargs["ellps"] = "sphere" + else: + parameters = cr_latlon.coordinate_conversion.parameters() + + if "earth_radius" in parameters: + kwargs["R"] = parameters.get("earth_radius") + elif "semi_major_axis" in parameters: + kwargs["a"] = parameters.get("semi_major_axis") + kwargs["rf"] = parameters.get("inverse_flattening") + kwargs["b"] = parameters.get("semi_minor_axis") + elif "reference_ellipsoid_name" in parameters: + kwargs["ellps"] = parameters.get("reference_ellipsoid_name") + else: + kwargs["ellps"] = "sphere" + + if "longitude_of_prime_meridian" in parameters: + kwargs["pm"] = parameters.get("longitude_of_prime_meridian", 0) + elif "prime_meridian_name" in parameters: + kwargs["pm"] = parameters.get("prime_meridian_name") + + return _create_proj_CRS(kwargs, cr_latlon) + + +def _get_1d_coordinates(f, cr, grid_mapping_name): + """Get 1-d coordinates and axes. + + .. versionadded:: NEXTVERSION + + :Parameters: + + f: `Field` or `Domain` + The Field or Domain containing the 1-d coordinates. + + cr: `CoordinateReference` + The coordinate reference construct that references the 1-d + coordinates. + + grid_mapping_name: `str` + The grid_mapping_name parameter of *cr*. + + :Returns: + + `dict` + + The 1-d coordinates and axes in the following dictionary + keys: + + * ``'x'``: The X coordinate construct + * ``'y'``: The Y coordinate construct + * ``'axis_x'``: The X domain axis construct key + * ``'axis_y'``: The Y domain axis construct key + + """ + match grid_mapping_name: + case "rotated_latitude_longitude": + identity_x = "grid_longitude" + identity_y = "grid_latitude" + case _: + identity_x = "projection_x_coordinate" + identity_y = "projection_y_coordinate" + + key_x, x = f.dimension_coordinate( + identity_x, item=True, default=(None, None) + ) + key_y, y = f.dimension_coordinate( + identity_y, item=True, default=(None, None) + ) + + if x is None and is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Missing 1-d {identity_x!r} dimension coordinates" + ) # pragma: no cover + return + + if y is None and is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Missing 1-d {identity_y!r} dimension coordinates" + ) # pragma: no cover + return + + return { + "x": x, + "y": y, + "axis_x": f.get_data_axes(key_x)[0], + "axis_y": f.get_data_axes(key_y)[0], + } + + +# ==================================================================== +# Functions for creating `pyproj.CRS` instances for each grid mapping +# +# These functions are called by `_create_2d_latlon_coordinates` +# ==================================================================== + +def _rotated_latitude_longitude(cr): + """Create a `pyproj.CRS` instance for a coordinate reference. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct that references the 1-d + coordinates. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + parameters = cr.coordinate_conversion.parameters() + + pole_lat = parameters.get("grid_north_pole_latitude") + pole_lon = parameters.get("grid_north_pole_longitude") + npgl = parameters.get("north_pole_grid_longitude", 0) + + try: + pole_lon = float(pole_lon) + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad grid mapping parameters: {parameters!r}" + ) # pragma: no cover + + return + + kwargs = { + "proj": "ob_tran", + "o_proj": "longlat", + "o_lon_p": npgl, + "o_lat_p": pole_lat, + "lon_0": pole_lon + 180, + } + proj = _create_proj_CRS(kwargs, cr) + + return proj diff --git a/cf/read_write/read.py b/cf/read_write/read.py index 6cf6411dbe..ac71ec8075 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -461,6 +461,7 @@ def __new__( file_type=None, group_dimension_search="closest_ancestor", filesystem=None, + legacy_um_backend=False, ): """Read field or domain constructs from a dataset.""" kwargs = locals() @@ -682,93 +683,65 @@ def _read(self, dataset): """ dataset_type = self.dataset_type + kwargs = self.kwargs + legacy_um_backend = bool(kwargs.get("legacy_um_backend")) + # ------------------------------------------------------------ # Try to read as a netCDF dataset # ------------------------------------------------------------ - super()._read(dataset) + if not legacy_um_backend: + super()._read(dataset) - if self.dataset_contents is not None: - # Successfully read the dataset - return + if self.dataset_contents is not None: + # Successfully read the dataset + return - # ------------------------------------------------------------ - # Try to read as a PP/UM dataset - # ------------------------------------------------------------ - if dataset_type is None or dataset_type.intersection( - self.UM_dataset_types - ): - if not hasattr(self, "um_read"): - # Initialise the UM read function - kwargs = self.kwargs - um_kwargs = { - key: kwargs[key] - for key in ( - "height_at_top_of_model", - "squeeze", - "unsqueeze", - "domain", - "dataset_type", - "unpack", - "verbose", - "filesystem", - "storage_options", + else: + # ------------------------------------------------------------ + # Try to read as a PP/UM dataset using the legacy UM backend + # ------------------------------------------------------------ + if dataset_type is None or dataset_type.intersection( + self.UM_dataset_types + ): + if not hasattr(self, "um_read"): + # Initialise the UM read function + kwargs = self.kwargs + um_kwargs = { + key: kwargs[key] + for key in ( + "height_at_top_of_model", + "squeeze", + "unsqueeze", + "domain", + "dataset_type", + "unpack", + "verbose", + "filesystem", + "storage_options", + ) + } + um_kwargs["set_standard_name"] = False + um_kwargs["select"] = self.select + um = self.um + um_kwargs["um_version"] = um.get("version") + um_kwargs["fmt"] = um.get("fmt") + um_kwargs["word_size"] = um.get("word_size") + um_kwargs["endian"] = um.get("endian") + + self.um_read = partial( + UMRead(self.implementation).read, **um_kwargs ) - } - um_kwargs["set_standard_name"] = False - um_kwargs["select"] = self.select - um = self.um - um_kwargs["um_version"] = um.get("version") - um_kwargs["fmt"] = um.get("fmt") - um_kwargs["word_size"] = um.get("word_size") - um_kwargs["endian"] = um.get("endian") - - self.um_read = partial( - UMRead(self.implementation).read, **um_kwargs - ) - - try: - # Try to read the dataset - self.dataset_contents = self.um_read(dataset) - except DatasetTypeError as error: - if dataset_type is None: - self.dataset_format_errors.append(error) - else: - # Successfully read the dataset - self.unique_dataset_categories.add("UM") + + try: + # Try to read the dataset + self.dataset_contents = self.um_read(dataset) + except DatasetTypeError as error: + if dataset_type is None: + self.dataset_format_errors.append(error) + else: + # Successfully read the dataset + self.unique_dataset_categories.add("UM") if self.dataset_contents is not None: # Successfully read the dataset return - - # ------------------------------------------------------------ - # Try to read as a GRIB dataset - # - # Not yet available. When (if!) the time comes, the framework - # will be: - # ------------------------------------------------------------ - # - # if dataset_type is None or dataset_type.intersection( - # self.GRIB_dataset_types - # ): - # if not hasattr(self, "grib_read"): - # # Initialise the GRIB read function - # kwargs = self.kwargs - # grib_kwargs = ... # - # - # self.grib_read = partial( - # GRIBRead(self.implementation).read, **grib_kwargs - # ) - # - # try: - # # Try to read the dataset - # self.dataset_contents = self.grib_read(dataset) - # except DatasetTypeError as error: - # if dataset_type is None: - # self.dataset_format_errors.append(error) - # else: - # # Successfully read the dataset - # self.unique_dataset_categories.add("GRIB") - # - # if self.dataset_contents is not None: - # # Successfully read the dataset - # return diff --git a/cf/read_write/um/umread.py b/cf/read_write/um/umread.py index 0f0fa56d48..9b1d24ea2b 100644 --- a/cf/read_write/um/umread.py +++ b/cf/read_write/um/umread.py @@ -2092,8 +2092,6 @@ def create_data(self): "byte_ordering": self.byte_ordering, "attributes": attributes, "unpack": self.unpack, - "storage_protocol": self.storage_protocol, - "storage_options": self.storage_options, } if len(recs) == 1: From 813f6d5ee699d1a336058b45470444a8c60726ec Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sat, 27 Jun 2026 15:00:44 +0100 Subject: [PATCH 03/43] dev --- cf/mixin/propertiesdata.py | 10 ++--- cf/read_write/read.py | 88 +++++++++++++++++++++++++++----------- cf/read_write/um/umread.py | 2 + 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/cf/mixin/propertiesdata.py b/cf/mixin/propertiesdata.py index 1c98d038cb..bbfd687a74 100644 --- a/cf/mixin/propertiesdata.py +++ b/cf/mixin/propertiesdata.py @@ -4587,8 +4587,8 @@ def identity( By default the identity is the first found of the following: - * The "standard_name" property. * The "id" attribute, preceded by ``'id%'``. + * The "standard_name" property. * The "cf_role" property, preceded by ``'cf_role='``. * The "axis" property, preceded by ``'axis='``. * The "long_name" property, preceded by ``'long_name='``. @@ -4671,14 +4671,14 @@ def identity( return default - n = self.get_property("standard_name", None) - if n is not None: - return str(n) - n = getattr(self, "id", None) if n is not None: return f"id%{n}" + n = self.get_property("standard_name", None) + if n is not None: + return str(n) + if relaxed: if strict: raise ValueError( diff --git a/cf/read_write/read.py b/cf/read_write/read.py index ac71ec8075..44a7d2aa05 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -249,6 +249,16 @@ class read(cfdm.read): .. versionadded:: 1.5 + legacy_um_backend: `bool`, optional + If True then read datasets with the legacy UM backend that + is embedded within the {{package}} library. This backend + was the only backend available prior to version + vNEXTVERSION. From vNEXTVERSION onwards, the UM backend + provided by `xnetcdf` is used when *legacy_um_backend* is + False (the default). + + .. versionadded:: NEXTVERSION + aggregate: `bool` or `dict`, optional If True (the default) or a dictionary (possibly empty) then aggregate the field constructs read in from all input @@ -556,18 +566,18 @@ def _finalise(self): `None` """ - # Whether or not there were only netCDF datasets - only_netCDF = self.unique_dataset_categories == set(("netCDF",)) - - # Whether or not there were any UM datasets - some_UM = "UM" in self.unique_dataset_categories + ## Whether or not there were only netCDF datasets + #only_netCDF = self.unique_dataset_categories == set(("netCDF",)) + # + ## Whether or not there were any UM datasets + #some_UM = "UM" in self.unique_dataset_categories # ---------------------------------------------------------------- # Select matching constructs from netCDF datasets (before # aggregation) # ---------------------------------------------------------------- select = self.select - if select and only_netCDF: + if select: # and only_netCDF: self.constructs = self.constructs.select_by_identity(*select) # ---------------------------------------------------------------- @@ -575,30 +585,47 @@ def _finalise(self): # ---------------------------------------------------------------- if self.aggregate and len(self.constructs) > 1: aggregate_options = self.aggregate_options - # Set defaults specific to UM fields - if some_UM and "strict_units" not in aggregate_options: - aggregate_options["relaxed_units"] = True + # Set aggregate options for UM fields + UM = False + for f in self.constructs: + um_identity = f.get_property("um_identity",None): + if um_identity is None: + continue + + try: + if um_identity.startswith("UM_"): + UM = True + break + except AttributeError: + pass + + if UM: + aggregate_options["field_identity"] = "long_name" + aggregate_options["equal"] = ("um_identity",) TODO + if "strict_units" not in aggregate_options: + aggregate_options["relaxed_units"] = True + self.constructs = cf_aggregate( self.constructs, **aggregate_options ) - # ---------------------------------------------------------------- - # Add standard names to non-netCDF fields (after aggregation) - # ---------------------------------------------------------------- - if not only_netCDF: - for f in self.constructs: - standard_name = f._custom.get("standard_name", None) - if standard_name is not None: - f.set_property("standard_name", standard_name, copy=False) - del f._custom["standard_name"] - - # ---------------------------------------------------------------- - # Select matching constructs from non-netCDF files (after - # setting their standard names) - # ---------------------------------------------------------------- - if select and not only_netCDF: - self.constructs = self.constructs.select_by_identity(*select) +# # ---------------------------------------------------------------- +# # Add standard names to non-netCDF fields (after aggregation) +# # ---------------------------------------------------------------- +# if not only_netCDF: +# for f in self.constructs: +# standard_name = f._custom.get("standard_name", None) +# if standard_name is not None: +# f.set_property("standard_name", standard_name, copy=False) +# del f._custom["standard_name"] +# +# # ---------------------------------------------------------------- +# # Select matching constructs from non-netCDF files (after +# # setting their standard names) +# # ---------------------------------------------------------------- +# if select and not only_netCDF: +# self.constructs = self.constructs.select_by_identity(*select) super()._finalise() @@ -698,8 +725,17 @@ def _read(self, dataset): else: # ------------------------------------------------------------ - # Try to read as a PP/UM dataset using the legacy UM backend + # Read as a PP/UM dataset using the legacy UM backend # ------------------------------------------------------------ + logger.warning( + "The 'legacy_um_backend' parameter will be removed in some " + "release after vNEXTVERSION, at which time only the UM " + "backend provided by `ppfive` will be available. " + "If there are questions about the parsing of UM datasets, " + "please raise an issue at " + "https://github.com/NCAS-CMS/ppfive/issues" + ) + if dataset_type is None or dataset_type.intersection( self.UM_dataset_types ): diff --git a/cf/read_write/um/umread.py b/cf/read_write/um/umread.py index 9b1d24ea2b..f49c99bbe2 100644 --- a/cf/read_write/um/umread.py +++ b/cf/read_write/um/umread.py @@ -948,6 +948,8 @@ def __init__( if um_condition: identity += f"_{um_condition}" + cf_properties["um_identity"] = identity + if long_name is None: cf_properties["long_name"] = identity From f35b817706d43ae5a275c4d025c021c561c71fcf Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sat, 27 Jun 2026 20:46:04 +0100 Subject: [PATCH 04/43] dev --- cf/aggregate.py | 9 ++++++++- cf/read_write/read.py | 28 ++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cf/aggregate.py b/cf/aggregate.py index 149be58d67..c42da8b5be 100644 --- a/cf/aggregate.py +++ b/cf/aggregate.py @@ -2911,6 +2911,7 @@ def aggregate( for signature in signatures: # sorted(signatures): meta = signatures[signature] + print(len(meta)) # Print useful information meta[0].print_info() @@ -3041,8 +3042,9 @@ def aggregate( # Record the names of the axes that are actually aggregated axes_aggregated = [] - + print(2222, aggregating_axes) for axis in aggregating_axes: + print(axis) number_of_fields = len(meta) if number_of_fields == 1: break @@ -3085,6 +3087,7 @@ def aggregate( if len(m) == 1: continue + print ('here 1') # ---------------------------------------------------- # Still here? The sort the fields in place by the # canonical first values of their 1-d coordinates for @@ -3107,8 +3110,10 @@ def aggregate( ) unaggregatable = True + print('unaggregatable 1') break + print ('here 2') # ---------------------------------------------------- # Still here? Then pass through the fields # ---------------------------------------------------- @@ -3193,6 +3198,7 @@ def aggregate( break if not unaggregatable: + print('here 3') # ------------------------------------------------- # The aggregation along this axis was successful # for this sub-group, so concatenate all of the @@ -3215,6 +3221,7 @@ def aggregate( # 0.00035, 0.0012, 0.013, 0.064 # ------------------------------------------------ field = m0.field + print(field) field_arrays = data_concatenation.pop("field") if field_arrays: # Concatenate the field data diff --git a/cf/read_write/read.py b/cf/read_write/read.py index 44a7d2aa05..dbc0de6813 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -586,10 +586,14 @@ def _finalise(self): if self.aggregate and len(self.constructs) > 1: aggregate_options = self.aggregate_options - # Set aggregate options for UM fields + # Find out if there is at least one field created from UM + # data UM = False for f in self.constructs: - um_identity = f.get_property("um_identity",None): + if not f.has_property("long_name"): + continue + + um_identity = f.get_property("um_identity",None) if um_identity is None: continue @@ -600,12 +604,28 @@ def _finalise(self): except AttributeError: pass + # Set aggregate options wh there is at least one field + # created from UM data if UM: aggregate_options["field_identity"] = "long_name" - aggregate_options["equal"] = ("um_identity",) TODO + + equal = aggregate_options.get("equal") + if equal is None: + equal = ["um_identity"] + else: + if isintance(equal, str): + equal = [equal, "um_identity"] + else: + equal = list(equal) + equal.append("um_identity") + + aggregate_options["equal"] = equal + if "strict_units" not in aggregate_options: aggregate_options["relaxed_units"] = True - + + + print(aggregate_options) self.constructs = cf_aggregate( self.constructs, **aggregate_options ) From 77db7dc2c38954e1c9bb6a7491d6ad65681e0414 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sun, 28 Jun 2026 10:36:18 +0100 Subject: [PATCH 05/43] dev --- cf/read_write/read.py | 106 +++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 48 deletions(-) diff --git a/cf/read_write/read.py b/cf/read_write/read.py index dbc0de6813..af3a662609 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -251,12 +251,19 @@ class read(cfdm.read): legacy_um_backend: `bool`, optional If True then read datasets with the legacy UM backend that - is embedded within the {{package}} library. This backend - was the only backend available prior to version - vNEXTVERSION. From vNEXTVERSION onwards, the UM backend + is embedded within the cf library, which was the only + backend available prior to version NEXTVERSION. From + version NEXTVERSION onwards, the `ppfive` UM backend provided by `xnetcdf` is used when *legacy_um_backend* is False (the default). + .. note:: The *legacy_um_backend* parameter will + eventually be removed, at which time only the + `ppfive` UM backend provided by `xnetcdf` will + be available. If there are questions about the + parsing of UM datasets, please raise an issue at + https://github.com/NCAS-CMS/ppfive/issues. + .. versionadded:: NEXTVERSION aggregate: `bool` or `dict`, optional @@ -566,47 +573,68 @@ def _finalise(self): `None` """ - ## Whether or not there were only netCDF datasets - #only_netCDF = self.unique_dataset_categories == set(("netCDF",)) - # - ## Whether or not there were any UM datasets - #some_UM = "UM" in self.unique_dataset_categories - # ---------------------------------------------------------------- # Select matching constructs from netCDF datasets (before # aggregation) # ---------------------------------------------------------------- select = self.select - if select: # and only_netCDF: + if select: self.constructs = self.constructs.select_by_identity(*select) # ---------------------------------------------------------------- # Aggregate the output fields or domains # ---------------------------------------------------------------- - if self.aggregate and len(self.constructs) > 1: - aggregate_options = self.aggregate_options - - # Find out if there is at least one field created from UM - # data - UM = False + self.aggregate = self.aggregate and len(self.constructs) > 1 + if self.aggregate: + UM = False # True if there is at least one UM field + non_UM = False # True if there is at least one non-UM field for f in self.constructs: - if not f.has_property("long_name"): - continue - + if UM and non_UM: + break + um_identity = f.get_property("um_identity",None) if um_identity is None: + non_UM = True continue try: - if um_identity.startswith("UM_"): - UM = True - break + if not um_identity.startswith("UM_"): + non_UM = True + continue except AttributeError: - pass + non_UM = True + continue + + if not f.has_property("long_name"): + non_UM = True + continue + + UM = True + + if UM and non_UM: + self.aggregate = False + logger.warning( + "Not aggregating fields from a mixture of UM and " + "non-UM sources (a field from a UM source has a " + "a long_name property; and a string-valued um_identity " + "property that starts with 'UM_'). Aggregation may still " + "be possible with cf.aggregate." + ) + + if self.aggregate: + aggregate_options = self.aggregate_options - # Set aggregate options wh there is at least one field - # created from UM data if UM: + # Set extra aggregate options for fields created from + # UM data. + # + # We can't trust the the standard_name to provide the + # identity for UM fields (multiple STASH codes can + # have the same standard name), so instead we have to + # use the long_name (which in general is taken from + # the STASHmaster name) and the um_identity (which + # encapsulates the submodel, stash/field code and UM + # version). aggregate_options["field_identity"] = "long_name" equal = aggregate_options.get("equal") @@ -618,35 +646,17 @@ def _finalise(self): else: equal = list(equal) equal.append("um_identity") - + aggregate_options["equal"] = equal if "strict_units" not in aggregate_options: aggregate_options["relaxed_units"] = True - - print(aggregate_options) + # Do the aggregation self.constructs = cf_aggregate( self.constructs, **aggregate_options ) -# # ---------------------------------------------------------------- -# # Add standard names to non-netCDF fields (after aggregation) -# # ---------------------------------------------------------------- -# if not only_netCDF: -# for f in self.constructs: -# standard_name = f._custom.get("standard_name", None) -# if standard_name is not None: -# f.set_property("standard_name", standard_name, copy=False) -# del f._custom["standard_name"] -# -# # ---------------------------------------------------------------- -# # Select matching constructs from non-netCDF files (after -# # setting their standard names) -# # ---------------------------------------------------------------- -# if select and not only_netCDF: -# self.constructs = self.constructs.select_by_identity(*select) - super()._finalise() def _initialise(self): @@ -748,9 +758,9 @@ def _read(self, dataset): # Read as a PP/UM dataset using the legacy UM backend # ------------------------------------------------------------ logger.warning( - "The 'legacy_um_backend' parameter will be removed in some " - "release after vNEXTVERSION, at which time only the UM " - "backend provided by `ppfive` will be available. " + "The 'legacy_um_backend' parameter will eventually be " + "removed, at which time only the `ppfive` UM backend " + "provided by `ppfive` will be available. " "If there are questions about the parsing of UM datasets, " "please raise an issue at " "https://github.com/NCAS-CMS/ppfive/issues" From fe45daab6a41562fa6d8ee6c613fe5fa775bcec1 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Sun, 28 Jun 2026 18:05:02 +0100 Subject: [PATCH 06/43] dev --- cf/aggregate.py | 9 +---- cf/cfimplementation.py | 10 ------ cf/data/array/xnetcdfarray.py | 16 +++++++++ cf/data/collapse/collapse_active.py | 7 +++- cf/data/fragment/fragmentfilearray.py | 2 +- cf/mixin/latlon_utils.py | 1 + cf/read_write/read.py | 52 +++++++++------------------ 7 files changed, 42 insertions(+), 55 deletions(-) create mode 100644 cf/data/array/xnetcdfarray.py diff --git a/cf/aggregate.py b/cf/aggregate.py index c42da8b5be..149be58d67 100644 --- a/cf/aggregate.py +++ b/cf/aggregate.py @@ -2911,7 +2911,6 @@ def aggregate( for signature in signatures: # sorted(signatures): meta = signatures[signature] - print(len(meta)) # Print useful information meta[0].print_info() @@ -3042,9 +3041,8 @@ def aggregate( # Record the names of the axes that are actually aggregated axes_aggregated = [] - print(2222, aggregating_axes) + for axis in aggregating_axes: - print(axis) number_of_fields = len(meta) if number_of_fields == 1: break @@ -3087,7 +3085,6 @@ def aggregate( if len(m) == 1: continue - print ('here 1') # ---------------------------------------------------- # Still here? The sort the fields in place by the # canonical first values of their 1-d coordinates for @@ -3110,10 +3107,8 @@ def aggregate( ) unaggregatable = True - print('unaggregatable 1') break - print ('here 2') # ---------------------------------------------------- # Still here? Then pass through the fields # ---------------------------------------------------- @@ -3198,7 +3193,6 @@ def aggregate( break if not unaggregatable: - print('here 3') # ------------------------------------------------- # The aggregation along this axis was successful # for this sub-group, so concatenate all of the @@ -3221,7 +3215,6 @@ def aggregate( # 0.00035, 0.0012, 0.013, 0.064 # ------------------------------------------------ field = m0.field - print(field) field_arrays = data_concatenation.pop("field") if field_arrays: # Concatenate the field data diff --git a/cf/cfimplementation.py b/cf/cfimplementation.py index 2132608555..a178f444b0 100644 --- a/cf/cfimplementation.py +++ b/cf/cfimplementation.py @@ -32,17 +32,12 @@ BoundsFromNodesArray, CellConnectivityArray, GatheredArray, - H5netcdfArray, - NetCDF4Array, PointTopologyArray, - PyfiveArray, RaggedContiguousArray, RaggedIndexedArray, RaggedIndexedContiguousArray, - ScipyNetcdfFileArray, SubsampledArray, XnetcdfArray, - ZarrArray, ) from .functions import CF @@ -148,11 +143,7 @@ def set_construct(self, parent, construct, axes=None, copy=True, **kwargs): BoundsFromNodesArray=BoundsFromNodesArray, CellConnectivityArray=CellConnectivityArray, GatheredArray=GatheredArray, - H5netcdfArray=H5netcdfArray, - NetCDF4Array=NetCDF4Array, - ScipyNetcdfFileArray=ScipyNetcdfFileArray, PointTopologyArray=PointTopologyArray, - PyfiveArray=PyfiveArray, Quantization=Quantization, RaggedContiguousArray=RaggedContiguousArray, RaggedIndexedArray=RaggedIndexedArray, @@ -160,7 +151,6 @@ def set_construct(self, parent, construct, axes=None, copy=True, **kwargs): SubsampledArray=SubsampledArray, TiePointIndex=TiePointIndex, XnetcdfArray=XnetcdfArray, - ZarrArray=ZarrArray, ) diff --git a/cf/data/array/xnetcdfarray.py b/cf/data/array/xnetcdfarray.py new file mode 100644 index 0000000000..ee4b40019d --- /dev/null +++ b/cf/data/array/xnetcdfarray.py @@ -0,0 +1,16 @@ +import cfdm + +from ...mixin_container import Container +from .mixin import ActiveStorageMixin + + +class XnetcdfArray( + ActiveStorageMixin, + Container, + cfdm.XnetcdfArray, +): + """A netCDF array accessed with `xnetcdf`. + + .. versionadded:: NEXTVERSION + + """ diff --git a/cf/data/collapse/collapse_active.py b/cf/data/collapse/collapse_active.py index 79fae066f6..ff8763c108 100644 --- a/cf/data/collapse/collapse_active.py +++ b/cf/data/collapse/collapse_active.py @@ -193,10 +193,15 @@ def active_chunk_function(method, *args, **kwargs): address = None dataset = x.get_variable(None) if dataset is None: - # Dateaset is a string, not a variable object. + # Dataset is a string, not a variable object. storage_options = x.get_storage_options() address = x.get_address() dataset = x.get_filename() + else: + if dataset.backend_api not in "pyfive": + return + x + dataset = dataset.backend_accessor active_kwargs = { "dataset": dataset, diff --git a/cf/data/fragment/fragmentfilearray.py b/cf/data/fragment/fragmentfilearray.py index 72d685ba8d..8a0ca31353 100644 --- a/cf/data/fragment/fragmentfilearray.py +++ b/cf/data/fragment/fragmentfilearray.py @@ -12,7 +12,7 @@ class FragmentFileArray( .. versionadded:: 3.17.0 """ - + TODO - fragmentxnetcdfarray and activestorage def __new__(cls, *args, **kwargs): """Store fragment classes. diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py index 8ee77731ca..1301a5a1e3 100644 --- a/cf/mixin/latlon_utils.py +++ b/cf/mixin/latlon_utils.py @@ -345,6 +345,7 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): # These functions are called by `_create_2d_latlon_coordinates` # ==================================================================== + def _rotated_latitude_longitude(cr): """Create a `pyproj.CRS` instance for a coordinate reference. diff --git a/cf/read_write/read.py b/cf/read_write/read.py index af3a662609..1f18c6b4c5 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -250,20 +250,20 @@ class read(cfdm.read): .. versionadded:: 1.5 legacy_um_backend: `bool`, optional - If True then read datasets with the legacy UM backend that - is embedded within the cf library, which was the only + If True then read the datasets with the legacy UM backend + that is embedded within the cf library, which was the only backend available prior to version NEXTVERSION. From version NEXTVERSION onwards, the `ppfive` UM backend provided by `xnetcdf` is used when *legacy_um_backend* is False (the default). - + .. note:: The *legacy_um_backend* parameter will eventually be removed, at which time only the `ppfive` UM backend provided by `xnetcdf` will be available. If there are questions about the parsing of UM datasets, please raise an issue at https://github.com/NCAS-CMS/ppfive/issues. - + .. versionadded:: NEXTVERSION aggregate: `bool` or `dict`, optional @@ -592,11 +592,11 @@ def _finalise(self): if UM and non_UM: break - um_identity = f.get_property("um_identity",None) + um_identity = f.get_property("um_identity", None) if um_identity is None: non_UM = True continue - + try: if not um_identity.startswith("UM_"): non_UM = True @@ -605,22 +605,18 @@ def _finalise(self): non_UM = True continue - if not f.has_property("long_name"): - non_UM = True - continue - UM = True - + if UM and non_UM: self.aggregate = False logger.warning( "Not aggregating fields from a mixture of UM and " - "non-UM sources (a field from a UM source has a " - "a long_name property; and a string-valued um_identity " - "property that starts with 'UM_'). Aggregation may still " - "be possible with cf.aggregate." - ) - + "non-UM sources (a field from a UM source has " + "a string-valued um_identity property that starts " + "with 'UM_'). Aggregation may still be possible with " + "cf.aggregate." + ) + if self.aggregate: aggregate_options = self.aggregate_options @@ -631,23 +627,9 @@ def _finalise(self): # We can't trust the the standard_name to provide the # identity for UM fields (multiple STASH codes can # have the same standard name), so instead we have to - # use the long_name (which in general is taken from - # the STASHmaster name) and the um_identity (which - # encapsulates the submodel, stash/field code and UM - # version). - aggregate_options["field_identity"] = "long_name" - - equal = aggregate_options.get("equal") - if equal is None: - equal = ["um_identity"] - else: - if isintance(equal, str): - equal = [equal, "um_identity"] - else: - equal = list(equal) - equal.append("um_identity") - - aggregate_options["equal"] = equal + # the um_identity propery (which encapsulates the + # submodel, stash/field code and UM version). + aggregate_options["field_identity"] = "um_identity" if "strict_units" not in aggregate_options: aggregate_options["relaxed_units"] = True @@ -765,7 +747,7 @@ def _read(self, dataset): "please raise an issue at " "https://github.com/NCAS-CMS/ppfive/issues" ) - + if dataset_type is None or dataset_type.intersection( self.UM_dataset_types ): From ed250fed37a462bac6d95047c291d7556b399760 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 1 Jul 2026 11:10:43 +0100 Subject: [PATCH 07/43] dev --- cf/data/fragment/fragmentfilearray.py | 16 --- cf/mixin/latlon_utils.py | 175 +++++++++++++++++++++----- 2 files changed, 141 insertions(+), 50 deletions(-) diff --git a/cf/data/fragment/fragmentfilearray.py b/cf/data/fragment/fragmentfilearray.py index 8a0ca31353..53078fb03d 100644 --- a/cf/data/fragment/fragmentfilearray.py +++ b/cf/data/fragment/fragmentfilearray.py @@ -12,19 +12,3 @@ class FragmentFileArray( .. versionadded:: 3.17.0 """ - TODO - fragmentxnetcdfarray and activestorage - def __new__(cls, *args, **kwargs): - """Store fragment classes. - - .. versionadded:: 3.17.0 - - """ - # Import fragment classes. Do this here (as opposed to outside - # the class) to aid subclassing. - from .fragmentumarray import FragmentUMArray - - instance = super().__new__(cls) - instance._FragmentArrays = instance._FragmentArrays + ( - FragmentUMArray, - ) - return instance diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py index 1301a5a1e3..77e0fd309e 100644 --- a/cf/mixin/latlon_utils.py +++ b/cf/mixin/latlon_utils.py @@ -211,6 +211,10 @@ def _create_proj_CRS(kwargs, cr): """ import pyproj + # Create the pyproj.CRS keywword arguments, which include + # parameters for describing the ellipsoid + kwargs = _get_ellipsoid_parameters(cr) | kwargs + # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} @@ -220,14 +224,39 @@ def _create_proj_CRS(kwargs, cr): if is_log_level_info(logger): logger.info( "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad grid mapping parameters: " - f"{cr.coordinate_conversion.parameters()!r}" + f"for {cr!r}: Bad pyproj.CRS parameters: {kwargs}" ) # pragma: no cover - + return return proj +def _get_ellipsoid_parameters(cr): + """TODO""" + kwargs = {} + if cr is None: + return kwargs + + parameters = cr_latlon.coordinate_conversion.parameters() + + if "earth_radius" in parameters: + kwargs["R"] = parameters.get("earth_radius") + elif "semi_major_axis" in parameters: + kwargs["a"] = parameters.get("semi_major_axis") + kwargs["rf"] = parameters.get("inverse_flattening") + kwargs["b"] = parameters.get("semi_minor_axis") + elif "reference_ellipsoid_name" in parameters: + kwargs["ellps"] = parameters.get("reference_ellipsoid_name") + else: + kwargs["ellps"] = "sphere" + + if "longitude_of_prime_meridian" in parameters: + kwargs["pm"] = parameters.get("longitude_of_prime_meridian", 0) + elif "prime_meridian_name" in parameters: + kwargs["pm"] = parameters.get("prime_meridian_name") + + return kwargs + def _create_latitude_longitude_CRS(cr_latlon): """Create a latitude_longitude `pyproj.CRS` instance. @@ -247,27 +276,8 @@ def _create_latitude_longitude_CRS(cr_latlon): """ kwargs = {"proj": "longlat"} - if cr_latlon is None: kwargs["ellps"] = "sphere" - else: - parameters = cr_latlon.coordinate_conversion.parameters() - - if "earth_radius" in parameters: - kwargs["R"] = parameters.get("earth_radius") - elif "semi_major_axis" in parameters: - kwargs["a"] = parameters.get("semi_major_axis") - kwargs["rf"] = parameters.get("inverse_flattening") - kwargs["b"] = parameters.get("semi_minor_axis") - elif "reference_ellipsoid_name" in parameters: - kwargs["ellps"] = parameters.get("reference_ellipsoid_name") - else: - kwargs["ellps"] = "sphere" - - if "longitude_of_prime_meridian" in parameters: - kwargs["pm"] = parameters.get("longitude_of_prime_meridian", 0) - elif "prime_meridian_name" in parameters: - kwargs["pm"] = parameters.get("prime_meridian_name") return _create_proj_CRS(kwargs, cr_latlon) @@ -347,35 +357,32 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): def _rotated_latitude_longitude(cr): - """Create a `pyproj.CRS` instance for a coordinate reference. + """Create a rotated_latitude_longitude `pyproj.CRS` instance. .. versionadded:: NEXTVERSION :Parameters: cr: `CoordinateReference` - The coordinate reference construct that references the 1-d - coordinates. - + The coordinate reference construct. + :Returns: `pyproj.CRS` The created CRS, or `None` if one couldn't be created. """ - parameters = cr.coordinate_conversion.parameters() - - pole_lat = parameters.get("grid_north_pole_latitude") - pole_lon = parameters.get("grid_north_pole_longitude") - npgl = parameters.get("north_pole_grid_longitude", 0) + p = cr.coordinate_conversion.parameters() + pole_lon = p.get("grid_north_pole_longitude") try: pole_lon = float(pole_lon) except Exception: if is_log_level_info(logger): logger.info( "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad grid mapping parameters: {parameters!r}" + f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " + f"{pole_lon!r}" ) # pragma: no cover return @@ -383,10 +390,110 @@ def _rotated_latitude_longitude(cr): kwargs = { "proj": "ob_tran", "o_proj": "longlat", - "o_lon_p": npgl, - "o_lat_p": pole_lat, + "o_lon_p": p.get("north_pole_grid_longitude", 0), + "o_lat_p": p.get("grid_north_pole_latitude"), "lon_0": pole_lon + 180, } proj = _create_proj_CRS(kwargs, cr) return proj + +def _transverse_mercator(cr): + """Create a transerve_mercator `pyproj.CRS` instance. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + + kwargs = { + "proj": "tmerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "k_0": p.get("scale_factor_at_central_meridian"), + "x_0": p.get("false_easting"), + "y_0": p.get("false_northing"), + } + + return _create_proj_CRS(kwargs, cr) + +#--------------- + + +def _albers_equal_area(cr): + """Create a albers_equal_area `pyproj.CRS` instance. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting"), + "y_0": p.get("false_northing"), + } + + standard_parallel = p.get("standard_parallel") + try: + lat_1 = standard_parallel[0] + except Exception: + lat_1 = standard_parallel + else: + + kwargs = { + "lat_1": p.get("standard_parallel")[0] if isinstance(p.get("standard_parallel"), (list, tuple)) else p.get("standard_parallel"), + "lat_2": p.get("standard_parallel")[1] if isinstance(p.get("standard_parallel"), (list, tuple)) and len(p.get("standard_parallel")) > 1 else None, + } + + return _create_proj_CRS(kwargs) + + +def _azimuthal_equidistant(cr): + """Create a `pyproj.CRS` instance for Azimuthal Equidistant.""" + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aeqd", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting"), + "y_0": p.get("false_northing"), + } + kwargs.update(_extract_datum_parameters(cr)) + return _create_proj_CRS({k: v for k, v in kwargs.items() if v is not None}, cr) + + +def _geostationary(cr): + """Create a `pyproj.CRS` instance for Geostationary Satellite.""" + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "geos", + "h": p.get("perspective_point_height"), + "lon_0": p.get("longitude_of_projection_origin"), + "sweep": p.get("sweep_angle_axis"), + "x_0": p.get("false_easting"), + "y_0": p.get("false_northing"), + } + kwargs.update(_extract_datum_parameters(cr)) + return _create_proj_CRS({k: v for k, v in kwargs.items() if v is not None}, cr) From 9a0eca0a373d22e9b8ebe2c5c9f1d9ee8ad3346c Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 1 Jul 2026 19:01:11 +0100 Subject: [PATCH 08/43] dev --- cf/mixin/latlon_utils.py | 1146 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 1087 insertions(+), 59 deletions(-) diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py index 77e0fd309e..a61ac8a2cf 100644 --- a/cf/mixin/latlon_utils.py +++ b/cf/mixin/latlon_utils.py @@ -81,13 +81,36 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # Create the source grid mapping pyproj CRS # ---------------------------------------------------------------- match grid_mapping_name: + case "albers_equal_area": + proj_src = _albers_equal_area(cr) + case "azimuthal_equidistant": + proj_src = _azimuthal_equidistant(cr) + case "geostationary": + proj_src = _geostationary(cr) + case "lambert_azimuthal_equal_area": + proj_src = _lambert_azimuthal_equal_area(cr) + case "lambert_conformal_conic": + proj_src = _lambert_conformal_conic(cr) + case "lambert_cylindrical_equal_area": + proj_src = _lambert_cylindrical_equal_area(cr) + case "mercator": + proj_src = _mercator(cr) + case "oblique_mercator": + proj_src = _oblique_mercator(cr) + case "orthographic": + proj_src = _orthographic(cr) + case "polar_stereographic": + proj_src = _polar_stereographic(cr) case "rotated_latitude_longitude": proj_src = _rotated_latitude_longitude(cr) - case "healpix" | "reduced_gaussian": - raise ValueError( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}" - ) + case "sinusoidal": + proj_src = _sinusoidal(cr) + case "stereographic": + proj_src = _stereographic(cr) + case "transverse_mercator": + proj_src = _transverse_mercator(cr) + case "vertical_perspective": + proj_src = _vertical_perspective(cr) case _: if is_log_level_info(logger): logger.info( @@ -111,7 +134,7 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- # Create the target latitude_longitude pyproj CRS # ---------------------------------------------------------------- - proj_latlon = _create_latitude_longitude_CRS(cr_latlon) + proj_latlon = _latitude_longitude(cr_latlon) if proj_latlon is None: return (None, None) @@ -355,9 +378,10 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): # These functions are called by `_create_2d_latlon_coordinates` # ==================================================================== +def _albers_equal_area(cr): + """Create an azimuthal_equidistant CRS. -def _rotated_latitude_longitude(cr): - """Create a rotated_latitude_longitude `pyproj.CRS` instance. + https://proj.org/en/stable/operations/projections/aea.html .. versionadded:: NEXTVERSION @@ -373,41 +397,74 @@ def _rotated_latitude_longitude(cr): """ p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } - pole_lon = p.get("grid_north_pole_longitude") + lat_2 = None + standard_parallel = p.get("standard_parallel") try: - pole_lon = float(pole_lon) + lat_1 = standard_parallel[0] except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " - f"{pole_lon!r}" - ) # pragma: no cover + lat_1 = standard_parallel + else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass - return + kwargs['lat_1'] = lat_1 + kwargs['lat_2'] = lat_2 + + return _create_proj_CRS(kwargs, cr) + + +def _azimuthal_equidistant(cr): + """Create an azimuthal_equidistant CRS. + + https://proj.org/en/stable/operations/projections/aeqd.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + """ + p = cr.coordinate_conversion.parameters() kwargs = { - "proj": "ob_tran", - "o_proj": "longlat", - "o_lon_p": p.get("north_pole_grid_longitude", 0), - "o_lat_p": p.get("grid_north_pole_latitude"), - "lon_0": pole_lon + 180, + "proj": "aeqd", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } - proj = _create_proj_CRS(kwargs, cr) - return proj + return _create_proj_CRS(kwargs, cr) -def _transverse_mercator(cr): - """Create a transerve_mercator `pyproj.CRS` instance. +def _geostationary(cr): + """Create a geostationary CRS. + + https://proj.org/en/stable/operations/projections/geos.html + .. versionadded:: NEXTVERSION :Parameters: cr: `CoordinateReference` The coordinate reference construct. - + :Returns: `pyproj.CRS` @@ -415,23 +472,78 @@ def _transverse_mercator(cr): """ p = cr.coordinate_conversion.parameters() + kwargs = + "proj": "geos", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + sweep_angle_axis = p.get("sweep_angle_axis") + fixed_angle_axis = p.get("fixed_angle_axis") + match sweep_angle_axis: + case 'x': + ok = fixed_angle_axis in (None, 'y') + case 'y': + ok = fixed_angle_axis in (None, 'x') + case None: + ok = True + if fixed_angle_axis == "x": + sweep_angle_axis = "y" + elif fixed_angle_axis == "y": + sweep_angle_axis = "x" + else: + ok = False + case _: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " + f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " + f"parameter: {fixed_angle_axis!r}" + ) # pragma: no cover + + kwargs["sweep"] = sweep_angle_axis + + return _create_proj_CRS(kwargs, cr) + +def _lambert_azimuthal_equal_area(cr): + """Create a lambert_azimuthal_equal_area CRS. + + https://proj.org/en/stable/operations/projections/laea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + """ + p = cr.coordinate_conversion.parameters() kwargs = { - "proj": "tmerc", + "proj": "laea", "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "k_0": p.get("scale_factor_at_central_meridian"), - "x_0": p.get("false_easting"), - "y_0": p.get("false_northing"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } - return _create_proj_CRS(kwargs, cr) -#--------------- +def _lambert_conformal_conic(cr): + """Create a lambert_conformal_conic CRS. -def _albers_equal_area(cr): - """Create a albers_equal_area `pyproj.CRS` instance. + https://proj.org/en/stable/operations/projections/lcc.html .. versionadded:: NEXTVERSION @@ -448,52 +560,968 @@ def _albers_equal_area(cr): """ p = cr.coordinate_conversion.parameters() kwargs = { - "proj": "aea", + "proj": "lcc", "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "x_0": p.get("false_easting"), - "y_0": p.get("false_northing"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } + lat_2 = None standard_parallel = p.get("standard_parallel") try: lat_1 = standard_parallel[0] except Exception: lat_1 = standard_parallel else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass + + kwargs['lat_1'] = lat_1 + kwargs['lat_2'] = lat_2 + + return _create_proj_CRS(kwargs, cr) + +def _lambert_cylindrical_equal_area(cr): + """Create a lambert_cylindrical_equal_area CRS. + + https://proj.org/en/stable/operations/projections/cea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "cea", + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return _create_proj_CRS(kwargs, cr) + + +def _latitude_longitude(cr): + """create a latitude_longitude CRS. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The latitude_longitude coordinate reference construct from + which to create the CRS, or `None` if there isn't one (in + which case a spherical CRS is created). + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + kwargs = {"proj": "longlat"} + if cr is None: + kwargs["ellps"] = "sphere" + return _create_proj_CRS(kwargs, cr) + + +def _mercator(cr): + """Create a mercator CRS. + + https://proj.org/en/stable/operations/projections/merc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() kwargs = { - "lat_1": p.get("standard_parallel")[0] if isinstance(p.get("standard_parallel"), (list, tuple)) else p.get("standard_parallel"), - "lat_2": p.get("standard_parallel")[1] if isinstance(p.get("standard_parallel"), (list, tuple)) and len(p.get("standard_parallel")) > 1 else None, + "proj": "merc", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } - return _create_proj_CRS(kwargs) + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return _create_proj_CRS(kwargs, cr) + +def _oblique_mercator(cr): + """Create an oblique_mercator CRS. -def _azimuthal_equidistant(cr): - """Create a `pyproj.CRS` instance for Azimuthal Equidistant.""" + https://proj.org/en/stable/operations/projections/omerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ p = cr.coordinate_conversion.parameters() kwargs = { - "proj": "aeqd", + "proj": "omerc", "lat_0": p.get("latitude_of_projection_origin"), "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting"), - "y_0": p.get("false_northing"), + "alpha": p.get("azimuth_of_central_line"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } - kwargs.update(_extract_datum_parameters(cr)) - return _create_proj_CRS({k: v for k, v in kwargs.items() if v is not None}, cr) + return _create_proj_CRS(kwargs, cr) -def _geostationary(cr): - """Create a `pyproj.CRS` instance for Geostationary Satellite.""" +def _orthographic(cr): + """Create an orthographic CRS. + + https://proj.org/en/stable/operations/projections/ortho.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ p = cr.coordinate_conversion.parameters() kwargs = { - "proj": "geos", - "h": p.get("perspective_point_height"), + "proj": "ortho", + "lat_0": p.get("latitude_of_projection_origin"), "lon_0": p.get("longitude_of_projection_origin"), - "sweep": p.get("sweep_angle_axis"), - "x_0": p.get("false_easting"), - "y_0": p.get("false_northing"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + + +def _polar_stereographic(cr): + """Create a polar_stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), } - kwargs.update(_extract_datum_parameters(cr)) - return _create_proj_CRS({k: v for k, v in kwargs.items() if v is not None}, cr) + + longitude_of_projection_origin = p.get("longitude_of_projection_origin") + if longitude_of_projection_origin is not None: + kwargs["lon_0"] = longitude_of_projection_origin + else: + kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + latitude_of_projection_origin = p.get("latitude_of_projection_origin") + try: + ok = latitude_of_projection_origin == -90 or latitude_of_projection_origin == 90 + except Exception: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " + f"{latitude_of_projection_origin!r}" + ) # pragma: no cover + + kwargs["lat_0"] = latitude_of_projection_origin + + return _create_proj_CRS(kwargs, cr) + +def _rotated_latitude_longitude(cr): + """Create a rotated_latitude_longitude CRS`. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "ob_tran", + "o_proj": "longlat", + "o_lon_p": p.get("north_pole_grid_longitude", 0), + "o_lat_p": p.get("grid_north_pole_latitude"), + } + + grid_north_pole_longitude = p.get("grid_north_pole_longitude") + try: + kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " + f"{grid_north_pole_longitude!r}" + ) # pragma: no cover + + return + + return _create_proj_CRS(kwargs, cr) + +def _sinusoidal(cr): + """Create a sinusoidal CRS. + + https://proj.org/en/stable/operations/projections/sinu.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "sinu", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return _create_proj_CRS(kwargs, cr) + + +def _stereographic(cr): + """Create a stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + +def _transverse_mercator(cr): + """Create a tranverse_mercator CRS. + + https://proj.org/en/stable/operations/projections/tmerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + + kwargs = { + "proj": "tmerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "k_0": p.get("scale_factor_at_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return _create_proj_CRS(kwargs, cr) + + +def _vertical_perspective(cr): + """Create a vertical_perspective CRS. + + https://proj.org/en/stable/operations/projections/nsper.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "nsper", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) +########################## + +def _albers_equal_area(cr): + """Create an azimuthal_equidistant CRS. + + https://proj.org/en/stable/operations/projections/aea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + lat_2 = None + standard_parallel = p.get("standard_parallel") + try: + lat_1 = standard_parallel[0] + except Exception: + lat_1 = standard_parallel + else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass + + kwargs['lat_1'] = lat_1 + kwargs['lat_2'] = lat_2 + + return _create_proj_CRS(kwargs, cr) + + +def _azimuthal_equidistant(cr): + """Create an azimuthal_equidistant CRS. + + https://proj.org/en/stable/operations/projections/aeqd.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aeqd", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return _create_proj_CRS(kwargs, cr) + + +def _geostationary(cr): + """Create a geostationary CRS. + + https://proj.org/en/stable/operations/projections/geos.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = + "proj": "geos", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + sweep_angle_axis = p.get("sweep_angle_axis") + fixed_angle_axis = p.get("fixed_angle_axis") + match sweep_angle_axis: + case 'x': + ok = fixed_angle_axis in (None, 'y') + case 'y': + ok = fixed_angle_axis in (None, 'x') + case None: + ok = True + if fixed_angle_axis == "x": + sweep_angle_axis = "y" + elif fixed_angle_axis == "y": + sweep_angle_axis = "x" + else: + ok = False + case _: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " + f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " + f"parameter: {fixed_angle_axis!r}" + ) # pragma: no cover + + kwargs["sweep"] = sweep_angle_axis + + return _create_proj_CRS(kwargs, cr) + +def _lambert_azimuthal_equal_area(cr): + """Create a lambert_azimuthal_equal_area CRS. + + https://proj.org/en/stable/operations/projections/laea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "laea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + + +def _lambert_conformal_conic(cr): + """Create a lambert_conformal_conic CRS. + + https://proj.org/en/stable/operations/projections/lcc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "lcc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + lat_2 = None + standard_parallel = p.get("standard_parallel") + try: + lat_1 = standard_parallel[0] + except Exception: + lat_1 = standard_parallel + else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass + + kwargs['lat_1'] = lat_1 + kwargs['lat_2'] = lat_2 + + return _create_proj_CRS(kwargs, cr) + +def _lambert_cylindrical_equal_area(cr): + """Create a lambert_cylindrical_equal_area CRS. + + https://proj.org/en/stable/operations/projections/cea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "cea", + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return _create_proj_CRS(kwargs, cr) + + +def _latitude_longitude(cr): + """create a latitude_longitude CRS. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The latitude_longitude coordinate reference construct from + which to create the CRS, or `None` if there isn't one (in + which case a spherical CRS is created). + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + kwargs = {"proj": "longlat"} + if cr is None: + kwargs["ellps"] = "sphere" + + return _create_proj_CRS(kwargs, cr) + + +def _mercator(cr): + """Create a mercator CRS. + + https://proj.org/en/stable/operations/projections/merc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "merc", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return _create_proj_CRS(kwargs, cr) + + +def _oblique_mercator(cr): + """Create an oblique_mercator CRS. + + https://proj.org/en/stable/operations/projections/omerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "omerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "alpha": p.get("azimuth_of_central_line"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + + +def _orthographic(cr): + """Create an orthographic CRS. + + https://proj.org/en/stable/operations/projections/ortho.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "ortho", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + + +def _polar_stereographic(cr): + """Create a polar_stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + longitude_of_projection_origin = p.get("longitude_of_projection_origin") + if longitude_of_projection_origin is not None: + kwargs["lon_0"] = longitude_of_projection_origin + else: + kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + latitude_of_projection_origin = p.get("latitude_of_projection_origin") + try: + ok = latitude_of_projection_origin == -90 or latitude_of_projection_origin == 90 + except Exception: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " + f"{latitude_of_projection_origin!r}" + ) # pragma: no cover + + kwargs["lat_0"] = latitude_of_projection_origin + + return _create_proj_CRS(kwargs, cr) + +def _rotated_latitude_longitude(cr): + """Create a rotated_latitude_longitude CRS`. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "ob_tran", + "o_proj": "longlat", + "o_lon_p": p.get("north_pole_grid_longitude", 0), + "o_lat_p": p.get("grid_north_pole_latitude"), + } + + grid_north_pole_longitude = p.get("grid_north_pole_longitude") + try: + kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " + f"{grid_north_pole_longitude!r}" + ) # pragma: no cover + + return + + return _create_proj_CRS(kwargs, cr) + +def _sinusoidal(cr): + """Create a sinusoidal CRS. + + https://proj.org/en/stable/operations/projections/sinu.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "sinu", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return _create_proj_CRS(kwargs, cr) + + +def _stereographic(cr): + """Create a stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + +def _transverse_mercator(cr): + """Create a tranverse_mercator CRS. + + https://proj.org/en/stable/operations/projections/tmerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + + kwargs = { + "proj": "tmerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "k_0": p.get("scale_factor_at_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return _create_proj_CRS(kwargs, cr) + + +def _vertical_perspective(cr): + """Create a vertical_perspective CRS. + + https://proj.org/en/stable/operations/projections/nsper.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "nsper", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return _create_proj_CRS(kwargs, cr) + From c16098b2b6a0aa0d3109b45bc91f1afc7ad77d4b Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 2 Jul 2026 08:39:32 +0100 Subject: [PATCH 09/43] dev --- cf/mixin/latlon_utils.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py index a61ac8a2cf..5c9b83c32b 100644 --- a/cf/mixin/latlon_utils.py +++ b/cf/mixin/latlon_utils.py @@ -143,12 +143,14 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- x = one_d["x"] y = one_d["y"] - lon_2d_mesh, lat_2d_mesh = np.meshgrid(x.array, y.array) + x = x.to_units('m') + y = x.to_units('m') + x_mesh, y_mesh = np.meshgrid(x.array, y.array) transformer = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True + proj_src, proj_latlon, always_xy=True, errcheck=True, radians=False ) - lon_2d, lat_2d = transformer.transform(lon_2d_mesh, lat_2d_mesh) + lon_2d, lat_2d = transformer.transform(x_mesh, y_mesh) # ---------------------------------------------------------------- # Create the 2-d lat/lon bounds from 1-d grid coordinate bounds @@ -164,10 +166,11 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): xb = np.append(xb[:, 0], xb[-1, 1]) yb = np.append(yb[:, 0], yb[-1, 1]) - lon_2d_mesh, lat_2d_mesh = np.meshgrid(xb, yb) + x_mesh, y_mesh = np.meshgrid(xb, yb) + del xb, yb lon_2d_vertices, lat_2d_vertices = transformer.transform( - lon_2d_mesh, lat_2d_mesh + x_mesh, y_mesh ) shape = (y.size, x.size, 4) From dc54d9adc81272e1d40a3637df7d040dcaac2aeb Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 2 Jul 2026 18:36:47 +0100 Subject: [PATCH 10/43] dev --- cf/mixin/fielddomain.py | 4 +- cf/mixin/latlon_utils.py | 1530 -------------------------------- cf/mixin/utils/__init__.py | 1 + cf/mixin/utils/grid_mapping.py | 686 ++++++++++++++ cf/mixin/utils/latlon_utils.py | 320 +++++++ 5 files changed, 1009 insertions(+), 1532 deletions(-) delete mode 100644 cf/mixin/latlon_utils.py create mode 100644 cf/mixin/utils/__init__.py create mode 100644 cf/mixin/utils/grid_mapping.py create mode 100644 cf/mixin/utils/latlon_utils.py diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index f3ec509c4c..d1841e49de 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2673,9 +2673,9 @@ def create_latlon_coordinates( # -------------------------------------------------------- # 2-d lat/lon coordinates # -------------------------------------------------------- - from .latlon_utils import _create_2d_latlon_coordinates + from .utils import create_2d_latlon_coordinates - lat_key, lon_key = _create_2d_latlon_coordinates( + lat_key, lon_key = create_2d_latlon_coordinates( f, cr, cr_latlon, cache=cache ) coords_created = lat_key is not None diff --git a/cf/mixin/latlon_utils.py b/cf/mixin/latlon_utils.py deleted file mode 100644 index 5c9b83c32b..0000000000 --- a/cf/mixin/latlon_utils.py +++ /dev/null @@ -1,1530 +0,0 @@ -"""2-d latitude/longitude coordinates functionality.""" - -import logging - -import numpy as np -from cfdm import is_log_level_info - -logger = logging.getLogger(__name__) - - -def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): - """Create 2-d latitude and longitude coordinates and bounds. - - When it is not possible to create latitude and longitude - coordinates, the reason why will be reported if the log level is - at ``2``/``'INFO'`` or higher. - - See CF Appendix F: Grid Mappings. - https://doi.org/10.5281/zenodo.14274886 - - .. versionadded:: NEXTVERSION - - :Parameters: - - f: `Field` or `Domain` - The Field or Domain containing the ??? grid, which will be - updated in-place. - - cr: `CoordinateReference` - The coordinate reference construct for the - non-latitude_longitude grid mapping. - - cr_latlon: `CoordinateReference` or `None` - The coordinate reference construct for the - latitude_longitude grid mapping, or `None` is there isn't - one. - - cache: `bool`, optional - If True (the default) then cache in memory the first and - last of any newly-created coordinates and bounds. This may - slightly slow down the coordinate creation process, but - may greatly speed up, and reduce the memory requirement - of, a future inspection of the coordinates and - bounds. Even when *cache* is True, new cached coordinate - values can only be created if the existing 1-d coordinates - themselves have cached first and last values. - - :Returns: - - (`str`, `str`) or (`None`, `None`) - The keys of the new 2-d latitude and longitude coordinate - constructs, in that order, or two `None`s if the 2-d - coordinates could not be created. - - """ - try: - import pyproj - except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Must install the 'pyproj' library" - ) # pragma: no cover - - return (None, None) - - grid_mapping_name = cr.coordinate_conversion.get_parameter( - "grid_mapping_name", None - ) - if grid_mapping_name is None: - return (None, None) - - # ---------------------------------------------------------------- - # Get the source 1-d grid coordinates and axes - # ---------------------------------------------------------------- - one_d = _get_1d_coordinates(f, cr, grid_mapping_name) - if one_d is None: - return (None, None) - - # ---------------------------------------------------------------- - # Create the source grid mapping pyproj CRS - # ---------------------------------------------------------------- - match grid_mapping_name: - case "albers_equal_area": - proj_src = _albers_equal_area(cr) - case "azimuthal_equidistant": - proj_src = _azimuthal_equidistant(cr) - case "geostationary": - proj_src = _geostationary(cr) - case "lambert_azimuthal_equal_area": - proj_src = _lambert_azimuthal_equal_area(cr) - case "lambert_conformal_conic": - proj_src = _lambert_conformal_conic(cr) - case "lambert_cylindrical_equal_area": - proj_src = _lambert_cylindrical_equal_area(cr) - case "mercator": - proj_src = _mercator(cr) - case "oblique_mercator": - proj_src = _oblique_mercator(cr) - case "orthographic": - proj_src = _orthographic(cr) - case "polar_stereographic": - proj_src = _polar_stereographic(cr) - case "rotated_latitude_longitude": - proj_src = _rotated_latitude_longitude(cr) - case "sinusoidal": - proj_src = _sinusoidal(cr) - case "stereographic": - proj_src = _stereographic(cr) - case "transverse_mercator": - proj_src = _transverse_mercator(cr) - case "vertical_perspective": - proj_src = _vertical_perspective(cr) - case _: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}" - ) # pragma: no cover - - return (None, None) - - if proj_src is None: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates. " - f"Unable to create a pyproj.CRS object for {cr!r} from " - f"the grid mapping parameters: " - f"{cr.coordinate_conversion.parameters()!r}" - ) # pragma: no cover - - return (None, None) - - # ---------------------------------------------------------------- - # Create the target latitude_longitude pyproj CRS - # ---------------------------------------------------------------- - proj_latlon = _latitude_longitude(cr_latlon) - if proj_latlon is None: - return (None, None) - - # ---------------------------------------------------------------- - # Create the 2-d lat/lon coordinates from 1-d grid coordinates - # ---------------------------------------------------------------- - x = one_d["x"] - y = one_d["y"] - x = x.to_units('m') - y = x.to_units('m') - x_mesh, y_mesh = np.meshgrid(x.array, y.array) - - transformer = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True, errcheck=True, radians=False - ) - lon_2d, lat_2d = transformer.transform(x_mesh, y_mesh) - - # ---------------------------------------------------------------- - # Create the 2-d lat/lon bounds from 1-d grid coordinate bounds - # ---------------------------------------------------------------- - xb = x.get_bounds_data(None) - yb = y.get_bounds_data(None) - if xb is None and yb is None: - lat_2d_bounds = None - lon_2d_bounds = None - else: - xb = xb.array - yb = yb.array - xb = np.append(xb[:, 0], xb[-1, 1]) - yb = np.append(yb[:, 0], yb[-1, 1]) - - x_mesh, y_mesh = np.meshgrid(xb, yb) - del xb, yb - - lon_2d_vertices, lat_2d_vertices = transformer.transform( - x_mesh, y_mesh - ) - - shape = (y.size, x.size, 4) - lat_2d_bounds = np.empty(shape, dtype=lat_2d.dtype) - lon_2d_bounds = np.empty(shape, dtype=lon_2d.dtype) - - lat_2d_bounds[..., 0] = lat_2d_vertices[:-1, :-1] - lon_2d_bounds[..., 0] = lon_2d_vertices[:-1, :-1] - - lat_2d_bounds[..., 1] = lat_2d_vertices[1:, :-1] - lon_2d_bounds[..., 1] = lon_2d_vertices[1:, :-1] - - lat_2d_bounds[..., 2] = lat_2d_vertices[1:, 1:] - lon_2d_bounds[..., 2] = lon_2d_vertices[1:, 1:] - - lat_2d_bounds[..., 3] = lat_2d_vertices[:-1, 1:] - lon_2d_bounds[..., 3] = lon_2d_vertices[:-1, 1:] - - lat_2d_bounds = f._Bounds(data=f._Data(lat_2d_bounds)) - lon_2d_bounds = f._Bounds(data=f._Data(lon_2d_bounds)) - - # ---------------------------------------------------------------- - # Add the 2-d lat/lon coordinates to the domain - # ---------------------------------------------------------------- - lat_2d = f._AuxiliaryCoordinate( - data=f._Data(lat_2d, "degrees_north"), - bounds=lat_2d_bounds, - properties={"standard_name": "latitude"}, - ) - lon_2d = f._AuxiliaryCoordinate( - data=f._Data(lon_2d, "degrees_east"), - bounds=lon_2d_bounds, - properties={"standard_name": "longitude"}, - ) - - axes = (one_d["axis_y"], one_d["axis_x"]) - - lat_key = f.set_construct(lat_2d, axes=axes, copy=False) - lon_key = f.set_construct(lon_2d, axes=axes, copy=False) - - return (lat_key, lon_key) - - -def _create_proj_CRS(kwargs, cr): - """Create a `pyproj.CRS` instance. - - .. versionadded:: NEXTVERSION - - :Parameters: - - kwargs: `dict` - A dictionary of keyword arguments for initialising the the - `pyproj.CRS` instance. - - cr: `CoordinateReference` - The coordinate reference construct from which *kwargs* was - derived. - - :Returns: - - `pyproj.CRS` or `None` - The created CRS, or `None` if one couldn't be created. - - """ - import pyproj - - # Create the pyproj.CRS keywword arguments, which include - # parameters for describing the ellipsoid - kwargs = _get_ellipsoid_parameters(cr) | kwargs - - # Remove `None` values - kwargs = {k: v for k, v in kwargs.items() if v is not None} - - try: - proj = pyproj.CRS(**kwargs) - except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad pyproj.CRS parameters: {kwargs}" - ) # pragma: no cover - - return - - return proj - -def _get_ellipsoid_parameters(cr): - """TODO""" - kwargs = {} - if cr is None: - return kwargs - - parameters = cr_latlon.coordinate_conversion.parameters() - - if "earth_radius" in parameters: - kwargs["R"] = parameters.get("earth_radius") - elif "semi_major_axis" in parameters: - kwargs["a"] = parameters.get("semi_major_axis") - kwargs["rf"] = parameters.get("inverse_flattening") - kwargs["b"] = parameters.get("semi_minor_axis") - elif "reference_ellipsoid_name" in parameters: - kwargs["ellps"] = parameters.get("reference_ellipsoid_name") - else: - kwargs["ellps"] = "sphere" - - if "longitude_of_prime_meridian" in parameters: - kwargs["pm"] = parameters.get("longitude_of_prime_meridian", 0) - elif "prime_meridian_name" in parameters: - kwargs["pm"] = parameters.get("prime_meridian_name") - - return kwargs - - -def _create_latitude_longitude_CRS(cr_latlon): - """Create a latitude_longitude `pyproj.CRS` instance. - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr_latlon: `CoordinateReference` or `None` - The latitude_longitude coordinate reference construct from - which to create the CRS, or `None` if there isn't one. - - :Returns: - - `pyproj.CRS` or `None` - The created CRS, or `None` if one couldn't be created. - - """ - kwargs = {"proj": "longlat"} - if cr_latlon is None: - kwargs["ellps"] = "sphere" - - return _create_proj_CRS(kwargs, cr_latlon) - - -def _get_1d_coordinates(f, cr, grid_mapping_name): - """Get 1-d coordinates and axes. - - .. versionadded:: NEXTVERSION - - :Parameters: - - f: `Field` or `Domain` - The Field or Domain containing the 1-d coordinates. - - cr: `CoordinateReference` - The coordinate reference construct that references the 1-d - coordinates. - - grid_mapping_name: `str` - The grid_mapping_name parameter of *cr*. - - :Returns: - - `dict` - - The 1-d coordinates and axes in the following dictionary - keys: - - * ``'x'``: The X coordinate construct - * ``'y'``: The Y coordinate construct - * ``'axis_x'``: The X domain axis construct key - * ``'axis_y'``: The Y domain axis construct key - - """ - match grid_mapping_name: - case "rotated_latitude_longitude": - identity_x = "grid_longitude" - identity_y = "grid_latitude" - case _: - identity_x = "projection_x_coordinate" - identity_y = "projection_y_coordinate" - - key_x, x = f.dimension_coordinate( - identity_x, item=True, default=(None, None) - ) - key_y, y = f.dimension_coordinate( - identity_y, item=True, default=(None, None) - ) - - if x is None and is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Missing 1-d {identity_x!r} dimension coordinates" - ) # pragma: no cover - return - - if y is None and is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Missing 1-d {identity_y!r} dimension coordinates" - ) # pragma: no cover - return - - return { - "x": x, - "y": y, - "axis_x": f.get_data_axes(key_x)[0], - "axis_y": f.get_data_axes(key_y)[0], - } - - -# ==================================================================== -# Functions for creating `pyproj.CRS` instances for each grid mapping -# -# These functions are called by `_create_2d_latlon_coordinates` -# ==================================================================== - -def _albers_equal_area(cr): - """Create an azimuthal_equidistant CRS. - - https://proj.org/en/stable/operations/projections/aea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "aea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - lat_2 = None - standard_parallel = p.get("standard_parallel") - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: - try: - lat_2 = standard_parallel[1] - except Exception: - pass - - kwargs['lat_1'] = lat_1 - kwargs['lat_2'] = lat_2 - - return _create_proj_CRS(kwargs, cr) - - -def _azimuthal_equidistant(cr): - """Create an azimuthal_equidistant CRS. - - https://proj.org/en/stable/operations/projections/aeqd.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "aeqd", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _geostationary(cr): - """Create a geostationary CRS. - - https://proj.org/en/stable/operations/projections/geos.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = - "proj": "geos", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - sweep_angle_axis = p.get("sweep_angle_axis") - fixed_angle_axis = p.get("fixed_angle_axis") - match sweep_angle_axis: - case 'x': - ok = fixed_angle_axis in (None, 'y') - case 'y': - ok = fixed_angle_axis in (None, 'x') - case None: - ok = True - if fixed_angle_axis == "x": - sweep_angle_axis = "y" - elif fixed_angle_axis == "y": - sweep_angle_axis = "x" - else: - ok = False - case _: - ok = False - - if not ok: - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " - f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " - f"parameter: {fixed_angle_axis!r}" - ) # pragma: no cover - - kwargs["sweep"] = sweep_angle_axis - - return _create_proj_CRS(kwargs, cr) - -def _lambert_azimuthal_equal_area(cr): - """Create a lambert_azimuthal_equal_area CRS. - - https://proj.org/en/stable/operations/projections/laea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "laea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _lambert_conformal_conic(cr): - """Create a lambert_conformal_conic CRS. - - https://proj.org/en/stable/operations/projections/lcc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "lcc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - lat_2 = None - standard_parallel = p.get("standard_parallel") - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: - try: - lat_2 = standard_parallel[1] - except Exception: - pass - - kwargs['lat_1'] = lat_1 - kwargs['lat_2'] = lat_2 - - return _create_proj_CRS(kwargs, cr) - -def _lambert_cylindrical_equal_area(cr): - """Create a lambert_cylindrical_equal_area CRS. - - https://proj.org/en/stable/operations/projections/cea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "cea", - "lon_0": p.get("longitude_of_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - return _create_proj_CRS(kwargs, cr) - - -def _latitude_longitude(cr): - """create a latitude_longitude CRS. - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The latitude_longitude coordinate reference construct from - which to create the CRS, or `None` if there isn't one (in - which case a spherical CRS is created). - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - kwargs = {"proj": "longlat"} - if cr is None: - kwargs["ellps"] = "sphere" - - return _create_proj_CRS(kwargs, cr) - - -def _mercator(cr): - """Create a mercator CRS. - - https://proj.org/en/stable/operations/projections/merc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "merc", - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - return _create_proj_CRS(kwargs, cr) - - -def _oblique_mercator(cr): - """Create an oblique_mercator CRS. - - https://proj.org/en/stable/operations/projections/omerc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "omerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "alpha": p.get("azimuth_of_central_line"), - "k_0": p.get("scale_factor_at_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _orthographic(cr): - """Create an orthographic CRS. - - https://proj.org/en/stable/operations/projections/ortho.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "ortho", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _polar_stereographic(cr): - """Create a polar_stereographic CRS. - - https://proj.org/en/stable/operations/projections/stere.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "stere", - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - longitude_of_projection_origin = p.get("longitude_of_projection_origin") - if longitude_of_projection_origin is not None: - kwargs["lon_0"] = longitude_of_projection_origin - else: - kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - latitude_of_projection_origin = p.get("latitude_of_projection_origin") - try: - ok = latitude_of_projection_origin == -90 or latitude_of_projection_origin == 90 - except Exception: - ok = False - - if not ok: - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " - f"{latitude_of_projection_origin!r}" - ) # pragma: no cover - - kwargs["lat_0"] = latitude_of_projection_origin - - return _create_proj_CRS(kwargs, cr) - -def _rotated_latitude_longitude(cr): - """Create a rotated_latitude_longitude CRS`. - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "ob_tran", - "o_proj": "longlat", - "o_lon_p": p.get("north_pole_grid_longitude", 0), - "o_lat_p": p.get("grid_north_pole_latitude"), - } - - grid_north_pole_longitude = p.get("grid_north_pole_longitude") - try: - kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 - except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " - f"{grid_north_pole_longitude!r}" - ) # pragma: no cover - - return - - return _create_proj_CRS(kwargs, cr) - -def _sinusoidal(cr): - """Create a sinusoidal CRS. - - https://proj.org/en/stable/operations/projections/sinu.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "sinu", - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _stereographic(cr): - """Create a stereographic CRS. - - https://proj.org/en/stable/operations/projections/stere.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "stere", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "k_0": p.get("scale_factor_at_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - -def _transverse_mercator(cr): - """Create a tranverse_mercator CRS. - - https://proj.org/en/stable/operations/projections/tmerc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - - kwargs = { - "proj": "tmerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "k_0": p.get("scale_factor_at_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _vertical_perspective(cr): - """Create a vertical_perspective CRS. - - https://proj.org/en/stable/operations/projections/nsper.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "nsper", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) -########################## - -def _albers_equal_area(cr): - """Create an azimuthal_equidistant CRS. - - https://proj.org/en/stable/operations/projections/aea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "aea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - lat_2 = None - standard_parallel = p.get("standard_parallel") - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: - try: - lat_2 = standard_parallel[1] - except Exception: - pass - - kwargs['lat_1'] = lat_1 - kwargs['lat_2'] = lat_2 - - return _create_proj_CRS(kwargs, cr) - - -def _azimuthal_equidistant(cr): - """Create an azimuthal_equidistant CRS. - - https://proj.org/en/stable/operations/projections/aeqd.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "aeqd", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _geostationary(cr): - """Create a geostationary CRS. - - https://proj.org/en/stable/operations/projections/geos.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = - "proj": "geos", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - sweep_angle_axis = p.get("sweep_angle_axis") - fixed_angle_axis = p.get("fixed_angle_axis") - match sweep_angle_axis: - case 'x': - ok = fixed_angle_axis in (None, 'y') - case 'y': - ok = fixed_angle_axis in (None, 'x') - case None: - ok = True - if fixed_angle_axis == "x": - sweep_angle_axis = "y" - elif fixed_angle_axis == "y": - sweep_angle_axis = "x" - else: - ok = False - case _: - ok = False - - if not ok: - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " - f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " - f"parameter: {fixed_angle_axis!r}" - ) # pragma: no cover - - kwargs["sweep"] = sweep_angle_axis - - return _create_proj_CRS(kwargs, cr) - -def _lambert_azimuthal_equal_area(cr): - """Create a lambert_azimuthal_equal_area CRS. - - https://proj.org/en/stable/operations/projections/laea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "laea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _lambert_conformal_conic(cr): - """Create a lambert_conformal_conic CRS. - - https://proj.org/en/stable/operations/projections/lcc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "lcc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - lat_2 = None - standard_parallel = p.get("standard_parallel") - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: - try: - lat_2 = standard_parallel[1] - except Exception: - pass - - kwargs['lat_1'] = lat_1 - kwargs['lat_2'] = lat_2 - - return _create_proj_CRS(kwargs, cr) - -def _lambert_cylindrical_equal_area(cr): - """Create a lambert_cylindrical_equal_area CRS. - - https://proj.org/en/stable/operations/projections/cea.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "cea", - "lon_0": p.get("longitude_of_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - return _create_proj_CRS(kwargs, cr) - - -def _latitude_longitude(cr): - """create a latitude_longitude CRS. - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The latitude_longitude coordinate reference construct from - which to create the CRS, or `None` if there isn't one (in - which case a spherical CRS is created). - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - kwargs = {"proj": "longlat"} - if cr is None: - kwargs["ellps"] = "sphere" - - return _create_proj_CRS(kwargs, cr) - - -def _mercator(cr): - """Create a mercator CRS. - - https://proj.org/en/stable/operations/projections/merc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "merc", - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - return _create_proj_CRS(kwargs, cr) - - -def _oblique_mercator(cr): - """Create an oblique_mercator CRS. - - https://proj.org/en/stable/operations/projections/omerc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "omerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "alpha": p.get("azimuth_of_central_line"), - "k_0": p.get("scale_factor_at_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _orthographic(cr): - """Create an orthographic CRS. - - https://proj.org/en/stable/operations/projections/ortho.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "ortho", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - - -def _polar_stereographic(cr): - """Create a polar_stereographic CRS. - - https://proj.org/en/stable/operations/projections/stere.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "stere", - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - longitude_of_projection_origin = p.get("longitude_of_projection_origin") - if longitude_of_projection_origin is not None: - kwargs["lon_0"] = longitude_of_projection_origin - else: - kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") - - standard_parallel = p.get("standard_parallel") - if standard_parallel is not None: - kwargs["lat_ts"] = standard_parallel - else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - - latitude_of_projection_origin = p.get("latitude_of_projection_origin") - try: - ok = latitude_of_projection_origin == -90 or latitude_of_projection_origin == 90 - except Exception: - ok = False - - if not ok: - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " - f"{latitude_of_projection_origin!r}" - ) # pragma: no cover - - kwargs["lat_0"] = latitude_of_projection_origin - - return _create_proj_CRS(kwargs, cr) - -def _rotated_latitude_longitude(cr): - """Create a rotated_latitude_longitude CRS`. - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "ob_tran", - "o_proj": "longlat", - "o_lon_p": p.get("north_pole_grid_longitude", 0), - "o_lat_p": p.get("grid_north_pole_latitude"), - } - - grid_north_pole_longitude = p.get("grid_north_pole_longitude") - try: - kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 - except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " - f"{grid_north_pole_longitude!r}" - ) # pragma: no cover - - return - - return _create_proj_CRS(kwargs, cr) - -def _sinusoidal(cr): - """Create a sinusoidal CRS. - - https://proj.org/en/stable/operations/projections/sinu.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "sinu", - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _stereographic(cr): - """Create a stereographic CRS. - - https://proj.org/en/stable/operations/projections/stere.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "stere", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "k_0": p.get("scale_factor_at_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - -def _transverse_mercator(cr): - """Create a tranverse_mercator CRS. - - https://proj.org/en/stable/operations/projections/tmerc.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - - kwargs = { - "proj": "tmerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "k_0": p.get("scale_factor_at_central_meridian"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - - return _create_proj_CRS(kwargs, cr) - - -def _vertical_perspective(cr): - """Create a vertical_perspective CRS. - - https://proj.org/en/stable/operations/projections/nsper.html - - .. versionadded:: NEXTVERSION - - :Parameters: - - cr: `CoordinateReference` - The coordinate reference construct. - - :Returns: - - `pyproj.CRS` - The created CRS, or `None` if one couldn't be created. - - """ - p = cr.coordinate_conversion.parameters() - kwargs = { - "proj": "nsper", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - } - return _create_proj_CRS(kwargs, cr) - diff --git a/cf/mixin/utils/__init__.py b/cf/mixin/utils/__init__.py new file mode 100644 index 0000000000..8b7e916868 --- /dev/null +++ b/cf/mixin/utils/__init__.py @@ -0,0 +1 @@ +from .latlon_utils import create_2d_latlon_coordinates diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py new file mode 100644 index 0000000000..9ae1ff8615 --- /dev/null +++ b/cf/mixin/utils/grid_mapping.py @@ -0,0 +1,686 @@ +"""Utilities for creating `proj.CRS` instances.""" + +import logging + +from cfdm import is_log_level_info + +logger = logging.getLogger(__name__) + + +def get_ellipsoid_parameters(cr): + """Get ellipsoid parmaeters from a coordinate reference construct. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` or `None` + The coordinate reference construct, or `None`, in which + case the CF defualt ellpsoid is assumed. + + :Returns: + + `dict` + The `proj.CRS` ellpsoid parameters. + + """ + kwargs = {} + if cr is None: + p = {} + else: + p = cr.coordinate_conversion.parameters() + if "reference_ellipsoid_name" in p: + kwargs["ellps"] = p["reference_ellipsoid_name"] + + if "semi_major_axis" in p: + kwargs["a"] = p["semi_major_axis"] + + if "semi_minor_axis" in p: + kwargs["b"] = p["semi_minor_axis"] + + if "inverse_flattening" in p: + kwargs["rf"] = p["inverse_flattening"] + + if not kwargs: + kwargs = {"ellps": "sphere"} + + kwargs["R"] = p.get("earth_radius") + + prime_meridian_name = p.get("prime_meridian_name") + if prime_meridian_name is not None: + kwargs["pm"] = prime_meridian_name + else: + kwargs["pm"] = p.get("longitude_of_prime_meridian", 0) + + return kwargs + + +def create_proj_CRS(kwargs, cr): + """Create a `pyproj.CRS` instance. + + .. versionadded:: NEXTVERSION + + :Parameters: + + kwargs: `dict` + A dictionary of keyword arguments for initialising the the + `pyproj.CRS` instance. + + cr: `CoordinateReference` + The coordinate reference construct from which *kwargs* was + derived. + + :Returns: + + `pyproj.CRS` or `None` + The created CRS, or `None` if one couldn't be created. + + """ + import pyproj + + # Create the `pyproj.CRS` keywword arguments, which include + # parameters for describing the ellipsoid + kwargs = get_ellipsoid_parameters(cr) | kwargs + + # Remove `None` values + kwargs = {k: v for k, v in kwargs.items() if v is not None} + + try: + proj = pyproj.CRS(**kwargs) + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad pyproj.CRS parameters: {kwargs!r}" + ) # pragma: no cover + + return + + return proj + + +# ==================================================================== +# Functions for creating `pyproj.CRS` instances for each CF grid +# mapping type. +# +# These functions are called by `_create_2d_latlon_coordinates`. +# ==================================================================== + + +def albers_equal_area(cr): + """Create an azimuthal_equidistant CRS. + + https://proj.org/en/stable/operations/projections/aea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + lat_2 = None + standard_parallel = p.get("standard_parallel") + try: + lat_1 = standard_parallel[0] + except Exception: + lat_1 = standard_parallel + else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass + + kwargs["lat_1"] = lat_1 + kwargs["lat_2"] = lat_2 + + return create_proj_CRS(kwargs, cr) + + +def azimuthal_equidistant(cr): + """Create an azimuthal_equidistant CRS. + + https://proj.org/en/stable/operations/projections/aeqd.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "aeqd", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return create_proj_CRS(kwargs, cr) + + +def geostationary(cr): + """Create a geostationary CRS. + + https://proj.org/en/stable/operations/projections/geos.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "geos", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + sweep_angle_axis = p.get("sweep_angle_axis") + fixed_angle_axis = p.get("fixed_angle_axis") + match sweep_angle_axis: + case "x": + ok = fixed_angle_axis in (None, "y") + case "y": + ok = fixed_angle_axis in (None, "x") + case None: + ok = True + if fixed_angle_axis == "x": + sweep_angle_axis = "y" + elif fixed_angle_axis == "y": + sweep_angle_axis = "x" + else: + ok = False + case _: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " + f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " + f"parameter: {fixed_angle_axis!r}" + ) # pragma: no cover + + kwargs["sweep"] = sweep_angle_axis + + return create_proj_CRS(kwargs, cr) + + +def lambert_azimuthal_equal_area(cr): + """Create a lambert_azimuthal_equal_area CRS. + + https://proj.org/en/stable/operations/projections/laea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "laea", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return create_proj_CRS(kwargs, cr) + + +def lambert_conformal_conic(cr): + """Create a lambert_conformal_conic CRS. + + https://proj.org/en/stable/operations/projections/lcc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "lcc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + lat_2 = None + standard_parallel = p.get("standard_parallel") + try: + lat_1 = standard_parallel[0] + except Exception: + lat_1 = standard_parallel + else: + try: + lat_2 = standard_parallel[1] + except Exception: + pass + + kwargs["lat_1"] = lat_1 + kwargs["lat_2"] = lat_2 + + return create_proj_CRS(kwargs, cr) + + +def lambert_cylindrical_equal_area(cr): + """Create a lambert_cylindrical_equal_area CRS. + + https://proj.org/en/stable/operations/projections/cea.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "cea", + "lon_0": p.get("longitude_of_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return create_proj_CRS(kwargs, cr) + + +def latitude_longitude(cr): + """create a latitude_longitude CRS. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The latitude_longitude coordinate reference construct from + which to create the CRS, or `None` if there isn't one (in + which case a spherical CRS is created). + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + kwargs = {"proj": "longlat"} + return create_proj_CRS(kwargs, cr) + + +def mercator(cr): + """Create a mercator CRS. + + https://proj.org/en/stable/operations/projections/merc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "merc", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + return create_proj_CRS(kwargs, cr) + + +def oblique_mercator(cr): + """Create an oblique_mercator CRS. + + https://proj.org/en/stable/operations/projections/omerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "omerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "alpha": p.get("azimuth_of_central_line"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return create_proj_CRS(kwargs, cr) + + +def orthographic(cr): + """Create an orthographic CRS. + + https://proj.org/en/stable/operations/projections/ortho.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "ortho", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return create_proj_CRS(kwargs, cr) + + +def polar_stereographic(cr): + """Create a polar_stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + longitude_of_projection_origin = p.get("longitude_of_projection_origin") + if longitude_of_projection_origin is not None: + kwargs["lon_0"] = longitude_of_projection_origin + else: + kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") + + standard_parallel = p.get("standard_parallel") + if standard_parallel is not None: + kwargs["lat_ts"] = standard_parallel + else: + kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + + latitude_of_projection_origin = p.get("latitude_of_projection_origin") + try: + ok = ( + latitude_of_projection_origin == -90 + or latitude_of_projection_origin == 90 + ) + except Exception: + ok = False + + if not ok: + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " + f"{latitude_of_projection_origin!r}" + ) # pragma: no cover + + kwargs["lat_0"] = latitude_of_projection_origin + + return create_proj_CRS(kwargs, cr) + + +def rotated_latitude_longitude(cr): + """Create a rotated_latitude_longitude CRS`. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "ob_tran", + "o_proj": "longlat", + "o_lon_p": p.get("north_pole_grid_longitude", 0), + "o_lat_p": p.get("grid_north_pole_latitude"), + } + + grid_north_pole_longitude = p.get("grid_north_pole_longitude") + try: + kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " + f"{grid_north_pole_longitude!r}" + ) # pragma: no cover + + return + + return create_proj_CRS(kwargs, cr) + + +def sinusoidal(cr): + """Create a sinusoidal CRS. + + https://proj.org/en/stable/operations/projections/sinu.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "sinu", + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return create_proj_CRS(kwargs, cr) + + +def stereographic(cr): + """Create a stereographic CRS. + + https://proj.org/en/stable/operations/projections/stere.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "stere", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "k_0": p.get("scale_factor_at_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return create_proj_CRS(kwargs, cr) + + +def transverse_mercator(cr): + """Create a tranverse_mercator CRS. + + https://proj.org/en/stable/operations/projections/tmerc.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + + kwargs = { + "proj": "tmerc", + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_central_meridian"), + "k_0": p.get("scale_factor_at_central_meridian"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + + return create_proj_CRS(kwargs, cr) + + +def vertical_perspective(cr): + """Create a vertical_perspective CRS. + + https://proj.org/en/stable/operations/projections/nsper.html + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `pyproj.CRS` + The created CRS, or `None` if one couldn't be created. + + """ + p = cr.coordinate_conversion.parameters() + kwargs = { + "proj": "nsper", + "h": p.get("perspective_point_height"), + "lat_0": p.get("latitude_of_projection_origin"), + "lon_0": p.get("longitude_of_projection_origin"), + "x_0": p.get("false_easting", 0), + "y_0": p.get("false_northing", 0), + } + return create_proj_CRS(kwargs, cr) diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py new file mode 100644 index 0000000000..3c33283f6b --- /dev/null +++ b/cf/mixin/utils/latlon_utils.py @@ -0,0 +1,320 @@ +"""Utilities for creating 2-d latitude/longitude coordinates.""" + +import logging + +import numpy as np +from cfdm import is_log_level_info + +from .grid_mapping import ( + albers_equal_area, + azimuthal_equidistant, + geostationary, + lambert_azimuthal_equal_area, + lambert_conformal_conic, + lambert_cylindrical_equal_area, + latitude_longitude, + mercator, + oblique_mercator, + orthographic, + polar_stereographic, + rotated_latitude_longitude, + sinusoidal, + stereographic, + transverse_mercator, + vertical_perspective, +) + +logger = logging.getLogger(__name__) + + +def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): + """Create 2-d latitude and longitude coordinates and bounds. + + When it is not possible to create latitude and longitude + coordinates, the reason why will be reported if the log level is + at ``2``/``'INFO'`` or higher. + + See CF Appendix F: Grid Mappings. + https://doi.org/10.5281/zenodo.14274886 + + .. versionadded:: NEXTVERSION + + :Parameters: + + f: `Field` or `Domain` + The Field or Domain containing the ??? grid, which will be + updated in-place. + + cr: `CoordinateReference` + The coordinate reference construct for the + non-latitude_longitude grid mapping. + + cr_latlon: `CoordinateReference` or `None` + The coordinate reference construct for the + latitude_longitude grid mapping, or `None` is there isn't + one. + + cache: `bool`, optional + If True (the default) then cache in memory the first and + last of any newly-created coordinates and bounds. This may + slightly slow down the coordinate creation process, but + may greatly speed up, and reduce the memory requirement + of, a future inspection of the coordinates and + bounds. Even when *cache* is True, new cached coordinate + values can only be created if the existing 1-d coordinates + themselves have cached first and last values. + + :Returns: + + (`str`, `str`) or (`None`, `None`) + The keys of the new 2-d latitude and longitude coordinate + constructs, in that order, or two `None`s if the 2-d + coordinates could not be created. + + """ + try: + import pyproj + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Must install the 'pyproj' library" + ) # pragma: no cover + + return (None, None) + + grid_mapping_name = cr.coordinate_conversion.get_parameter( + "grid_mapping_name", None + ) + if grid_mapping_name is None: + # Invalid non-latitude_longitude coordinate reference + return (None, None) + + # ---------------------------------------------------------------- + # Get the source 1-d grid coordinates and axes + # ---------------------------------------------------------------- + one_d = _get_1d_coordinates(f, cr, grid_mapping_name) + if one_d is None: + # Invalid 1-d grid coordinates + return (None, None) + + # ---------------------------------------------------------------- + # Create the source grid mapping pyproj CRS + # ---------------------------------------------------------------- + match grid_mapping_name: + case "albers_equal_area": + proj_src = albers_equal_area(cr) + case "azimuthal_equidistant": + proj_src = azimuthal_equidistant(cr) + case "geostationary": + proj_src = geostationary(cr) + case "lambert_azimuthal_equal_area": + proj_src = lambert_azimuthal_equal_area(cr) + case "lambert_conformal_conic": + proj_src = lambert_conformal_conic(cr) + case "lambert_cylindrical_equal_area": + proj_src = lambert_cylindrical_equal_area(cr) + case "mercator": + proj_src = mercator(cr) + case "oblique_mercator": + proj_src = oblique_mercator(cr) + case "orthographic": + proj_src = orthographic(cr) + case "polar_stereographic": + proj_src = polar_stereographic(cr) + case "rotated_latitude_longitude": + proj_src = rotated_latitude_longitude(cr) + case "sinusoidal": + proj_src = sinusoidal(cr) + case "stereographic": + proj_src = stereographic(cr) + case "transverse_mercator": + proj_src = transverse_mercator(cr) + case "vertical_perspective": + proj_src = vertical_perspective(cr) + case _: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}" + ) # pragma: no cover + + return (None, None) + + if proj_src is None: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates. " + f"Unable to create a pyproj.CRS object for {cr!r} from " + f"the grid mapping parameters: " + f"{cr.coordinate_conversion.parameters()!r}" + ) # pragma: no cover + + return (None, None) + + # ---------------------------------------------------------------- + # Create the target latitude_longitude pyproj CRS + # ---------------------------------------------------------------- + proj_latlon = latitude_longitude(cr_latlon) + if proj_latlon is None: + # Invalid latitude_longitude coordinate reference + return (None, None) + + # ---------------------------------------------------------------- + # Create the 2-d lat/lon coordinates from 1-d grid coordinates + # ---------------------------------------------------------------- + x = one_d["x"] + y = one_d["y"] + x = x.to_units("m") + y = x.to_units("m") + + # Create x and y 2-d meshes of cell centres + x_mesh, y_mesh = np.meshgrid(x.array, y.array) + + transformer = pyproj.Transformer.from_crs( + proj_src, proj_latlon, always_xy=True, errcheck=True, radians=False + ) + lon, lat = transformer.transform(x_mesh, y_mesh) + + lat = f._Data(lat, "degrees_north") + lon = f._Data(lon, "degrees_east") + + # ---------------------------------------------------------------- + # Create the 2-d lat/lon bounds from 1-d grid coordinate bounds + # ---------------------------------------------------------------- + xb = x.get_bounds_data(None) + yb = y.get_bounds_data(None) + if xb is None or yb is None: + lat_bounds = None + lon_bounds = None + else: + xb = xb.array + yb = yb.array + xb = np.append(xb[:, 0], xb[-1, 1]) + yb = np.append(yb[:, 0], yb[-1, 1]) + + # Create x and y 2-d meshes of unique vertices + x_mesh, y_mesh = np.meshgrid(xb, yb) + del xb, yb + + lon_vertices, lat_vertices = transformer.transform(x_mesh, y_mesh) + + shape = (y.size, x.size, 4) + lat_bounds = np.empty(shape, dtype=lat_vertices.dtype) + lon_bounds = np.empty(shape, dtype=lon_vertices.dtype) + + lat_bounds[..., 0] = lat_vertices[:-1, :-1] + lon_bounds[..., 0] = lon_vertices[:-1, :-1] + + lat_bounds[..., 1] = lat_vertices[1:, :-1] + lon_bounds[..., 1] = lon_vertices[1:, :-1] + + lat_bounds[..., 2] = lat_vertices[1:, 1:] + lon_bounds[..., 2] = lon_vertices[1:, 1:] + + lat_bounds[..., 3] = lat_vertices[:-1, 1:] + lon_bounds[..., 3] = lon_vertices[:-1, 1:] + + lat_bounds = f._Bounds(data=f._Data(lat_bounds)) + lon_bounds = f._Bounds(data=f._Data(lon_bounds)) + + # ---------------------------------------------------------------- + # Add the 2-d lat/lon coordinates to the domain + # ---------------------------------------------------------------- + aux_lat = f._AuxiliaryCoordinate( + data=lat, + bounds=lat_bounds, + properties={"standard_name": "latitude"}, + ) + aux_lon = f._AuxiliaryCoordinate( + data=lon, + bounds=lon_bounds, + properties={"standard_name": "longitude"}, + ) + + axes = (one_d["axis_y"], one_d["axis_x"]) + + lat_key = f.set_construct(aux_lat, axes=axes, copy=False) + lon_key = f.set_construct(aux_lon, axes=axes, copy=False) + + return (lat_key, lon_key) + + +def _get_1d_coordinates(f, cr, grid_mapping_name): + """Get 1-d coordinates and axes. + + .. versionadded:: NEXTVERSION + + :Parameters: + + f: `Field` or `Domain` + The Field or Domain containing the 1-d coordinates. + + cr: `CoordinateReference` + The coordinate reference construct that implies the 1-d + coordinates. + + grid_mapping_name: `str` + The grid_mapping_name parameter of *cr*. + + :Returns: + + `dict` + + The 1-d coordinates and axes in the following dictionary + keys: + + * ``'x'``: The X coordinate construct + * ``'y'``: The Y coordinate construct + * ``'axis_x'``: The X domain axis construct key + * ``'axis_y'``: The Y domain axis construct key + + """ + x = None + y = None + + # Look for 1-d coordinates named by the coordinate reference + for key in cr.coordinates(): + c = f.dimension_construct(f"key%{key}", default=None) + if c is None: + continue + + if c.X: + key_x = key + x = c + elif c.Y: + key_y = key + y = c + + if x is None and y is None: + # Look for 1-d coordinates by identity + match grid_mapping_name: + case "rotated_latitude_longitude": + identity_x = "grid_longitude" + identity_y = "grid_latitude" + case _: + identity_x = "projection_x_coordinate" + identity_y = "projection_y_coordinate" + + key_x, x = f.dimension_coordinate( + identity_x, item=True, default=(None, None) + ) + key_y, y = f.dimension_coordinate( + identity_y, item=True, default=(None, None) + ) + + if x is None or y is None: + if is_log_level_info(logger): + logger.info( + "Can't create 2-d latitude and longitude coordinates " + f"for {cr!r}: Missing 1-d dimension coordinates" + ) # pragma: no cover + + return + + return { + "x": x, + "y": y, + "axis_x": f.get_data_axes(key_x)[0], + "axis_y": f.get_data_axes(key_y)[0], + } From c8d74d106a0c0552603a472a71e7daf8ae6b69ee Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 3 Jul 2026 10:18:55 +0100 Subject: [PATCH 11/43] dev --- cf/mixin/fielddomain.py | 4 +- cf/mixin/utils/latlon_utils.py | 98 ++++++++++++++++++---------------- 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index d1841e49de..2197312a39 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2530,8 +2530,8 @@ def create_latlon_coordinates( :Returns: `{{class}}` or `None` - A new {{class}}, with new latitude and longitude - constructs if any could be created. If the operation + The {{class}} with new latitude and longitude + constructs, if any could be created. If the operation was in-place then `None` is returned. **Examples** diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 3c33283f6b..92231eebb4 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -27,7 +27,7 @@ logger = logging.getLogger(__name__) -def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): +def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): """Create 2-d latitude and longitude coordinates and bounds. When it is not possible to create latitude and longitude @@ -42,8 +42,8 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): :Parameters: f: `Field` or `Domain` - The Field or Domain containing the ??? grid, which will be - updated in-place. + The Field or Domain, which will be updated in-place, + containing non-latitude_longitude grid. cr: `CoordinateReference` The coordinate reference construct for the @@ -68,7 +68,7 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): (`str`, `str`) or (`None`, `None`) The keys of the new 2-d latitude and longitude coordinate - constructs, in that order, or two `None`s if the 2-d + constructs, in that order; or two `None`s if the 2-d coordinates could not be created. """ @@ -102,36 +102,39 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # Create the source grid mapping pyproj CRS # ---------------------------------------------------------------- match grid_mapping_name: - case "albers_equal_area": - proj_src = albers_equal_area(cr) - case "azimuthal_equidistant": - proj_src = azimuthal_equidistant(cr) - case "geostationary": - proj_src = geostationary(cr) - case "lambert_azimuthal_equal_area": - proj_src = lambert_azimuthal_equal_area(cr) - case "lambert_conformal_conic": - proj_src = lambert_conformal_conic(cr) - case "lambert_cylindrical_equal_area": - proj_src = lambert_cylindrical_equal_area(cr) - case "mercator": - proj_src = mercator(cr) - case "oblique_mercator": - proj_src = oblique_mercator(cr) - case "orthographic": - proj_src = orthographic(cr) - case "polar_stereographic": - proj_src = polar_stereographic(cr) + # The commented-out grid mappings do not yet have unit tests, + # and so are not yet available. + + # case "albers_equal_area": + # proj_src = albers_equal_area(cr) + # case "azimuthal_equidistant": + # proj_src = azimuthal_equidistant(cr) + # case "geostationary": + # proj_src = geostationary(cr) + # case "lambert_azimuthal_equal_area": + # proj_src = lambert_azimuthal_equal_area(cr) + # case "lambert_conformal_conic": + # proj_src = lambert_conformal_conic(cr) + # case "lambert_cylindrical_equal_area": + # proj_src = lambert_cylindrical_equal_area(cr) + # case "mercator": + # proj_src = mercator(cr) + # case "oblique_mercator": + # proj_src = oblique_mercator(cr) + # case "orthographic": + # proj_src = orthographic(cr) + # case "polar_stereographic": + # proj_src = polar_stereographic(cr) case "rotated_latitude_longitude": - proj_src = rotated_latitude_longitude(cr) - case "sinusoidal": - proj_src = sinusoidal(cr) - case "stereographic": - proj_src = stereographic(cr) - case "transverse_mercator": - proj_src = transverse_mercator(cr) - case "vertical_perspective": - proj_src = vertical_perspective(cr) + proj_src = rotated_latitude_longitude(cr) + # case "sinusoidal": + # proj_src = sinusoidal(cr) + # case "stereographic": + # proj_src = stereographic(cr) + # case "transverse_mercator": + # proj_src = transverse_mercator(cr) + # case "vertical_perspective": + # proj_src = vertical_perspective(cr) case _: if is_log_level_info(logger): logger.info( @@ -241,26 +244,26 @@ def _create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): def _get_1d_coordinates(f, cr, grid_mapping_name): - """Get 1-d coordinates and axes. - + """Get 1-d dimension coordinates and axes. + .. versionadded:: NEXTVERSION :Parameters: f: `Field` or `Domain` - The Field or Domain containing the 1-d coordinates. + The Field or Domain containing the 1-d dimension + coordinates. cr: `CoordinateReference` - The coordinate reference construct that implies the 1-d - coordinates. + The coordinate reference construct that defines or implies + the 1-d dimension coordinates. grid_mapping_name: `str` The grid_mapping_name parameter of *cr*. :Returns: - `dict` - + `dict` or `None` The 1-d coordinates and axes in the following dictionary keys: @@ -269,22 +272,25 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): * ``'axis_x'``: The X domain axis construct key * ``'axis_y'``: The Y domain axis construct key + If both 1-d dimension coordinates could not be found then + `None` is returned. + """ x = None y = None # Look for 1-d coordinates named by the coordinate reference for key in cr.coordinates(): - c = f.dimension_construct(f"key%{key}", default=None) - if c is None: + dc = f.dimension_coordinate(f"key%{key}", default=None) + if dc is None: continue - if c.X: + if dc.X: key_x = key - x = c - elif c.Y: + x = dc + elif dc.Y: key_y = key - y = c + y = dc if x is None and y is None: # Look for 1-d coordinates by identity From 7309ac3fca9f535177f69f2bd5575818b2f97b44 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 3 Jul 2026 12:12:27 +0100 Subject: [PATCH 12/43] dev --- cf/__init__.py | 4 +- cf/mixin/utils/grid_mapping.py | 4 +- cf/mixin/utils/latlon_utils.py | 29 +++++--- cf/test/test_2d_latlon.py | 120 +++++++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 cf/test/test_2d_latlon.py diff --git a/cf/__init__.py b/cf/__init__.py index 6b47804e69..dd19cfb31b 100644 --- a/cf/__init__.py +++ b/cf/__init__.py @@ -104,8 +104,8 @@ # Check the version of cfdm (this is worth doing because of the very # tight coupling between cf and cfdm, and the risk of bad things # happening at run time if the versions are mismatched). -_minimum_vn = "1.13.1.0" -_maximum_vn = "1.13.2.0" +_minimum_vn = "1.13.2.0" +_maximum_vn = "1.13.3.0" _cfdm_vn = Version(cfdm.__version__) if _cfdm_vn < Version(_minimum_vn) or _cfdm_vn >= Version(_maximum_vn): raise RuntimeError( diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 9ae1ff8615..c58a77d513 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -1,4 +1,4 @@ -"""Utilities for creating `proj.CRS` instances.""" +"""Utilities for creating `pyproj.CRS` instances.""" import logging @@ -21,7 +21,7 @@ def get_ellipsoid_parameters(cr): :Returns: `dict` - The `proj.CRS` ellpsoid parameters. + The `pyproj.CRS` ellpsoid parameters. """ kwargs = {} diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 92231eebb4..626673fb67 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -5,6 +5,8 @@ import numpy as np from cfdm import is_log_level_info +from cf import Units + from .grid_mapping import ( albers_equal_area, azimuthal_equidistant, @@ -104,10 +106,10 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): match grid_mapping_name: # The commented-out grid mappings do not yet have unit tests, # and so are not yet available. - + # case "albers_equal_area": # proj_src = albers_equal_area(cr) - # case "azimuthal_equidistant": + # case "azimuthal_equidistant": # proj_src = azimuthal_equidistant(cr) # case "geostationary": # proj_src = geostationary(cr) @@ -126,7 +128,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # case "polar_stereographic": # proj_src = polar_stereographic(cr) case "rotated_latitude_longitude": - proj_src = rotated_latitude_longitude(cr) + proj_src = rotated_latitude_longitude(cr) # case "sinusoidal": # proj_src = sinusoidal(cr) # case "stereographic": @@ -168,16 +170,23 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- x = one_d["x"] y = one_d["y"] - x = x.to_units("m") - y = x.to_units("m") + + metres = Units("m") + if x.Units.equivalent(metres): + x = x.to_units(metres) + + if y.Units.equivalent(metres): + y = y.to_units(metres) # Create x and y 2-d meshes of cell centres x_mesh, y_mesh = np.meshgrid(x.array, y.array) transformer = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True, errcheck=True, radians=False + proj_src, proj_latlon, always_xy=True + ) + lon, lat = transformer.transform( + x_mesh, y_mesh, errcheck=True, radians=False ) - lon, lat = transformer.transform(x_mesh, y_mesh) lat = f._Data(lat, "degrees_north") lon = f._Data(lon, "degrees_east") @@ -245,7 +254,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): def _get_1d_coordinates(f, cr, grid_mapping_name): """Get 1-d dimension coordinates and axes. - + .. versionadded:: NEXTVERSION :Parameters: @@ -318,6 +327,10 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): return + # Make sure the 1-d coordinates are referenced from the coordinate + # reference + cr.set_coordinates((key_x, key_y)) + return { "x": x, "y": y, diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py new file mode 100644 index 0000000000..b60d4a2218 --- /dev/null +++ b/cf/test/test_2d_latlon.py @@ -0,0 +1,120 @@ +import datetime +import unittest + +import numpy as np + +import cf + + +class LatLon2dTest(unittest.TestCase): + """Test the creation of 2-d lat/lon.""" + + def test_rotated_latitude_longitude_0(self): + """Test rotated_latitude_longitude.""" + # Test round trip + import pyproj + + from cf.mixin.utils.grid_mapping import ( + latitude_longitude, + rotated_latitude_longitude, + ) + + cr = cf.CoordinateReference() + cc = cr.coordinate_conversion + cc.set_parameter("grid_mapping_name", "rotated_latitude_longitude") + cc.set_parameter("grid_north_pole_latitude", 38.0) + cc.set_parameter("grid_north_pole_longitude", 190.0) + + proj_src = rotated_latitude_longitude(cr) + proj_latlon = latitude_longitude(None) + + transformer0 = pyproj.Transformer.from_crs( + proj_latlon, proj_src, always_xy=True + ).transform + transformer1 = pyproj.Transformer.from_crs( + proj_src, proj_latlon, always_xy=True + ).transform + + # Centres + lon0 = np.array([[1, 1]], float) + lat0 = np.array([[50, 60]], float) + gridx , gridy = transformer0(lon0, lat0) + lon1, lat1 = transformer1(gridx , gridy ) + + self.assertTrue(np.allclose(lon0, lon1)) + self.assertTrue(np.allclose(lat0, lat1)) +swap 1 and 0 + # Bounds + blon0 = np.array([[0, 0, 2, 2], [0, 0, 2, 2]], float) + blat0 = np.array([[51, 49, 49, 51], [61, 59, 59, 61]], float) + bgridx , bgridy = transformer0(blon0, blat0) + blon1, blat1 = transformer1( bgridx , bgridy) + + self.assertTrue(np.allclose(blon0, blon1)) + self.assertTrue(np.allclose(blat0, blat1)) + print() + print('gridx=', gridx, 'bgridx=', bgridx) + print('gridy=', gridy, 'bgridy=', bgridy) + + # Test with Field + f = cf.example_field(0) + f = f[:2, 0] + + key_x, x = f.dimension_coordinate("X", item=True) + x.data[...] = gridx[0, 0] + print('x.array=', x.array) + + x.bounds.data[...] = bgridx[0, [0, -1]] + x.override_units("degrees", inplace=True) + x.standard_name = "grid_longitude" + + key_y, y = f.dimension_coordinate("Y", item=True) + y.data[...] = gridy + print('y.array=', y.array) + y.bounds.data[0] = bgridy[0, [0, 1]] + y.bounds.data[1] = bgridy[1, [0, 1]] + y.override_units("degrees", inplace=True) + y.standard_name = "grid_latitude" + + fcr = cf.CoordinateReference() + fcr.coordinate_conversion.set_parameter( + "grid_mapping_name", "rotated_latitude_longitude" + ) + fcr.coordinate_conversion.set_parameter( + "grid_north_pole_latitude", 38.0 + ) + fcr.coordinate_conversion.set_parameter( + "grid_north_pole_longitude", 190.0 + ) + f.set_construct(fcr, copy=False) + + self.assertEqual(len(f.auxiliary_coordinates()), 0) + + for coordinates in (set(), {key_x, key_y}): + fcr.clear_coordinates() + fcr.set_coordinates(coordinates) + + g = f.create_latlon_coordinates() + + self.assertEqual(len(g.auxiliary_coordinates()), 2) + + gcr = g.coordinate_reference() + self.assertEqual( + gcr.coordinates(), + {key_x, key_y, "auxiliarycoordinate0", "auxiliarycoordinate1"}, + ) + + lat = g.auxiliary_coordinate('latitude') + print () + print(lat.array, lat0) + self.assertTrue(np.allclose(lat.array, lat0)) + + lon = g.auxiliary_coordinate('longitude') + self.assertTrue(np.allclose(lon.array, lon0)) + + +if __name__ == "__main__": + print("Run date:", datetime.datetime.now()) + cf.environment() + print("") + unittest.main(verbosity=2) From 4b33d7e21d50013ce6622b907f94b0ae9b79a289 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 3 Jul 2026 19:12:44 +0100 Subject: [PATCH 13/43] dev --- cf/mixin/fielddomain.py | 10 + cf/mixin/utils/grid_mapping.py | 21 +- cf/mixin/utils/latlon_utils.py | 398 +++++++++++++++++++++++++++------ cf/test/test_2d_latlon.py | 94 +++++--- 4 files changed, 405 insertions(+), 118 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 2197312a39..074de3ee89 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -1927,6 +1927,7 @@ def del_domain_axis( return domain_axis + def coordinate_reference_domain_axes(self, identity=None): """Return the domain axes that apply to a coordinate reference construct. @@ -2450,6 +2451,15 @@ def healpix_to_ugrid(self, cache=True, inplace=False): return f + @_inplace_enabled(default=False) + @_manage_log_level_via_verbosity + def create_projection_coordinates(self + overwrite=False, + cache=True, + inplace=False, + verbose=None): + """TODO""" + @_inplace_enabled(default=False) @_manage_log_level_via_verbosity def create_latlon_coordinates( diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index c58a77d513..59a8d740a6 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -88,13 +88,13 @@ def create_proj_CRS(kwargs, cr): try: proj = pyproj.CRS(**kwargs) except Exception: + proj = None if is_log_level_info(logger): logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad pyproj.CRS parameters: {kwargs!r}" + f"Can't create a pyproj.CRS for {cr!r}: " + f"Bad pyproj.CRS parameters: {kwargs!r}" ) # pragma: no cover - return return proj @@ -230,10 +230,9 @@ def geostationary(cr): if not ok: logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'sweep_angle_axis' parameter: " - f"{sweep_angle_axis!r}, or bad 'fixed_angle_axis' " - f"parameter: {fixed_angle_axis!r}" + f"Can't create coordinates for {cr!r}: " + f"Bad 'sweep_angle_axis' parameter: {sweep_angle_axis!r}, " + f"or bad 'fixed_angle_axis' parameter: {fixed_angle_axis!r}" ) # pragma: no cover kwargs["sweep"] = sweep_angle_axis @@ -515,8 +514,8 @@ def polar_stereographic(cr): if not ok: logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'latitude_of_projection_origin' parameter: " + f"Can't create coordinates for {cr!r}: " + "Bad 'latitude_of_projection_origin' parameter: " f"{latitude_of_projection_origin!r}" ) # pragma: no cover @@ -555,8 +554,8 @@ def rotated_latitude_longitude(cr): except Exception: if is_log_level_info(logger): logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Bad 'grid_north_pole_longitude' parameter: " + f"Can't create coordinates for {cr!r}: " + "Bad 'grid_north_pole_longitude' parameter: " f"{grid_north_pole_longitude!r}" ) # pragma: no cover diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 626673fb67..aafe7aa882 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -79,8 +79,8 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): except Exception: if is_log_level_info(logger): logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Must install the 'pyproj' library" + f"Can't create 2-d lat/lon coordinates: " + "Must install the 'pyproj' library" ) # pragma: no cover return (None, None) @@ -103,56 +103,12 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- # Create the source grid mapping pyproj CRS # ---------------------------------------------------------------- - match grid_mapping_name: - # The commented-out grid mappings do not yet have unit tests, - # and so are not yet available. - - # case "albers_equal_area": - # proj_src = albers_equal_area(cr) - # case "azimuthal_equidistant": - # proj_src = azimuthal_equidistant(cr) - # case "geostationary": - # proj_src = geostationary(cr) - # case "lambert_azimuthal_equal_area": - # proj_src = lambert_azimuthal_equal_area(cr) - # case "lambert_conformal_conic": - # proj_src = lambert_conformal_conic(cr) - # case "lambert_cylindrical_equal_area": - # proj_src = lambert_cylindrical_equal_area(cr) - # case "mercator": - # proj_src = mercator(cr) - # case "oblique_mercator": - # proj_src = oblique_mercator(cr) - # case "orthographic": - # proj_src = orthographic(cr) - # case "polar_stereographic": - # proj_src = polar_stereographic(cr) - case "rotated_latitude_longitude": - proj_src = rotated_latitude_longitude(cr) - # case "sinusoidal": - # proj_src = sinusoidal(cr) - # case "stereographic": - # proj_src = stereographic(cr) - # case "transverse_mercator": - # proj_src = transverse_mercator(cr) - # case "vertical_perspective": - # proj_src = vertical_perspective(cr) - case _: - if is_log_level_info(logger): - logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}" - ) # pragma: no cover - - return (None, None) - + proj_src = _create_projection_CRS(cr, grid_mapping_name) if proj_src is None: if is_log_level_info(logger): logger.info( - "Can't create 2-d latitude and longitude coordinates. " - f"Unable to create a pyproj.CRS object for {cr!r} from " - f"the grid mapping parameters: " - f"{cr.coordinate_conversion.parameters()!r}" + "Can't create 2-d lat/lon coordinates: " + f"Unable to create a pyproj.CRS object for {cr!r}" ) # pragma: no cover return (None, None) @@ -178,7 +134,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): if y.Units.equivalent(metres): y = y.to_units(metres) - # Create x and y 2-d meshes of cell centres + # Create x and y meshes of cell centres x_mesh, y_mesh = np.meshgrid(x.array, y.array) transformer = pyproj.Transformer.from_crs( @@ -187,7 +143,8 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): lon, lat = transformer.transform( x_mesh, y_mesh, errcheck=True, radians=False ) - + del x_mesh, y_mesh + lat = f._Data(lat, "degrees_north") lon = f._Data(lon, "degrees_east") @@ -202,30 +159,30 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): else: xb = xb.array yb = yb.array - xb = np.append(xb[:, 0], xb[-1, 1]) - yb = np.append(yb[:, 0], yb[-1, 1]) - # Create x and y 2-d meshes of unique vertices - x_mesh, y_mesh = np.meshgrid(xb, yb) + # Create x and y meshes of vertices. + shape = (y.size, x.size) + xb = np.broadcast_to(xb[np.newaxis, :, :], shape + (2,)) + yb = np.broadcast_to(yb[:, np.newaxis, :], shape + (2,)) + + x_mesh = np.empty(shape + (4,), dtype=xb.dtype) + y_mesh = np.empty(shape + (4,), dtype=yb.dtype) + + x_mesh[..., 0] = xb[..., 0] + y_mesh[..., 0] = yb[..., 0] + + x_mesh[..., 1] = xb[..., 0] + y_mesh[..., 1] = yb[..., 1] + + x_mesh[..., 2] = xb[..., 1] + y_mesh[..., 2] = yb[..., 1] + + x_mesh[..., 3] = xb[..., 1] + y_mesh[..., 3] = yb[..., 0] del xb, yb - lon_vertices, lat_vertices = transformer.transform(x_mesh, y_mesh) - - shape = (y.size, x.size, 4) - lat_bounds = np.empty(shape, dtype=lat_vertices.dtype) - lon_bounds = np.empty(shape, dtype=lon_vertices.dtype) - - lat_bounds[..., 0] = lat_vertices[:-1, :-1] - lon_bounds[..., 0] = lon_vertices[:-1, :-1] - - lat_bounds[..., 1] = lat_vertices[1:, :-1] - lon_bounds[..., 1] = lon_vertices[1:, :-1] - - lat_bounds[..., 2] = lat_vertices[1:, 1:] - lon_bounds[..., 2] = lon_vertices[1:, 1:] - - lat_bounds[..., 3] = lat_vertices[:-1, 1:] - lon_bounds[..., 3] = lon_vertices[:-1, 1:] + lon_bounds, lat_bounds = transformer.transform(x_mesh, y_mesh) + del x_mesh, y_mesh lat_bounds = f._Bounds(data=f._Data(lat_bounds)) lon_bounds = f._Bounds(data=f._Data(lon_bounds)) @@ -251,6 +208,191 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): return (lat_key, lon_key) +### + + +def create_1d_projection_coordinates(f, cr, cr_latlon=None, cache=True): + """Create TODO-d latitude and longitude coordinates and bounds. + + When it is not possible to create latitude and longitude + coordinates, the reason why will be reported if the log level is + at ``2``/``'INFO'`` or higher. + + See CF Appendix F: Grid Mappings. + https://doi.org/10.5281/zenodo.14274886 + + .. versionadded:: NEXTVERSION + + :Parameters: + + f: `Field` or `Domain` + The Field or Domain, which will be updated in-place, + containing non-latitude_longitude grid. + + cr: `CoordinateReference` + The coordinate reference construct for the + non-latitude_longitude grid mapping. + + cr_latlon: `CoordinateReference` or `None` + The coordinate reference construct for the + latitude_longitude grid mapping, or `None` is there isn't + one. + + cache: `bool`, optional + If True (the default) then cache in memory the first and + last of any newly-created coordinates and bounds. This may + slightly slow down the coordinate creation process, but + may greatly speed up, and reduce the memory requirement + of, a future inspection of the coordinates and + bounds. Even when *cache* is True, new cached coordinate + values can only be created if the existing 1-d coordinates + themselves have cached first and last values. + + :Returns: + + (`str`, `str`) or (`None`, `None`) + The keys of the new 2-d latitude and longitude coordinate + constructs, in that order; or two `None`s if the 2-d + coordinates could not be created. + + """ + try: + import pyproj + except Exception: + if is_log_level_info(logger): + logger.info( + "Can't create 1-d projection coordinates: " + "Must install the 'pyproj' library" + ) # pragma: no cover + + return (None, None) + + grid_mapping_name = cr.coordinate_conversion.get_parameter( + "grid_mapping_name", None + ) + if grid_mapping_name is None: + # Invalid non-latitude_longitude coordinate reference + return (None, None) + + # ---------------------------------------------------------------- + # Get the source 1-d grid coordinates and axes + # ---------------------------------------------------------------- + two_d = _get_2d_latlon_coordinates(f, cr, cr_latlon) + if two_d is None: + # Invalid 2-d lat/lon coordinates + return (None, None) + + # ---------------------------------------------------------------- + # Create the destination grid mapping `pyproj.CRS` + # ---------------------------------------------------------------- + proj_dst = _create_projection_CRS(cr, grid_mapping_name) + if proj_dst is None: + if is_log_level_info(logger): + logger.info( + "Can't create 1-d projection coordinates. " + f"Unable to create a pyproj.CRS object for {cr!r}" + ) # pragma: no cover + + return (None, None) + + # ---------------------------------------------------------------- + # Create the target latitude_longitude pyproj CRS + # ---------------------------------------------------------------- + proj_latlon = latitude_longitude(cr_latlon) + if proj_latlon is None: + # Invalid latitude_longitude coordinate reference + return (None, None) + + # ---------------------------------------------------------------- + # Create the 2-d lat/lon coordinates from 1-d grid coordinates + # ---------------------------------------------------------------- + transformer = pyproj.Transformer.from_crs( + proj_latlon, proj_dst, always_xy=True + ) + x, y = transformer.transform( + two_d['lon'].array, two_d['lat'].array, errcheck=True, radians=False + ) + + match grid_mapping_name: + case "rotated_latitude_longitude": + standard_name_x = "grid_longitude" + standard_name_y = "grid_latitude" + units = "degrees" + case _: + standard_name_x = "projection_x_coordinate" + standard_name_y = "projection_y_coordinate" + units = "m" + + if not np.allclose(x, x[0]): + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + f"{standard_name_x} coordinates are not logically 1-d" + ) # pragma: no cover + + return (None, None) + + x = x[0] + + if not np.allclose(y, y[:, :1]): + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + f"{standard_name_y} coordinates are not logically 1-d" + ) # pragma: no cover + + return (None, None) + + y = y[:, 0] + + x = f._Data(x, units) + y = f._Data(y, units) + + lon_bounds = lon.get_bounds_data(None) + lat_bounds = lat.get_bounds_data(None) + if lon_bounds is None or lat_bounds is None: + x_bounds = None + y_bounds = None + else: + x_bounds, y_bounds = transformer.transform( + lon_bounds.array, lat_bounds.array, errcheck=True, radians=False + ) + + if not np.allclose(x_bounds, x_bounds[0]): + x_bounds = None + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + f"{standard_name_x} coordinates are not logically 1-d" + ) # pragma: no cover + else: + x_bounds = x_bounds[0, :, 1:3] + + if not np.allclose(y_bounds, y_bounds[:, :1]): + y_bounds = None + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + f"{standard_name_y} coordinates are not logically 1-d" + ) # pragma: no cover + else: + y_bounds = y_bounds[:, 0, :2] + + if x_bounds is not None and y_bounds is not None: + x_bounds = f._Bounds(data=f._Data(x_bounds)) + y_bounds = f._Bounds(data=f._Data(y_bounds)) + + x = f._DimensionCoordinate( + data=x, + bounds=x_bounds, + properties={"axis": "X", "standard_name": standard_name_x}, + ) + + y = f._DimensionCoordinate( + data=y, + bounds=y_bounds, + properties={"axis": "Y", "standard_name": standard_name_y}, + ) def _get_1d_coordinates(f, cr, grid_mapping_name): """Get 1-d dimension coordinates and axes. @@ -321,8 +463,8 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): if x is None or y is None: if is_log_level_info(logger): logger.info( - "Can't create 2-d latitude and longitude coordinates " - f"for {cr!r}: Missing 1-d dimension coordinates" + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + "Missing 1-d dimension coordinates" ) # pragma: no cover return @@ -337,3 +479,111 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): "axis_x": f.get_data_axes(key_x)[0], "axis_y": f.get_data_axes(key_y)[0], } + +def _get_2d_latlon_coordinates(f, cr, cr_latlon): + """TODO""" + for ref in (cr_latlon, cr): + lat = None + lon = None + + if ref is None: + continue + + for key in ref.coordinates(): + ac = f.auxiliary_coordinate(f"key%{key}", default=None) + if ac is None: + continue + + if ac.ndim != 2: + continue + + if ac.Units.islongitude: + key_lon = key + lon = ac + elif ac.Units.islatitude: + key_lat = key + lat = ac + + if lon is not None and lat is not None: + break + + + if lon is None and lat is None: + key_lon, lon = f.auxiliary_coordinate( + 'X', filter_by_naxes=(2,), item=True, default=(None, None) + ) + key_lat, lat = f.auxiliary_coordinate( + 'Y', filter_by_naxes=(2,), item=True, default=(None, None) + ) + + if lat is None or lat.ndim !=2 or not lat.Units.islatitude: + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + "Missing 2-d latitude coordinates" + ) # pragma: no cover + + return + + if lon is None or lon.ndim !=2 or not lon.Units.islongitude: + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + "Missing 2-d longitude coordinates" + ) # pragma: no cover + + return + + axes_lat = f.get_data_axes(key_lat) + axes_lon = f.get_data_axes(key_lon) + if axes_lon != axes_lat: + axes_lon = axes_lon[::-1] + if axes_lon != axes_lat: + if is_log_level_info(logger): + logger.info( + f"Can't create 1-d projection coordinates for {cr!r}: " + "2-d lat/lon coordinates span different axes" + ) # pragma: no cover + + return + + lon = lon.transpose() + + return {'lat': lat, 'lon': lon, 'axes': axes_lat} + +def _create_projection_CRS(cr, grid_mapping_name): + match grid_mapping_name: + case "albers_equal_area": + proj = albers_equal_area(cr) + case "azimuthal_equidistant": + proj = azimuthal_equidistant(cr) + case "geostationary": + proj = geostationary(cr) + case "lambert_azimuthal_equal_area": + proj = lambert_azimuthal_equal_area(cr) + case "lambert_conformal_conic": + proj = lambert_conformal_conic(cr) + case "lambert_cylindrical_equal_area": + proj = lambert_cylindrical_equal_area(cr) + case "mercator": + proj = mercator(cr) + case "oblique_mercator": + proj = oblique_mercator(cr) + case "orthographic": + proj = orthographic(cr) + case "polar_stereographic": + proj = polar_stereographic(cr) + case "rotated_latitude_longitude": + proj = rotated_latitude_longitude(cr) + case "sinusoidal": + proj = sinusoidal(cr) + case "stereographic": + proj = stereographic(cr) + case "transverse_mercator": + proj = transverse_mercator(cr) + case "vertical_perspective": + proj = vertical_perspective(cr) + case _: + proj = None + + return proj diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index b60d4a2218..a851d04bcf 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -29,50 +29,74 @@ def test_rotated_latitude_longitude_0(self): proj_latlon = latitude_longitude(None) transformer0 = pyproj.Transformer.from_crs( - proj_latlon, proj_src, always_xy=True + proj_src, proj_latlon, always_xy=True ).transform transformer1 = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True + proj_latlon, proj_src, always_xy=True ).transform - + print() # Centres - lon0 = np.array([[1, 1]], float) - lat0 = np.array([[50, 60]], float) - gridx , gridy = transformer0(lon0, lat0) - lon1, lat1 = transformer1(gridx , gridy ) - - self.assertTrue(np.allclose(lon0, lon1)) - self.assertTrue(np.allclose(lat0, lat1)) -swap 1 and 0 - # Bounds - blon0 = np.array([[0, 0, 2, 2], [0, 0, 2, 2]], float) - blat0 = np.array([[51, 49, 49, 51], [61, 59, 59, 61]], float) - bgridx , bgridy = transformer0(blon0, blat0) - blon1, blat1 = transformer1( bgridx , bgridy) + x0 = np.array([-10, 5], float) + y0 = np.array([-10, 0, 20], float) + lon, lat = transformer0(*np.meshgrid(x0, y0)) + x1, y1 = transformer1(lon, lat) + print(x1) + print(y1) + self.assertTrue(np.allclose(x1, x1[0])) + x1 = x1[0] + self.assertTrue(np.allclose(y1, y1[:, [0]])) + y1 = y1[:,0] + self.assertTrue(np.allclose(x0,x1)) + self.assertTrue(np.allclose(y0,y1)) - self.assertTrue(np.allclose(blon0, blon1)) - self.assertTrue(np.allclose(blat0, blat1)) - print() - print('gridx=', gridx, 'bgridx=', bgridx) - print('gridy=', gridy, 'bgridy=', bgridy) + # Bounds + bx0 = np.array([[-20, 0], [0, 10]], float) + by0 = np.array([[-15, -5], [-5, 5], [15, 25]], float) + lon_bnds_2d = np.broadcast_to(bx0[np.newaxis, :, :], (3, 2, 2)) + lat_bnds_2d = np.broadcast_to(by0[:, np.newaxis, :], (3, 2, 2)) + + full_lon_bnds = np.zeros((3, 2, 4)) + full_lat_bnds = np.zeros((3, 2, 4)) + + # Corner 0: Bottom-Left (min lat, min lon) + full_lon_bnds[..., 0] = lon_bnds_2d[..., 0] + full_lat_bnds[..., 0] = lat_bnds_2d[..., 0] + + # Corner 1: Top-Left (max lat, min lon) + full_lon_bnds[..., 1] = lon_bnds_2d[..., 0] + full_lat_bnds[..., 1] = lat_bnds_2d[..., 1] + + # Corner 2: Top-Right (max lat, max lon) + full_lon_bnds[..., 2] = lon_bnds_2d[..., 1] + full_lat_bnds[..., 2] = lat_bnds_2d[..., 1] + + # Corner 3: Bottom-Right (min lat, max lon) + full_lon_bnds[..., 3] = lon_bnds_2d[..., 1] + full_lat_bnds[..., 3] = lat_bnds_2d[..., 0] + print(full_lon_bnds) + print(full_lat_bnds) + + blon, blat = transformer0(full_lon_bnds, full_lat_bnds) + bx1, by1 = transformer1( blon, blat) + print(blon) + print(blat) + self.assertTrue(np.allclose(bx1, full_lon_bnds)) + self.assertTrue(np.allclose(by1, full_lat_bnds)) # Test with Field f = cf.example_field(0) - f = f[:2, 0] + f = f[:3, :2] key_x, x = f.dimension_coordinate("X", item=True) - x.data[...] = gridx[0, 0] - print('x.array=', x.array) + x.data[...] = x0 - x.bounds.data[...] = bgridx[0, [0, -1]] + x.bounds.data[...] = bx0 x.override_units("degrees", inplace=True) x.standard_name = "grid_longitude" key_y, y = f.dimension_coordinate("Y", item=True) - y.data[...] = gridy - print('y.array=', y.array) - y.bounds.data[0] = bgridy[0, [0, 1]] - y.bounds.data[1] = bgridy[1, [0, 1]] + y.data[...] = y0 + y.bounds.data[...] = by0 y.override_units("degrees", inplace=True) y.standard_name = "grid_latitude" @@ -105,12 +129,16 @@ def test_rotated_latitude_longitude_0(self): ) lat = g.auxiliary_coordinate('latitude') - print () - print(lat.array, lat0) - self.assertTrue(np.allclose(lat.array, lat0)) + self.assertTrue(np.allclose(lat.array, lat)) + print('----------') + print(lat.bounds.array) + print(blat) + print(lat.bounds.array-blat) + self.assertTrue(np.allclose(lat.bounds.array, blat)) lon = g.auxiliary_coordinate('longitude') - self.assertTrue(np.allclose(lon.array, lon0)) + self.assertTrue(np.allclose(lon.array, lon)) + self.assertTrue(np.allclose(lon.bounds.array, blon)) if __name__ == "__main__": From a489a9d945d1ceeeceea7a8b8076961c70e363b7 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 6 Jul 2026 22:49:58 +0100 Subject: [PATCH 14/43] dev --- cf/mixin/fielddomain.py | 10 - cf/mixin/utils/grid_mapping.py | 212 +++++++++++--- cf/mixin/utils/latlon_utils.py | 513 ++++++++++++--------------------- cf/test/rotated_pole.pp | Bin 0 -> 46912 bytes cf/test/test_2d_latlon.py | 155 ++-------- 5 files changed, 385 insertions(+), 505 deletions(-) create mode 100644 cf/test/rotated_pole.pp diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 074de3ee89..2197312a39 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -1927,7 +1927,6 @@ def del_domain_axis( return domain_axis - def coordinate_reference_domain_axes(self, identity=None): """Return the domain axes that apply to a coordinate reference construct. @@ -2451,15 +2450,6 @@ def healpix_to_ugrid(self, cache=True, inplace=False): return f - @_inplace_enabled(default=False) - @_manage_log_level_via_verbosity - def create_projection_coordinates(self - overwrite=False, - cache=True, - inplace=False, - verbose=None): - """TODO""" - @_inplace_enabled(default=False) @_manage_log_level_via_verbosity def create_latlon_coordinates( diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 59a8d740a6..b9a86c7dcf 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -1,4 +1,74 @@ -"""Utilities for creating `pyproj.CRS` instances.""" +"""Utilities for creating `pyproj.CRS` instances. + +:Glossary: + +Defintions of `pyproj.CRS` parameters that map to CF grid mapping +parameters. See https://proj.org/en/stable/operations/projections for +details. + +* a: Semi-major axis of the ellipsoid. + +* alpha: Azimuth of centerline clockwise from north at the center + point of the line. If gamma is not given then alpha + determines the value of gamma. + +* b: Semi-minor axis of the ellipsoid. + +* ellps: The name of a built-in ellipsoid definition. + +* f: Flattening of the ellipsoid. + +* h: Height of the view point above the Earth and must be in the same + units as the radius of the sphere or semimajor axis of the + ellipsoid. + +* k_0: Scale factor. Determines scale factor used in the projection. + +* lat_0: Latitude of natural origin, latitude of false origin or + latitude of projection centre (naming and meaning depend on + the projection method). + +* lat_1: First standard parallel. + +* lat_2: Second standard parallel. + +* lat_ts: Defines the latitude where scale is not distorted. It is + only taken into account for Polar Stereographic formulations + (lat_0 = +/- 90 ), and then defaults to the lat_0 value. If + set to a value different from +/- 90, it takes precedence + over k_0 if both options are used together. + +* lon_0: Central meridian/longitude of natural origin, longitude of + origin or longitude of false origin (naming and meaning + depend on the projection method). + +* o_lat_p: Latitude of the North pole of the unrotated source CRS, + expressed in the rotated geographic CRS. + +* o_lon_p: Longitude of the North pole of the unrotated source CRS, + expressed in the rotated geographic CRS. + +* o_proj: Oblique projection. + +* pm: Prime meridian. + +* R: Radius of the sphere, given in meters. If used in conjunction + with ellps, R takes precedence. + +* rf: Reverse flattening of the ellipsoid, 1/f + +* sweep: Sweep angle axis of the viewing instrument. Valid options are + "x" and "y". + +* y_0: False northing, northing at false origin or northing at + projection centre (naming and meaning depend on the projection + method). Always in meters. + +* x_0: False easting, easting at false origin or easting at projection + centre (naming and meaning depend on the projection + method). Always in meters. + +""" import logging @@ -7,9 +77,13 @@ logger = logging.getLogger(__name__) -def get_ellipsoid_parameters(cr): +def _get_ellipsoid_parameters(cr): """Get ellipsoid parmaeters from a coordinate reference construct. + https://proj.org/en/stable/usage/ellipsoids.html + + https://proj.org/en/stable/usage/projections.html + .. versionadded:: NEXTVERSION :Parameters: @@ -55,7 +129,7 @@ def get_ellipsoid_parameters(cr): return kwargs -def create_proj_CRS(kwargs, cr): +def _create_pyproj_CRS(kwargs, cr): """Create a `pyproj.CRS` instance. .. versionadded:: NEXTVERSION @@ -80,21 +154,20 @@ def create_proj_CRS(kwargs, cr): # Create the `pyproj.CRS` keywword arguments, which include # parameters for describing the ellipsoid - kwargs = get_ellipsoid_parameters(cr) | kwargs + kwargs = _get_ellipsoid_parameters(cr) | kwargs # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} try: proj = pyproj.CRS(**kwargs) - except Exception: - proj = None + except Exception as error: if is_log_level_info(logger): logger.info( - f"Can't create a pyproj.CRS for {cr!r}: " - f"Bad pyproj.CRS parameters: {kwargs!r}" + f"Can't create a pyproj.CRS for {cr!r}: {error}" ) # pragma: no cover + return return proj @@ -102,8 +175,6 @@ def create_proj_CRS(kwargs, cr): # ==================================================================== # Functions for creating `pyproj.CRS` instances for each CF grid # mapping type. -# -# These functions are called by `_create_2d_latlon_coordinates`. # ==================================================================== @@ -149,7 +220,7 @@ def albers_equal_area(cr): kwargs["lat_1"] = lat_1 kwargs["lat_2"] = lat_2 - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def azimuthal_equidistant(cr): @@ -179,7 +250,7 @@ def azimuthal_equidistant(cr): "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def geostationary(cr): @@ -229,15 +300,18 @@ def geostationary(cr): ok = False if not ok: - logger.info( - f"Can't create coordinates for {cr!r}: " - f"Bad 'sweep_angle_axis' parameter: {sweep_angle_axis!r}, " - f"or bad 'fixed_angle_axis' parameter: {fixed_angle_axis!r}" - ) # pragma: no cover + if is_log_level_info(logger): + logger.info( + f"Can't create coordinates for {cr!r}: " + f"Bad 'sweep_angle_axis' parameter: {sweep_angle_axis!r}, " + f"or bad 'fixed_angle_axis' parameter: {fixed_angle_axis!r}" + ) # pragma: no cover + + return kwargs["sweep"] = sweep_angle_axis - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def lambert_azimuthal_equal_area(cr): @@ -266,7 +340,7 @@ def lambert_azimuthal_equal_area(cr): "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def lambert_conformal_conic(cr): @@ -311,7 +385,7 @@ def lambert_conformal_conic(cr): kwargs["lat_1"] = lat_1 kwargs["lat_2"] = lat_2 - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def lambert_cylindrical_equal_area(cr): @@ -346,7 +420,7 @@ def lambert_cylindrical_equal_area(cr): else: kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def latitude_longitude(cr): @@ -368,7 +442,7 @@ def latitude_longitude(cr): """ kwargs = {"proj": "longlat"} - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def mercator(cr): @@ -403,7 +477,7 @@ def mercator(cr): else: kwargs["k_0"] = p.get("scale_factor_at_projection_origin") - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def oblique_mercator(cr): @@ -434,7 +508,7 @@ def oblique_mercator(cr): "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def orthographic(cr): @@ -463,7 +537,7 @@ def orthographic(cr): "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def polar_stereographic(cr): @@ -513,20 +587,25 @@ def polar_stereographic(cr): ok = False if not ok: - logger.info( - f"Can't create coordinates for {cr!r}: " - "Bad 'latitude_of_projection_origin' parameter: " - f"{latitude_of_projection_origin!r}" - ) # pragma: no cover + if is_log_level_info(logger): + logger.info( + f"Can't create coordinates for {cr!r}: " + "Bad 'latitude_of_projection_origin' parameter: " + f"{latitude_of_projection_origin!r}" + ) # pragma: no cover + + return kwargs["lat_0"] = latitude_of_projection_origin - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def rotated_latitude_longitude(cr): """Create a rotated_latitude_longitude CRS`. + https://proj.org/en/stable/operations/projections/ob_tran.html + .. versionadded:: NEXTVERSION :Parameters: @@ -561,7 +640,7 @@ def rotated_latitude_longitude(cr): return - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def sinusoidal(cr): @@ -590,7 +669,7 @@ def sinusoidal(cr): "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def stereographic(cr): @@ -620,7 +699,7 @@ def stereographic(cr): "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def transverse_mercator(cr): @@ -652,7 +731,7 @@ def transverse_mercator(cr): "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) def vertical_perspective(cr): @@ -682,4 +761,65 @@ def vertical_perspective(cr): "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } - return create_proj_CRS(kwargs, cr) + return _create_pyproj_CRS(kwargs, cr) + + +def create_projection_CRS(cr, grid_mapping_name): + """Create a projection CRS. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` or `None` + The coordinate reference construct that defines the + projection, or `None` if the there isn't one and the + projection is latitude_longitude. + + grid_mapping_name: `str` + The ``grid_mapping_name`` parameter of *cr*. Mut be + ``'latitude_longitude'`` if *cr* is `None`. + + :Returns: + + `pyproj.CRS` or `None` + The projection CRS, or `None` if it coulcn't be created. + + """ + match grid_mapping_name: + case "albers_equal_area": + proj = albers_equal_area(cr) + case "azimuthal_equidistant": + proj = azimuthal_equidistant(cr) + case "geostationary": + proj = geostationary(cr) + case "lambert_azimuthal_equal_area": + proj = lambert_azimuthal_equal_area(cr) + case "lambert_conformal_conic": + proj = lambert_conformal_conic(cr) + case "lambert_cylindrical_equal_area": + proj = lambert_cylindrical_equal_area(cr) + case "latitude_longitude": + proj = latitude_longitude(cr) + case "mercator": + proj = mercator(cr) + case "oblique_mercator": + proj = oblique_mercator(cr) + case "orthographic": + proj = orthographic(cr) + case "polar_stereographic": + proj = polar_stereographic(cr) + case "rotated_latitude_longitude": + proj = rotated_latitude_longitude(cr) + case "sinusoidal": + proj = sinusoidal(cr) + case "stereographic": + proj = stereographic(cr) + case "transverse_mercator": + proj = transverse_mercator(cr) + case "vertical_perspective": + proj = vertical_perspective(cr) + case _: + proj = None + + return proj diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index aafe7aa882..2f80f26e23 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -7,24 +7,26 @@ from cf import Units -from .grid_mapping import ( - albers_equal_area, - azimuthal_equidistant, - geostationary, - lambert_azimuthal_equal_area, - lambert_conformal_conic, - lambert_cylindrical_equal_area, - latitude_longitude, - mercator, - oblique_mercator, - orthographic, - polar_stereographic, - rotated_latitude_longitude, - sinusoidal, - stereographic, - transverse_mercator, - vertical_perspective, -) +from .grid_mapping import create_projection_CRS + +# from .grid_mapping import ( +# albers_equal_area, +# azimuthal_equidistant, +# geostationary, +# lambert_azimuthal_equal_area, +# lambert_conformal_conic, +# lambert_cylindrical_equal_area, +# latitude_longitude, +# mercator, +# oblique_mercator, +# orthographic, +# polar_stereographic, +# rotated_latitude_longitude, +# sinusoidal, +# stereographic, +# transverse_mercator, +# vertical_perspective, +# ) logger = logging.getLogger(__name__) @@ -79,7 +81,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): except Exception: if is_log_level_info(logger): logger.info( - f"Can't create 2-d lat/lon coordinates: " + f"Can't create 2-d lat/lon coordinates for {cr!r}: " "Must install the 'pyproj' library" ) # pragma: no cover @@ -90,6 +92,12 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): ) if grid_mapping_name is None: # Invalid non-latitude_longitude coordinate reference + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + f"Unable to create a {grid_mapping_name} pyproj.CRS object" + ) # pragma: no cover + return (None, None) # ---------------------------------------------------------------- @@ -97,32 +105,51 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- one_d = _get_1d_coordinates(f, cr, grid_mapping_name) if one_d is None: - # Invalid 1-d grid coordinates + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + "Can't find all 1-d dimension coordinates" + ) # pragma: no cover + return (None, None) # ---------------------------------------------------------------- - # Create the source grid mapping pyproj CRS + # Create the source prjection CRS # ---------------------------------------------------------------- - proj_src = _create_projection_CRS(cr, grid_mapping_name) + proj_src = create_projection_CRS(cr, grid_mapping_name) if proj_src is None: if is_log_level_info(logger): logger.info( - "Can't create 2-d lat/lon coordinates: " - f"Unable to create a pyproj.CRS object for {cr!r}" + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + f"Unable to create a {grid_mapping_name} pyproj.CRS object" ) # pragma: no cover return (None, None) # ---------------------------------------------------------------- - # Create the target latitude_longitude pyproj CRS + # Create the destination latitude_longitude CRS # ---------------------------------------------------------------- - proj_latlon = latitude_longitude(cr_latlon) + proj_latlon = create_projection_CRS(cr_latlon, "latitude_longitude") if proj_latlon is None: # Invalid latitude_longitude coordinate reference + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + "Unable to create a latitude_longitude pyproj.CRS object" + ) # pragma: no cover + return (None, None) # ---------------------------------------------------------------- - # Create the 2-d lat/lon coordinates from 1-d grid coordinates + # Create the transform function from source to destination + # coordinates + # ---------------------------------------------------------------- + transformer = pyproj.Transformer.from_crs( + proj_src, proj_latlon, always_xy=True + ) + + # ---------------------------------------------------------------- + # Create 2-d lat/lon coordinate from 1-d grid coordinate centres # ---------------------------------------------------------------- x = one_d["x"] y = one_d["y"] @@ -137,14 +164,22 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # Create x and y meshes of cell centres x_mesh, y_mesh = np.meshgrid(x.array, y.array) - transformer = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True - ) - lon, lat = transformer.transform( - x_mesh, y_mesh, errcheck=True, radians=False - ) - del x_mesh, y_mesh - + try: + lon, lat = transformer.transform( + x_mesh, y_mesh, errcheck=True, radians=False + ) + except Exception as error: + # Invalid latitude_longitude coordinate reference + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + f"Error during pyproj transformation: {error}" + ) # pragma: no cover + + return (None, None) + else: + del x_mesh, y_mesh + lat = f._Data(lat, "degrees_north") lon = f._Data(lon, "degrees_east") @@ -167,13 +202,13 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): x_mesh = np.empty(shape + (4,), dtype=xb.dtype) y_mesh = np.empty(shape + (4,), dtype=yb.dtype) - + x_mesh[..., 0] = xb[..., 0] y_mesh[..., 0] = yb[..., 0] x_mesh[..., 1] = xb[..., 0] y_mesh[..., 1] = yb[..., 1] - + x_mesh[..., 2] = xb[..., 1] y_mesh[..., 2] = yb[..., 1] @@ -181,8 +216,19 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): y_mesh[..., 3] = yb[..., 0] del xb, yb - lon_bounds, lat_bounds = transformer.transform(x_mesh, y_mesh) - del x_mesh, y_mesh + try: + lon_bounds, lat_bounds = transformer.transform(x_mesh, y_mesh) + except Exception as error: + # Invalid latitude_longitude coordinate reference + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinate bounds for {cr!r}: " + f"Error during pyproj transformation: {error}" + ) # pragma: no cover + + return (None, None) + else: + del x_mesh, y_mesh lat_bounds = f._Bounds(data=f._Data(lat_bounds)) lon_bounds = f._Bounds(data=f._Data(lon_bounds)) @@ -208,191 +254,6 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): return (lat_key, lon_key) -### - - -def create_1d_projection_coordinates(f, cr, cr_latlon=None, cache=True): - """Create TODO-d latitude and longitude coordinates and bounds. - - When it is not possible to create latitude and longitude - coordinates, the reason why will be reported if the log level is - at ``2``/``'INFO'`` or higher. - - See CF Appendix F: Grid Mappings. - https://doi.org/10.5281/zenodo.14274886 - - .. versionadded:: NEXTVERSION - - :Parameters: - - f: `Field` or `Domain` - The Field or Domain, which will be updated in-place, - containing non-latitude_longitude grid. - - cr: `CoordinateReference` - The coordinate reference construct for the - non-latitude_longitude grid mapping. - - cr_latlon: `CoordinateReference` or `None` - The coordinate reference construct for the - latitude_longitude grid mapping, or `None` is there isn't - one. - - cache: `bool`, optional - If True (the default) then cache in memory the first and - last of any newly-created coordinates and bounds. This may - slightly slow down the coordinate creation process, but - may greatly speed up, and reduce the memory requirement - of, a future inspection of the coordinates and - bounds. Even when *cache* is True, new cached coordinate - values can only be created if the existing 1-d coordinates - themselves have cached first and last values. - - :Returns: - - (`str`, `str`) or (`None`, `None`) - The keys of the new 2-d latitude and longitude coordinate - constructs, in that order; or two `None`s if the 2-d - coordinates could not be created. - - """ - try: - import pyproj - except Exception: - if is_log_level_info(logger): - logger.info( - "Can't create 1-d projection coordinates: " - "Must install the 'pyproj' library" - ) # pragma: no cover - - return (None, None) - - grid_mapping_name = cr.coordinate_conversion.get_parameter( - "grid_mapping_name", None - ) - if grid_mapping_name is None: - # Invalid non-latitude_longitude coordinate reference - return (None, None) - - # ---------------------------------------------------------------- - # Get the source 1-d grid coordinates and axes - # ---------------------------------------------------------------- - two_d = _get_2d_latlon_coordinates(f, cr, cr_latlon) - if two_d is None: - # Invalid 2-d lat/lon coordinates - return (None, None) - - # ---------------------------------------------------------------- - # Create the destination grid mapping `pyproj.CRS` - # ---------------------------------------------------------------- - proj_dst = _create_projection_CRS(cr, grid_mapping_name) - if proj_dst is None: - if is_log_level_info(logger): - logger.info( - "Can't create 1-d projection coordinates. " - f"Unable to create a pyproj.CRS object for {cr!r}" - ) # pragma: no cover - - return (None, None) - - # ---------------------------------------------------------------- - # Create the target latitude_longitude pyproj CRS - # ---------------------------------------------------------------- - proj_latlon = latitude_longitude(cr_latlon) - if proj_latlon is None: - # Invalid latitude_longitude coordinate reference - return (None, None) - - # ---------------------------------------------------------------- - # Create the 2-d lat/lon coordinates from 1-d grid coordinates - # ---------------------------------------------------------------- - transformer = pyproj.Transformer.from_crs( - proj_latlon, proj_dst, always_xy=True - ) - x, y = transformer.transform( - two_d['lon'].array, two_d['lat'].array, errcheck=True, radians=False - ) - - match grid_mapping_name: - case "rotated_latitude_longitude": - standard_name_x = "grid_longitude" - standard_name_y = "grid_latitude" - units = "degrees" - case _: - standard_name_x = "projection_x_coordinate" - standard_name_y = "projection_y_coordinate" - units = "m" - - if not np.allclose(x, x[0]): - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - f"{standard_name_x} coordinates are not logically 1-d" - ) # pragma: no cover - - return (None, None) - - x = x[0] - - if not np.allclose(y, y[:, :1]): - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - f"{standard_name_y} coordinates are not logically 1-d" - ) # pragma: no cover - - return (None, None) - - y = y[:, 0] - - x = f._Data(x, units) - y = f._Data(y, units) - - lon_bounds = lon.get_bounds_data(None) - lat_bounds = lat.get_bounds_data(None) - if lon_bounds is None or lat_bounds is None: - x_bounds = None - y_bounds = None - else: - x_bounds, y_bounds = transformer.transform( - lon_bounds.array, lat_bounds.array, errcheck=True, radians=False - ) - - if not np.allclose(x_bounds, x_bounds[0]): - x_bounds = None - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - f"{standard_name_x} coordinates are not logically 1-d" - ) # pragma: no cover - else: - x_bounds = x_bounds[0, :, 1:3] - - if not np.allclose(y_bounds, y_bounds[:, :1]): - y_bounds = None - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - f"{standard_name_y} coordinates are not logically 1-d" - ) # pragma: no cover - else: - y_bounds = y_bounds[:, 0, :2] - - if x_bounds is not None and y_bounds is not None: - x_bounds = f._Bounds(data=f._Data(x_bounds)) - y_bounds = f._Bounds(data=f._Data(y_bounds)) - - x = f._DimensionCoordinate( - data=x, - bounds=x_bounds, - properties={"axis": "X", "standard_name": standard_name_x}, - ) - - y = f._DimensionCoordinate( - data=y, - bounds=y_bounds, - properties={"axis": "Y", "standard_name": standard_name_y}, - ) def _get_1d_coordinates(f, cr, grid_mapping_name): """Get 1-d dimension coordinates and axes. @@ -461,12 +322,7 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): ) if x is None or y is None: - if is_log_level_info(logger): - logger.info( - f"Can't create 2-d lat/lon coordinates for {cr!r}: " - "Missing 1-d dimension coordinates" - ) # pragma: no cover - + # Can't find all 1-d dimension coordinates return # Make sure the 1-d coordinates are referenced from the coordinate @@ -480,110 +336,95 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): "axis_y": f.get_data_axes(key_y)[0], } -def _get_2d_latlon_coordinates(f, cr, cr_latlon): - """TODO""" - for ref in (cr_latlon, cr): - lat = None - lon = None - - if ref is None: - continue - - for key in ref.coordinates(): - ac = f.auxiliary_coordinate(f"key%{key}", default=None) - if ac is None: - continue - - if ac.ndim != 2: - continue - - if ac.Units.islongitude: - key_lon = key - lon = ac - elif ac.Units.islatitude: - key_lat = key - lat = ac - - if lon is not None and lat is not None: - break - - - if lon is None and lat is None: - key_lon, lon = f.auxiliary_coordinate( - 'X', filter_by_naxes=(2,), item=True, default=(None, None) - ) - key_lat, lat = f.auxiliary_coordinate( - 'Y', filter_by_naxes=(2,), item=True, default=(None, None) - ) - - if lat is None or lat.ndim !=2 or not lat.Units.islatitude: - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - "Missing 2-d latitude coordinates" - ) # pragma: no cover - - return - - if lon is None or lon.ndim !=2 or not lon.Units.islongitude: - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - "Missing 2-d longitude coordinates" - ) # pragma: no cover - return - - axes_lat = f.get_data_axes(key_lat) - axes_lon = f.get_data_axes(key_lon) - if axes_lon != axes_lat: - axes_lon = axes_lon[::-1] - if axes_lon != axes_lat: - if is_log_level_info(logger): - logger.info( - f"Can't create 1-d projection coordinates for {cr!r}: " - "2-d lat/lon coordinates span different axes" - ) # pragma: no cover - - return - - lon = lon.transpose() - - return {'lat': lat, 'lon': lon, 'axes': axes_lat} - -def _create_projection_CRS(cr, grid_mapping_name): - match grid_mapping_name: - case "albers_equal_area": - proj = albers_equal_area(cr) - case "azimuthal_equidistant": - proj = azimuthal_equidistant(cr) - case "geostationary": - proj = geostationary(cr) - case "lambert_azimuthal_equal_area": - proj = lambert_azimuthal_equal_area(cr) - case "lambert_conformal_conic": - proj = lambert_conformal_conic(cr) - case "lambert_cylindrical_equal_area": - proj = lambert_cylindrical_equal_area(cr) - case "mercator": - proj = mercator(cr) - case "oblique_mercator": - proj = oblique_mercator(cr) - case "orthographic": - proj = orthographic(cr) - case "polar_stereographic": - proj = polar_stereographic(cr) - case "rotated_latitude_longitude": - proj = rotated_latitude_longitude(cr) - case "sinusoidal": - proj = sinusoidal(cr) - case "stereographic": - proj = stereographic(cr) - case "transverse_mercator": - proj = transverse_mercator(cr) - case "vertical_perspective": - proj = vertical_perspective(cr) - case _: - proj = None - - return proj +# def _create_projection_CRS(cr, grid_mapping_name): +# """Create a projection CRS. +# +# .. versionadded:: NEXTVERSION +# +# :Parameters: +# +# cr: `CoordinateReference` or `None` +# The coordinate reference construct that defines the +# projection, or `None` if the there isn't one and the +# projetion is latitude_longitude. +# +# grid_mapping_name: `str` +# The ``grid_mapping_name`` parameter of *cr*. Mut be +# ``'latitude_longitude'`` if *cr* is `None`. +# +# :Returns: +# +# `pyproj.CRS` or `None` +# The projection CRS, or `None` if it coulcn't be created. +# +# """ +# match grid_mapping_name: +# case "albers_equal_area": +# from .grid_mapping import albers_equal_area +# +# proj = albers_equal_area(cr) +# case "azimuthal_equidistant": +# from .grid_mapping import azimuthal_equidistant +# +# proj = azimuthal_equidistant(cr) +# case "geostationary": +# from .grid_mapping import geostationary +# +# proj = geostationary(cr) +# case "lambert_azimuthal_equal_area": +# from .grid_mapping import lambert_azimuthal_equal_area +# +# proj = lambert_azimuthal_equal_area(cr) +# case "lambert_conformal_conic": +# from .grid_mapping import ambert_conformal_conic +# +# proj = lambert_conformal_conic(cr) +# case "lambert_cylindrical_equal_area": +# from .grid_mapping import lambert_cylindrical_equal_area +# +# proj = lambert_cylindrical_equal_area(cr) +# case "latitude_longitude": +# from .grid_mapping import latitude_longitude +# +# proj = latitude_longitude(cr) +# case "mercator": +# from .grid_mapping import mercator +# +# proj = mercator(cr) +# case "oblique_mercator": +# from .grid_mapping import oblique_mercator +# +# proj = oblique_mercator(cr) +# case "orthographic": +# from .grid_mapping import orthographic +# +# proj = orthographic(cr) +# case "polar_stereographic": +# from .grid_mapping import polar_stereographic +# +# proj = polar_stereographic(cr) +# case "rotated_latitude_longitude": +# from .grid_mapping import rotated_latitude_longitude +# +# proj = rotated_latitude_longitude(cr) +# case "sinusoidal": +# from .grid_mapping import sinusoidal +# +# proj = sinusoidal(cr) +# case "stereographic": +# from .grid_mapping import stereographic +# +# proj = stereographic(cr) +# case "transverse_mercator": +# from .grid_mapping import transverse_mercator +# +# proj = transverse_mercator(cr) +# case "vertical_perspective": +# from .grid_mapping import vertical_perspective +# +# proj = vertical_perspective(cr) +# case _: +# proj = None +# +# return proj diff --git a/cf/test/rotated_pole.pp b/cf/test/rotated_pole.pp new file mode 100644 index 0000000000000000000000000000000000000000..7088c9ae99edfa132e8e80f00547a359dcde27a0 GIT binary patch literal 46912 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

    ;e1oJA{!MNq@k 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 literal 0 HcmV?d00001 diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index a851d04bcf..c179cb0666 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -7,138 +7,47 @@ class LatLon2dTest(unittest.TestCase): - """Test the creation of 2-d lat/lon.""" + """Test the creation of 2-d lat/lon coordinatesx.""" - def test_rotated_latitude_longitude_0(self): + def test_rotated_latitude_longitude(self): """Test rotated_latitude_longitude.""" - # Test round trip - import pyproj - - from cf.mixin.utils.grid_mapping import ( - latitude_longitude, - rotated_latitude_longitude, + f = cf.read("rotated_pole.pp")[0] + + cr = f.coordinate_reference() + self.assertEqual( + cr.coordinate_conversion.parameters(), + { + "grid_mapping_name": "rotated_latitude_longitude", + "grid_north_pole_latitude": 38, + "grid_north_pole_longitude": 190, + }, ) - cr = cf.CoordinateReference() - cc = cr.coordinate_conversion - cc.set_parameter("grid_mapping_name", "rotated_latitude_longitude") - cc.set_parameter("grid_north_pole_latitude", 38.0) - cc.set_parameter("grid_north_pole_longitude", 190.0) - - proj_src = rotated_latitude_longitude(cr) - proj_latlon = latitude_longitude(None) - - transformer0 = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True - ).transform - transformer1 = pyproj.Transformer.from_crs( - proj_latlon, proj_src, always_xy=True - ).transform - print() - # Centres - x0 = np.array([-10, 5], float) - y0 = np.array([-10, 0, 20], float) - lon, lat = transformer0(*np.meshgrid(x0, y0)) - x1, y1 = transformer1(lon, lat) - print(x1) - print(y1) - self.assertTrue(np.allclose(x1, x1[0])) - x1 = x1[0] - self.assertTrue(np.allclose(y1, y1[:, [0]])) - y1 = y1[:,0] - self.assertTrue(np.allclose(x0,x1)) - self.assertTrue(np.allclose(y0,y1)) + self.assertFalse(f.auxiliary_coordinates()) - # Bounds - bx0 = np.array([[-20, 0], [0, 10]], float) - by0 = np.array([[-15, -5], [-5, 5], [15, 25]], float) - lon_bnds_2d = np.broadcast_to(bx0[np.newaxis, :, :], (3, 2, 2)) - lat_bnds_2d = np.broadcast_to(by0[:, np.newaxis, :], (3, 2, 2)) - - full_lon_bnds = np.zeros((3, 2, 4)) - full_lat_bnds = np.zeros((3, 2, 4)) - - # Corner 0: Bottom-Left (min lat, min lon) - full_lon_bnds[..., 0] = lon_bnds_2d[..., 0] - full_lat_bnds[..., 0] = lat_bnds_2d[..., 0] - - # Corner 1: Top-Left (max lat, min lon) - full_lon_bnds[..., 1] = lon_bnds_2d[..., 0] - full_lat_bnds[..., 1] = lat_bnds_2d[..., 1] + self.assertIsNone(f.create_latlon_coordinates(inplace=True)) - # Corner 2: Top-Right (max lat, max lon) - full_lon_bnds[..., 2] = lon_bnds_2d[..., 1] - full_lat_bnds[..., 2] = lat_bnds_2d[..., 1] - - # Corner 3: Bottom-Right (min lat, max lon) - full_lon_bnds[..., 3] = lon_bnds_2d[..., 1] - full_lat_bnds[..., 3] = lat_bnds_2d[..., 0] - print(full_lon_bnds) - print(full_lat_bnds) - - blon, blat = transformer0(full_lon_bnds, full_lat_bnds) - bx1, by1 = transformer1( blon, blat) - print(blon) - print(blat) - self.assertTrue(np.allclose(bx1, full_lon_bnds)) - self.assertTrue(np.allclose(by1, full_lat_bnds)) - - # Test with Field - f = cf.example_field(0) - f = f[:3, :2] - - key_x, x = f.dimension_coordinate("X", item=True) - x.data[...] = x0 - - x.bounds.data[...] = bx0 - x.override_units("degrees", inplace=True) - x.standard_name = "grid_longitude" - - key_y, y = f.dimension_coordinate("Y", item=True) - y.data[...] = y0 - y.bounds.data[...] = by0 - y.override_units("degrees", inplace=True) - y.standard_name = "grid_latitude" - - fcr = cf.CoordinateReference() - fcr.coordinate_conversion.set_parameter( - "grid_mapping_name", "rotated_latitude_longitude" - ) - fcr.coordinate_conversion.set_parameter( - "grid_north_pole_latitude", 38.0 - ) - fcr.coordinate_conversion.set_parameter( - "grid_north_pole_longitude", 190.0 + # Compare the 2-d lat/lon corodinates against + # known-to-be-correct values + lat = f.auxiliary_coordinate("latitude") + self.assertEqual(lat.shape, (110, 106)) + self.assertTrue(np.allclose(lat[0, 0].array, 67.1246604)) + self.assertTrue( + np.allclose( + lat[0, 0].bounds.array, + [67.13411912, 66.82618815, 67.11220769, 67.42286415], + ) ) - f.set_construct(fcr, copy=False) - - self.assertEqual(len(f.auxiliary_coordinates()), 0) - for coordinates in (set(), {key_x, key_y}): - fcr.clear_coordinates() - fcr.set_coordinates(coordinates) - - g = f.create_latlon_coordinates() - - self.assertEqual(len(g.auxiliary_coordinates()), 2) - - gcr = g.coordinate_reference() - self.assertEqual( - gcr.coordinates(), - {key_x, key_y, "auxiliarycoordinate0", "auxiliarycoordinate1"}, + lon = f.auxiliary_coordinate("longitude") + self.assertEqual(lon.shape, (110, 106)) + self.assertTrue(np.allclose(lon[0, 0].array, -45.98136153)) + self.assertTrue( + np.allclose( + lon[0, 0].bounds.array, + [-46.7492162, -45.94548426, -45.21355527, -46.01992883], ) - - lat = g.auxiliary_coordinate('latitude') - self.assertTrue(np.allclose(lat.array, lat)) - print('----------') - print(lat.bounds.array) - print(blat) - print(lat.bounds.array-blat) - self.assertTrue(np.allclose(lat.bounds.array, blat)) - - lon = g.auxiliary_coordinate('longitude') - self.assertTrue(np.allclose(lon.array, lon)) - self.assertTrue(np.allclose(lon.bounds.array, blon)) + ) if __name__ == "__main__": From 0a8871136ada3fd89d7ea108ccb28e70507ec02d Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 8 Jul 2026 22:38:27 +0100 Subject: [PATCH 15/43] dev --- cf/data/array/aggregatedarray.py | 2 +- cf/data/array/umarray.py | 14 ++-- cf/functions.py | 136 +++++-------------------------- cf/read_write/read.py | 134 +++++++----------------------- cf/test/test_2d_latlon.py | 2 +- cf/test/test_CFA.py | 4 +- cf/test/test_kerchunk.py | 7 +- cf/test/test_pp.py | 27 +++--- cf/test/test_quantization.py | 20 ++--- cf/test/test_read_write.py | 48 ++++++----- cf/test/test_zarr.py | 7 -- 11 files changed, 106 insertions(+), 295 deletions(-) diff --git a/cf/data/array/aggregatedarray.py b/cf/data/array/aggregatedarray.py index 1c451bb2fb..f159e1efaf 100644 --- a/cf/data/array/aggregatedarray.py +++ b/cf/data/array/aggregatedarray.py @@ -19,5 +19,5 @@ def __new__(cls, *args, **kwargs): """ # Override the inherited FragmentFileArray class instance = super().__new__(cls) - instance._FragmentArray["uri"] = FragmentFileArray + instance._AggregatedArray__FragmentArray["uri"] = FragmentFileArray return instance diff --git a/cf/data/array/umarray.py b/cf/data/array/umarray.py index c097836d89..5c236783e4 100644 --- a/cf/data/array/umarray.py +++ b/cf/data/array/umarray.py @@ -71,14 +71,6 @@ def __init__( .. versionadded:: 3.16.3 - {{init storage_protocol: `None` or `str`, optional}} - - .. versionadded:: 3.20.0 - - {{init storage_options: `dict` or `None`, optional}} - - .. versionadded:: 3.20.0 - {{init source: optional}} {{init copy: `bool`, optional}} @@ -107,6 +99,12 @@ def __init__( Deprecated at version 3.16.3. Use the *attributes* parameter instead. + storage_protocol: Deprecated at version NEXTVERSION + Use *filesystem* instead. + + storage_protocol: Deprecated at version NEXTVERSION + Use *filesystem* instead. + """ super().__init__( filename=filename, diff --git a/cf/functions.py b/cf/functions.py index ebbc9f4feb..d4943bcbd4 100644 --- a/cf/functions.py +++ b/cf/functions.py @@ -2469,7 +2469,9 @@ def equivalent(x, y, rtol=None, atol=None, traceback=False): ) -def load_stash2standard_name(table=None, delimiter="!", merge=True): +def load_stash2standard_name( + table=None, delimiter="!", merge=True, reset=False +): """Load a STASH to standard name conversion table from a file. This used when reading PP and UM fields files. @@ -2502,7 +2504,7 @@ def load_stash2standard_name(table=None, delimiter="!", merge=True): :Parameters: - table: `str`, optional + table: `str` or `None`, optional Use the conversion table at this file location. By default the table will be looked for at ``os.path.join(os.path.dirname(cf.__file__),'etc/STASH_to_CF.txt')`` @@ -2521,8 +2523,7 @@ def load_stash2standard_name(table=None, delimiter="!", merge=True): into the existing table, overwriting any entries which already exist. - If *table* is `None` then *merge* is taken as False, - regardless of its given value. + :Returns: @@ -2538,121 +2539,17 @@ def load_stash2standard_name(table=None, delimiter="!", merge=True): >>> cf.load_stash2standard_name('my_table4.txt', merge=False) """ - import csv - import re - - # 0 Model - # 1 STASH code - # 2 STASH name - # 3 units - # 4 valid from UM vn - # 5 valid to UM vn - # 6 standard_name - # 7 CF extra info - # 8 PP extra info - # Number matching regular expression - number_regex = r"([-+]?\d*\.?\d+(e[-+]?\d+)?)" - - if table is None: - # Use default conversion table - merge = False - package_path = os.path.dirname(__file__) - table = os.path.join(package_path, "etc/STASH_to_CF.txt") - else: - # User supplied table - table = abspath(os.path.expanduser(os.path.expandvars(table))) - - with open(table, "r") as open_table: - lines = csv.reader( - open_table, delimiter=delimiter, skipinitialspace=True - ) - lines = list(lines) - - raw_list = [] - [raw_list.append(line) for line in lines] - - # Get rid of comments - for line in raw_list[:]: - if line[0].startswith("#"): - raw_list.pop(0) - continue - - break - - # Convert to a dictionary which is keyed by (submodel, STASHcode) - # tuples - ( - model, - stash, - name, - units, - valid_from, - valid_to, - standard_name, - cf, - pp, - ) = list(range(9)) - - stash2sn = {} - for x in raw_list: - key = (int(x[model]), int(x[stash])) - - if not x[units]: - x[units] = None - - try: - cf_info = {} - if x[cf]: - for d in x[7].split(): - if d.startswith("height="): - cf_info["height"] = re.split( - number_regex, d, re.IGNORECASE - )[1:4:2] - if cf_info["height"] == "": - cf_info["height"][1] = "1" - - if d.startswith("below_"): - cf_info["below"] = re.split( - number_regex, d, re.IGNORECASE - )[1:4:2] - if cf_info["below"] == "": - cf_info["below"][1] = "1" - - if d.startswith("where_"): - cf_info["where"] = d.replace("where_", "where ", 1) - if d.startswith("over_"): - cf_info["over"] = d.replace("over_", "over ", 1) - - x[cf] = cf_info - except IndexError: - pass - - try: - x[valid_from] = float(x[valid_from]) - except ValueError: - x[valid_from] = None - - try: - x[valid_to] = float(x[valid_to]) - except ValueError: - x[valid_to] = None - - x[pp] = x[pp].rstrip() - - line = (x[name:],) - - if key in stash2sn: - stash2sn[key] += line - else: - stash2sn[key] = line - - if not merge: - _stash2standard_name.clear() + try: + import umfive + except Exception: + return - _stash2standard_name.update(stash2sn) + umfive.load_stash_table( + table=table, delimiter=delimiter, merge=merge, reset=reset + ) -def stash2standard_name(): +def stash2standard_name(reset=False): """Return a copy of the loaded STASH to standard name conversion table. @@ -2661,7 +2558,12 @@ def stash2standard_name(): .. seealso:: `load_stash2standard_name` """ - return _stash2standard_name.copy() + try: + import umfive + except Exception: + return {} + + return umfive.stash_table(reset=reset) def flat(x): diff --git a/cf/read_write/read.py b/cf/read_write/read.py index 1f18c6b4c5..8ebdc36596 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -94,16 +94,12 @@ class read(cfdm.read): **PP and UM fields files** - 32-bit and 64-bit PP and UM fields files of any endian-ness can be - read. In nearly all cases the file format is auto-detected from - the first 64 bits in the file, but for the few occasions when this - is not possible, the *um* keyword allows the format to be - specified, as well as the UM version (if the latter is not - inferrable from the PP or lookup header information). - - 2-d "slices" within a single file are always combined, where - possible, into field constructs with 3-d, 4-d or 5-d data. This is - done prior to any field construct aggregation (see the *aggregate* + 32-bit and 64-bit Met Office (UK) PP files and Met Office (UK) + fields files of any endian-ness can be read. + + 2-d "slices" within a single file are combined, where possible, + into field constructs with 3-d, 4-d or 5-d data. This is done + prior to any field construct aggregation (see the *aggregate* parameter). When reading PP and UM fields files, the *relaxed_units* aggregate @@ -176,76 +172,7 @@ class read(cfdm.read): {{read warnings: `bool`, optional}} - um: `dict`, optional - For Met Office (UK) PP files and Met Office (UK) fields - files only, provide extra decoding instructions. This - option is ignored for input files which are not PP or - fields files. In most cases, how to decode a file is - inferrable from the file's contents, but if not then each - key/value pair in the dictionary sets a decoding option as - follows: - - * ``'fmt'``: `str` - - The file format (``'PP'`` or ``'FF'``) - - * ``'word_size'``: `int` - - The word size in bytes (``4`` or ``8``). - - * ``'endian'``: `str` - - The byte order (``'big'`` or ``'little'``). - - * ``'version'``: `int` or `str` - - The UM version to be used when decoding the - header. Valid versions are, for example, ``4.2``, - ``'6.6.3'`` and ``'8.2'``. In general, a given version - is ignored if it can be inferred from the header (which - is usually the case for files created by the UM at - versions 5.3 and later). The exception to this is when - the given version has a third element (such as the 3 in - 6.6.3), in which case any version in the header is - ignored. The default version is ``4.5``. - - * ``'height_at_top_of_model'``: `float` - - The height in metres of the upper bound of the top model - level. By default the height at top model is taken from - the top level's upper bound defined by BRSVD1 in the - lookup header. If the height can't be determined from - the header, or the given height is less than or equal to - 0, then a coordinate reference system will still be - created that contains the 'a' and 'b' formula term - values, but without an atmosphere hybrid height - dimension coordinate construct. - - .. note:: A current limitation is that if pseudolevels - and atmosphere hybrid height coordinates are - defined by same the lookup headers then the - height **can't be determined - automatically**. In this case the height may - be found after reading as the maximum value of - the bounds of the domain ancillary construct - containing the 'a' formula term. The file can - then be re-read with this height as a *um* - parameter. - - If format is specified as ``'PP'`` then the word size and - byte order default to ``4`` and ``'big'`` respectively. - - This parameter replaces the deprecated *umversion* and - *height_at_top_of_model* parameters. - - *Parameter example:* - To specify that the input files are 32-bit, big-endian - PP files: ``um={'fmt': 'PP'}`` - - *Parameter example:* - To specify that the input files are 32-bit, - little-endian PP files from version 5.1 of the UM: - ``um={'fmt': 'PP', 'endian': 'little', 'version': 5.1}`` + {{read um: `dict` or `None`, optional}} .. versionadded:: 1.5 @@ -253,16 +180,17 @@ class read(cfdm.read): If True then read the datasets with the legacy UM backend that is embedded within the cf library, which was the only backend available prior to version NEXTVERSION. From - version NEXTVERSION onwards, the `ppfive` UM backend + version NEXTVERSION onwards, the `umfive` UM backend provided by `xnetcdf` is used when *legacy_um_backend* is False (the default). - .. note:: The *legacy_um_backend* parameter will - eventually be removed, at which time only the - `ppfive` UM backend provided by `xnetcdf` will - be available. If there are questions about the - parsing of UM datasets, please raise an issue at - https://github.com/NCAS-CMS/ppfive/issues. + .. note:: The *legacy_um_backend* parameter will be + removed at a future version, at which time only + the `umfive` UM backend (provided via `xnetcdf`) + will be available. If there are questions about + the parsing of UM datasets, please raise an + issue at + https://github.com/NCAS-CMS/umfive/issues. .. versionadded:: NEXTVERSION @@ -314,9 +242,13 @@ class read(cfdm.read): .. versionadded:: 3.11.0 - {{read netcdf_backend: `None` or (sequence of) `str`, optional}} + {{read backend: `None` or (sequence of) `str`, optional}} - .. versionadded:: 3.17.0 + .. versionadded:: (cfdm) NEXTVERSION + + {{read backend_options: `None` or `dict`, optional}} + + .. versionadded:: (cfdm) NEXTVERSION {{read storage_options: `dict` or `None`, optional}} @@ -388,6 +320,9 @@ class read(cfdm.read): file_type: deprecated at version 3.18.0 Use the *dataset_type* parameter instead. + netcdf_backend: Deprecated at version NEXTVERSION + Use *backend* instead. + :Returns: `FieldList` or `DomainList` @@ -469,7 +404,8 @@ def __new__( cfa=None, cfa_write=None, to_memory=None, - netcdf_backend=None, + backend=None, + backend_options=None, storage_options=None, cache=True, chunks="auto", @@ -479,6 +415,7 @@ def __new__( group_dimension_search="closest_ancestor", filesystem=None, legacy_um_backend=False, + netcdf_backend=None, ): """Read field or domain constructs from a dataset.""" kwargs = locals() @@ -730,22 +667,17 @@ def _read(self, dataset): # ------------------------------------------------------------ if not legacy_um_backend: super()._read(dataset) - - if self.dataset_contents is not None: - # Successfully read the dataset - return - else: # ------------------------------------------------------------ # Read as a PP/UM dataset using the legacy UM backend # ------------------------------------------------------------ logger.warning( - "The 'legacy_um_backend' parameter will eventually be " - "removed, at which time only the `ppfive` UM backend " - "provided by `ppfive` will be available. " + "The 'legacy_um_backend' parameter will be removed " + "at a future version, at which time only the `umfive` " + "UM backend (provided via `xnetcdf`) will be available. " "If there are questions about the parsing of UM datasets, " "please raise an issue at " - "https://github.com/NCAS-CMS/ppfive/issues" + "https://github.com/NCAS-CMS/umfive/issues" ) if dataset_type is None or dataset_type.intersection( @@ -789,7 +721,3 @@ def _read(self, dataset): else: # Successfully read the dataset self.unique_dataset_categories.add("UM") - - if self.dataset_contents is not None: - # Successfully read the dataset - return diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index c179cb0666..263f6d5334 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -26,7 +26,7 @@ def test_rotated_latitude_longitude(self): self.assertFalse(f.auxiliary_coordinates()) self.assertIsNone(f.create_latlon_coordinates(inplace=True)) - + # Compare the 2-d lat/lon corodinates against # known-to-be-correct values lat = f.auxiliary_coordinate("latitude") diff --git a/cf/test/test_CFA.py b/cf/test/test_CFA.py index 4745b55c0c..324fe38bf8 100644 --- a/cf/test/test_CFA.py +++ b/cf/test/test_CFA.py @@ -287,8 +287,8 @@ def test_CFA_unique_value(self): self.assertEqual( fa.data.nc_get_aggregated_data(), { - "map": "fragment_map_uid", - "unique_values": "fragment_value_uid", + "map": "/fragment_map_uid", + "unique_values": "/fragment_value_uid", }, ) diff --git a/cf/test/test_kerchunk.py b/cf/test/test_kerchunk.py index b37d09718d..c01bc9c999 100644 --- a/cf/test/test_kerchunk.py +++ b/cf/test/test_kerchunk.py @@ -66,11 +66,11 @@ def test_kerchunk_original_filenames(self): self.assertEqual(k.get_original_filenames(), set()) def test_read_dict(self): - """Test cf.read with an Kerchunk dictionary.""" + """Test cfdm.read with an Kerchunk dictionary.""" with open(kerchunk_file, "r") as fh: d = json.load(fh) - with self.assertRaises(ValueError): + with self.assertRaises(Exception): cf.read(d) fs = fsspec.filesystem("reference", fo=d) @@ -78,7 +78,7 @@ def test_read_dict(self): self.assertEqual(len(cf.read(kerchunk)), 1) def test_read_bytes(self): - """Test cf.read with a Kerchunk raw bytes representation.""" + """Test cfdm.read with a Kerchunk raw bytes representation.""" with open(kerchunk_file, "r") as fh: d = json.load(fh) @@ -91,7 +91,6 @@ def test_read_bytes(self): kerchunk = fs.get_mapper() self.assertEqual(len(cf.read(kerchunk)), 1) - if __name__ == "__main__": print("Run date:", datetime.datetime.now()) cf.environment() diff --git a/cf/test/test_pp.py b/cf/test/test_pp.py index dfe5d259ef..aba610fcff 100644 --- a/cf/test/test_pp.py +++ b/cf/test/test_pp.py @@ -54,26 +54,19 @@ class ppTest(unittest.TestCase): def test_PP_read_um(self): f = cf.read(self.ppextradata)[0] - g = cf.read(self.ppextradata, um={"fmt": "pp"})[0] + g = cf.read(self.ppextradata)[0] self.assertTrue(f.equals(g)) - for vn in (4.5, 405, "4.5"): - g = cf.read(self.ppextradata, um={"fmt": "pp", "version": vn})[0] - self.assertTrue(f.equals(g)) + g = cf.read(self.ppextradata, um={"um_version": "4.5"})[0] + self.assertTrue(f.equals(g)) p = cf.read("wgdos_packed.pp")[0] p0 = cf.read( "wgdos_packed.pp", - um={ - "fmt": "PP", - "endian": "little", - "word_size": 4, - "version": 4.5, - "height_at_top_of_model": 23423.65, - }, + um={"um_version": "4.5", "height_at_top_of_model": 23423.65}, )[0] - self.assertTrue(p.equals(p0, verbose=2)) + self.assertTrue(p.equals(p0)) def test_load_stash2standard_name(self): f = cf.read(self.ppfile)[0] @@ -85,7 +78,7 @@ def test_load_stash2standard_name(self): f = cf.read(self.ppfile)[0] self.assertEqual(f.identity(), "NEW_NAME") self.assertEqual(f.Units, cf.Units("Pa")) - cf.load_stash2standard_name() + cf.load_stash2standard_name(reset=True) f = cf.read(self.ppfile)[0] self.assertEqual(f.identity(), "eastward_wind") self.assertEqual(f.Units, cf.Units("m s-1")) @@ -117,7 +110,7 @@ def test_PP_WGDOS_UNPACKING(self): g = cf.read(tmpfile)[0] self.assertTrue((f.array == array).all()) - self.assertTrue(f.equals(g, verbose=2)) + self.assertTrue(f.equals(g)) def test_PP_extra_data(self): f = cf.read(self.ppextradata)[0] @@ -128,9 +121,9 @@ def test_PP_extra_data(self): sites = f.dimension_coordinate("long_name=site") self.assertTrue(np.allclose(sites, [1, 2, 3])) - regions = f.auxiliary_coordinate("region").array + regions = f.auxiliary_coordinate("region") self.assertEqual( - regions.tolist(), + regions.array.tolist(), ["Northern Hemisphere", "Southern Hemisphere", "Global"], ) @@ -142,7 +135,7 @@ def test_PP_um_version(self): f = cf.read(self.ppfile)[0] self.assertEqual(f.get_property("um_version"), "11.0") - f = cf.read(self.ppfile, um={"version": "6.6.3"})[0] + f = cf.read(self.ppfile, um={"um_version": "6.6.3"})[0] self.assertEqual(f.get_property("um_version"), "6.6.3") # def test_PP_file_object(self): diff --git a/cf/test/test_quantization.py b/cf/test/test_quantization.py index f5a08326d1..0f3f543197 100644 --- a/cf/test/test_quantization.py +++ b/cf/test/test_quantization.py @@ -78,7 +78,7 @@ def test_quantization_read_write(self): f.set_quantize_on_write(q0) # Write the field and read it back in - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") g = cf.read(tmpfile1)[0] # Check that f and g have different data (i.e. that @@ -189,7 +189,7 @@ def test_quantization_write_exceptions(self): # digit_round f.set_quantize_on_write(algorithm="digitround", quantization_nsd=2) with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") # NetCDF3 formats for fmt in self.netcdf3_fmts: @@ -199,29 +199,29 @@ def test_quantization_write_exceptions(self): # Integer data type f.data.dtype = int with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") # Out-of-range quantization_nsd f.data.dtype = "float32" f.set_quantize_on_write(algorithm="bitgroom", quantization_nsd=8) with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") f.data.dtype = "float64" f.set_quantize_on_write(algorithm="bitgroom", quantization_nsd=16) with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") # Out-of-range quantization_nsb f.data.dtype = "float32" f.set_quantize_on_write(algorithm="bitround", quantization_nsb=24) with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") f.data.dtype = "float64" f.set_quantize_on_write(algorithm="bitround", quantization_nsb=53) with self.assertRaises(ValueError): - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile1, backend="netCDF4") def test_quantization_copy(self): """Test that quantization information gets copied.""" @@ -246,14 +246,14 @@ def test_quantization_backends(self): # Backends that allow quantisation-on-write for backend in ("netCDF4",): - cf.write(f, tmpfile1, netcdf_backend=backend) + cf.write(f, tmpfile1, backend=backend) # Backends that do not allow quantisation-on-write with self.assertRaises(NotImplementedError): - cf.write(f, tmpdir, fmt="ZARR3", netcdf_backend="zarr") + cf.write(f, tmpdir, fmt="ZARR3", backend="zarr") with self.assertRaises(NotImplementedError): - cf.write(f, tmpfile1, netcdf_backend="h5netcdf-h5py") + cf.write(f, tmpfile1, backend="h5netcdf-h5py") if __name__ == "__main__": diff --git a/cf/test/test_read_write.py b/cf/test/test_read_write.py index 1f3b60a122..5e4e537783 100644 --- a/cf/test/test_read_write.py +++ b/cf/test/test_read_write.py @@ -357,7 +357,7 @@ def test_write_netcdf_mode(self): tmpfile, fmt=fmt, mode="a", - netcdf_backend="netCDF4", + backend="netCDF4", ) f = cf.read(tmpfile) @@ -443,21 +443,21 @@ def test_write_netcdf_mode(self): tmpfile, fmt=fmt, mode="a", - netcdf_backend="netCDF4", + backend="netCDF4", ) # 2. now append f = cf.read(tmpfile) self.assertEqual(len(f), overall_length) # Also test the mode="r+" alias for mode="a". cf.write( - g, tmpfile, fmt=fmt, mode="w", netcdf_backend="netCDF4" + g, tmpfile, fmt=fmt, mode="w", backend="netCDF4" ) # 1. overwrite to wipe cf.write( append_ex_fields, tmpfile, fmt=fmt, mode="r+", - netcdf_backend="netCDF4", + backend="netCDF4", ) # 2. now append f = cf.read(tmpfile) self.assertEqual(len(f), overall_length) @@ -560,10 +560,10 @@ def test_write_netcdf_mode(self): # Check behaviour when append identical fields, as an edge case: cf.write( - g, tmpfile, fmt=fmt, mode="w", netcdf_backend="netCDF4" + g, tmpfile, fmt=fmt, mode="w", backend="netCDF4" ) # 1. overwrite to wipe cf.write( - g_copy, tmpfile, fmt=fmt, mode="a", netcdf_backend="netCDF4" + g_copy, tmpfile, fmt=fmt, mode="a", backend="netCDF4" ) # 2. now append f = cf.read(tmpfile) self.assertEqual(len(f), 2) @@ -653,7 +653,7 @@ def test_read_write_unlimited(self): else: backend = None - g = cf.read(tmpfile, netcdf_backend=backend)[0] + g = cf.read(tmpfile, backend=backend)[0] domain_axes = g.domain_axes() self.assertTrue(domain_axes["domainaxis0"].nc_is_unlimited(), fmt) @@ -869,7 +869,7 @@ def test_write_omit_data(self): f = self.f1 cf.write(f, tmpfile) - cf.write(f, tmpfile, omit_data="all", netcdf_backend="netCDF4") + cf.write(f, tmpfile, omit_data="all", backend="netCDF4") g = cf.read(tmpfile) self.assertEqual(len(g), 1) g = g[0] @@ -885,7 +885,7 @@ def test_write_omit_data(self): f, tmpfile, omit_data=("field", "dimension_coordinate"), - netcdf_backend="netCDF4", + backend="netCDF4", ) g = cf.read(tmpfile)[0] @@ -895,7 +895,7 @@ def test_write_omit_data(self): self.assertFalse(np.ma.count(g.construct("grid_latitude").array)) self.assertTrue(np.ma.count(g.construct("latitude").array)) - cf.write(f, tmpfile, omit_data="field", netcdf_backend="netCDF4") + cf.write(f, tmpfile, omit_data="field", backend="netCDF4") g = cf.read(tmpfile)[0] # Check that only the field data are missing @@ -909,7 +909,7 @@ def test_read_url(self): """Test reading remote url.""" for scheme in ("http", "https"): remote = f"{scheme}:///psl.noaa.gov/thredds/dodsC/Datasets/cru/crutem5/Monthlies/air.mon.anom.nobs.nc" - f = cf.read(remote, netcdf_backend="netCDF4") + f = cf.read(remote, backend="netCDF4") self.assertEqual(len(f), 1) @unittest.skipUnless( @@ -981,19 +981,19 @@ def test_read_zarr(self): z = cf.read(zarr_dataset, dataset_type="Zarr") self.assertEqual(len(z), 1) - def test_write_netcdf_backend(self): - """Test cf.write with different netCDF backends.""" + def test_write_backend(self): + """Test cf.write with different backends.""" f = self.f0 - cf.write(f, tmpfile0, netcdf_backend="h5netcdf-h5py") - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile0, backend="h5netcdf-h5py") + cf.write(f, tmpfile1, backend="netCDF4") f0 = cf.read(tmpfile0)[0] f1 = cf.read(tmpfile1)[0] self.assertTrue(f1.equals(f0)) f = cf.read(filename) - cf.write(f, tmpfile0, netcdf_backend="h5netcdf-h5py") - cf.write(f, tmpfile1, netcdf_backend="netCDF4") + cf.write(f, tmpfile0, backend="h5netcdf-h5py") + cf.write(f, tmpfile1, backend="netCDF4") f0 = cf.read(tmpfile0)[0] f1 = cf.read(tmpfile1)[0] self.assertTrue(f1.equals(f0)) @@ -1001,17 +1001,15 @@ def test_write_netcdf_backend(self): # Bad fmt/backend combinations for backend in ("netCDF4", "h5netcdf-h5py"): with self.assertRaises(ValueError): - cf.write(f, tmpfile, fmt="ZARR3", netcdf_backend=backend) + cf.write(f, tmpfile, fmt="ZARR3", backend=backend) for backend in ("zarr", "h5netcdf-h5py"): with self.assertRaises(ValueError): - cf.write( - f, tmpfile, fmt="NETCDF3_CLASSIC", netcdf_backend=backend - ) + cf.write(f, tmpfile, fmt="NETCDF3_CLASSIC", backend=backend) for backend in ("zarr",): with self.assertRaises(ValueError): - cf.write(f, tmpfile, fmt="NETCDF4", netcdf_backend=backend) + cf.write(f, tmpfile, fmt="NETCDF4", backend=backend) def test_write_h5py_options(self): """Test cf.write with h5py_options.""" @@ -1026,7 +1024,7 @@ def test_write_h5py_options(self): cf.write( f, tmpfile1, - netcdf_backend="h5netcdf-h5py", + backend="h5netcdf-h5py", h5py_options=h5py_options, ) self.assertTrue(os.path.getsize(tmpfile1) > size) @@ -1039,7 +1037,7 @@ def test_write_h5py_options(self): cf.write( f, tmpfile0, - netcdf_backend="netCDF4", + backend="netCDF4", h5py_options=h5py_options, ) @@ -1053,7 +1051,7 @@ def test_read_netcdf_file(self): f = self.f0 cf.write(f, tmpfile, fmt="NETCDF3_CLASSIC") - g = cf.read(tmpfile, netcdf_backend="netcdf_file")[0] + g = cf.read(tmpfile, backend="netcdf_file")[0] self.assertTrue(g.equals(f)) diff --git a/cf/test/test_zarr.py b/cf/test/test_zarr.py index 2310a83953..edd315f0a4 100644 --- a/cf/test/test_zarr.py +++ b/cf/test/test_zarr.py @@ -248,13 +248,6 @@ def test_zarr_groups_dimension(self): self.assertTrue(z.equals(n)) self.assertTrue(z.equals(f)) - # Check that grouped netCDF datasets can only be read with - # 'closest_ancestor' - cf.read(grouped_file, group_dimension_search="closest_ancestor") - for gsn in ("furthest_ancestor", "local", "BAD VALUE"): - with self.assertRaises(ValueError): - cf.read(grouped_file, group_dimension_search=gsn) - def test_zarr_groups_DSG(self): """Test Zarr groups containing DSGs.""" f = cf.example_field(4) From 8ac2b98cf88638cbe8bbed806ff63f727d3d5823 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 9 Jul 2026 15:49:40 +0100 Subject: [PATCH 16/43] dev --- cf/mixin/utils/grid_mapping.py | 233 ++++++++++++++++++++------------- cf/mixin/utils/latlon_utils.py | 6 + 2 files changed, 145 insertions(+), 94 deletions(-) diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index b9a86c7dcf..938da2d4d7 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -90,7 +90,7 @@ def _get_ellipsoid_parameters(cr): cr: `CoordinateReference` or `None` The coordinate reference construct, or `None`, in which - case the CF defualt ellpsoid is assumed. + case the CF default ellpsoid is assumed. :Returns: @@ -101,25 +101,43 @@ def _get_ellipsoid_parameters(cr): kwargs = {} if cr is None: p = {} - else: - p = cr.coordinate_conversion.parameters() - if "reference_ellipsoid_name" in p: - kwargs["ellps"] = p["reference_ellipsoid_name"] - - if "semi_major_axis" in p: - kwargs["a"] = p["semi_major_axis"] + else: + p = cr.datum.parameters() + + inverse_flattening = p.get("inverse_flattening") + semi_major_axis = p.get("semi_major_axis") + semi_minor_axis = p.get("semi_minor_axis") + earth_radius= p.get("earth_radius") + reference_ellipsoid_name=p.get("reference_ellipsoid_name") + + if inverse_flattening == 0: + # Sphere + if semi_major_axis is not None: + kwargs["R"] = semi_major_axis + elif earth_radius is not None: + kwargs["R"] = earth_radius + elif reference_ellipsoid_name is None: + reference_ellipsoid_name = "sphere" + else: + # Ellipsoid + if earth_radius is not None: + kwargs["R"] = earth_radius + else: + if semi_major_axis is not None: + kwargs["a"] = semi_major_axis - if "semi_minor_axis" in p: - kwargs["b"] = p["semi_minor_axis"] + if semi_minor_axis is not None: + kwargs["b"] = semi_minor_axis - if "inverse_flattening" in p: - kwargs["rf"] = p["inverse_flattening"] + if inverse_flattening is not None: + kwargs["rf"] = inverse_flattening + + if reference_ellipsoid_name is not None: + kwargs["ellps"] = reference_ellipsoid_name if not kwargs: kwargs = {"ellps": "sphere"} - kwargs["R"] = p.get("earth_radius") - prime_meridian_name = p.get("prime_meridian_name") if prime_meridian_name is not None: kwargs["pm"] = prime_meridian_name @@ -169,6 +187,9 @@ def _create_pyproj_CRS(kwargs, cr): return + if is_log_level_info(logger): + print(f"pyproj.CRS arguments: {kwargs}") + return proj @@ -199,26 +220,25 @@ def albers_equal_area(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "aea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), + "lon_0": p["longitude_of_central_meridian"], + "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } lat_2 = None - standard_parallel = p.get("standard_parallel") + standard_parallel = p["standard_parallel"] try: lat_1 = standard_parallel[0] except Exception: lat_1 = standard_parallel else: try: - lat_2 = standard_parallel[1] + kwargs["lat_2"]= standard_parallel[1] except Exception: pass kwargs["lat_1"] = lat_1 - kwargs["lat_2"] = lat_2 return _create_pyproj_CRS(kwargs, cr) @@ -244,8 +264,8 @@ def azimuthal_equidistant(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "aeqd", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], + "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -274,9 +294,9 @@ def geostationary(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "geos", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], + "lat_0": p["latitude_of_projection_origin"], + "h": p["perspective_point_height"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -335,8 +355,8 @@ def lambert_azimuthal_equal_area(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "laea", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lat_0": p["latitude_of_projection_origin"], + "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -348,6 +368,22 @@ def lambert_conformal_conic(cr): https://proj.org/en/stable/operations/projections/lcc.html + :Example: + + char crs ; + crs:proj4_params = "+proj=lcc +lat_1=30.00 +lat_2=60.00 +lat_0=47.82 +lon_0=74.64 +x_0=-25000. +y_0=-25000. +ellps=sphere +a=6371229. +b=6371229. +units=m +no_defs" ; + crs:grid_mapping_name = "lambert_conformal_conic" ; + crs:standard_parallel = 30., 60. ; + crs:longitude_of_central_meridian = 74.64 ; + crs:latitude_of_projection_origin = 47.82 ; + crs:semi_major_axis = 6371229. ; + crs:inverse_flattening = 0. ; + crs:false_easting = -25000. ; + crs:false_northing = -25000. ; + + (y, x) = (-2450000, -3750000) + (lat, lon) = (18.958610534668, 40.6170616149902) + .. versionadded:: NEXTVERSION :Parameters: @@ -364,27 +400,26 @@ def lambert_conformal_conic(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "lcc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_central_meridian"], + "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } lat_2 = None - standard_parallel = p.get("standard_parallel") + standard_parallel = p["standard_parallel"] try: lat_1 = standard_parallel[0] except Exception: lat_1 = standard_parallel else: try: - lat_2 = standard_parallel[1] + kwargs["lat_2"] = standard_parallel[1] except Exception: pass kwargs["lat_1"] = lat_1 - kwargs["lat_2"] = lat_2 - + print(kwargs) return _create_pyproj_CRS(kwargs, cr) @@ -409,7 +444,7 @@ def lambert_cylindrical_equal_area(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "cea", - "lon_0": p.get("longitude_of_central_meridian"), + "lon_0": p["longitude_of_central_meridian"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -418,14 +453,14 @@ def lambert_cylindrical_equal_area(cr): if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + kwargs["k_0"] = p["scale_factor_at_projection_origin"] return _create_pyproj_CRS(kwargs, cr) def latitude_longitude(cr): """create a latitude_longitude CRS. - + .. versionadded:: NEXTVERSION :Parameters: @@ -435,6 +470,10 @@ def latitude_longitude(cr): which to create the CRS, or `None` if there isn't one (in which case a spherical CRS is created). + .. note:: Only the datum parameters are used, so the + coordinate reference construct does not not need + to be a latitude_longitude grid mapping. + :Returns: `pyproj.CRS` @@ -466,7 +505,7 @@ def mercator(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "merc", - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -475,7 +514,7 @@ def mercator(cr): if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + kwargs["k_0"] = p["scale_factor_at_projection_origin"] return _create_pyproj_CRS(kwargs, cr) @@ -501,10 +540,10 @@ def oblique_mercator(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "omerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "alpha": p.get("azimuth_of_central_line"), - "k_0": p.get("scale_factor_at_projection_origin"), + "alpha": p["azimuth_of_central_line"], + "lat_0": p["latitude_of_projection_origin"], + "lon_0": p["longitude_of_projection_origin"], + "k_0": p["scale_factor_at_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -532,8 +571,8 @@ def orthographic(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "ortho", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], + "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -569,15 +608,15 @@ def polar_stereographic(cr): if longitude_of_projection_origin is not None: kwargs["lon_0"] = longitude_of_projection_origin else: - kwargs["lon_0"] = p.get("straight_vertical_longitude_from_pole") + kwargs["lon_0"] = p["straight_vertical_longitude_from_pole"] standard_parallel = p.get("standard_parallel") if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel else: - kwargs["k_0"] = p.get("scale_factor_at_projection_origin") + kwargs["k_0"] = p["scale_factor_at_projection_origin"] - latitude_of_projection_origin = p.get("latitude_of_projection_origin") + latitude_of_projection_origin = p["latitude_of_projection_origin"] try: ok = ( latitude_of_projection_origin == -90 @@ -623,11 +662,11 @@ def rotated_latitude_longitude(cr): kwargs = { "proj": "ob_tran", "o_proj": "longlat", + "o_lat_p": p["grid_north_pole_latitude"], "o_lon_p": p.get("north_pole_grid_longitude", 0), - "o_lat_p": p.get("grid_north_pole_latitude"), } - grid_north_pole_longitude = p.get("grid_north_pole_longitude") + grid_north_pole_longitude = p["grid_north_pole_longitude"] try: kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 except Exception: @@ -664,7 +703,7 @@ def sinusoidal(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "sinu", - "lon_0": p.get("longitude_of_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -693,9 +732,9 @@ def stereographic(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "stere", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), - "k_0": p.get("scale_factor_at_projection_origin"), + "lon_0": p["longitude_of_projection_origin"], + "lat_0": p["latitude_of_projection_origin"], + "k_0": p["scale_factor_at_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -724,9 +763,9 @@ def transverse_mercator(cr): kwargs = { "proj": "tmerc", - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_central_meridian"), - "k_0": p.get("scale_factor_at_central_meridian"), + "k_0": p["scale_factor_at_central_meridian"], + "lon_0": p["longitude_of_central_meridian"], + "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -755,9 +794,9 @@ def vertical_perspective(cr): p = cr.coordinate_conversion.parameters() kwargs = { "proj": "nsper", - "h": p.get("perspective_point_height"), - "lat_0": p.get("latitude_of_projection_origin"), - "lon_0": p.get("longitude_of_projection_origin"), + "lat_0": p["latitude_of_projection_origin"], + "lon_0": p["longitude_of_projection_origin"], + "h": p["perspective_point_height"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), } @@ -786,40 +825,46 @@ def create_projection_CRS(cr, grid_mapping_name): The projection CRS, or `None` if it coulcn't be created. """ - match grid_mapping_name: - case "albers_equal_area": - proj = albers_equal_area(cr) - case "azimuthal_equidistant": - proj = azimuthal_equidistant(cr) - case "geostationary": - proj = geostationary(cr) - case "lambert_azimuthal_equal_area": - proj = lambert_azimuthal_equal_area(cr) - case "lambert_conformal_conic": - proj = lambert_conformal_conic(cr) - case "lambert_cylindrical_equal_area": - proj = lambert_cylindrical_equal_area(cr) - case "latitude_longitude": - proj = latitude_longitude(cr) - case "mercator": - proj = mercator(cr) - case "oblique_mercator": - proj = oblique_mercator(cr) - case "orthographic": - proj = orthographic(cr) - case "polar_stereographic": - proj = polar_stereographic(cr) - case "rotated_latitude_longitude": - proj = rotated_latitude_longitude(cr) - case "sinusoidal": - proj = sinusoidal(cr) - case "stereographic": - proj = stereographic(cr) - case "transverse_mercator": - proj = transverse_mercator(cr) - case "vertical_perspective": - proj = vertical_perspective(cr) - case _: - proj = None - + proj = None + try: + match grid_mapping_name: + case "albers_equal_area": + proj = albers_equal_area(cr) + case "azimuthal_equidistant": + proj = azimuthal_equidistant(cr) + case "geostationary": + proj = geostationary(cr) + case "lambert_azimuthal_equal_area": + proj = lambert_azimuthal_equal_area(cr) + case "lambert_conformal_conic": + proj = lambert_conformal_conic(cr) + case "lambert_cylindrical_equal_area": + proj = lambert_cylindrical_equal_area(cr) + case "latitude_longitude": + proj = latitude_longitude(cr) + case "mercator": + proj = mercator(cr) + case "oblique_mercator": + proj = oblique_mercator(cr) + case "orthographic": + proj = orthographic(cr) + case "polar_stereographic": + proj = polar_stereographic(cr) + case "rotated_latitude_longitude": + proj = rotated_latitude_longitude(cr) + case "sinusoidal": + proj = sinusoidal(cr) + case "stereographic": + proj = stereographic(cr) + case "transverse_mercator": + proj = transverse_mercator(cr) + case "vertical_perspective": + proj = vertical_perspective(cr) + + except KeyError as error: + if is_log_level_info(logger): + logger.info( + f"{cr!r} has missing coordinate conversion property: {error}" + ) # pragma: no cover + return proj diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 2f80f26e23..1e4c357761 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -129,6 +129,12 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # ---------------------------------------------------------------- # Create the destination latitude_longitude CRS # ---------------------------------------------------------------- + if cr_latlon is None: + # When specific latitude_longitude coordinate refernce has not + # been provided, then get the shape of the ellipsoid from the + # projection coordinate reference. + cr_latlon = cr + proj_latlon = create_projection_CRS(cr_latlon, "latitude_longitude") if proj_latlon is None: # Invalid latitude_longitude coordinate reference From 51bfa2244fd3753be09d041bccc25060236a48c0 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 10 Jul 2026 00:21:48 +0100 Subject: [PATCH 17/43] dev --- cf/mixin/utils/grid_mapping.py | 86 ++--- cf/mixin/utils/latlon_utils.py | 2 +- cf/test/test_2d_latlon.py | 560 ++++++++++++++++++++++++++++++++- cf/test/test_kerchunk.py | 1 + 4 files changed, 611 insertions(+), 38 deletions(-) diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 938da2d4d7..62b7d6b2e2 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -72,7 +72,7 @@ import logging -from cfdm import is_log_level_info +from cfdm import is_log_level_debug, is_log_level_info logger = logging.getLogger(__name__) @@ -101,15 +101,15 @@ def _get_ellipsoid_parameters(cr): kwargs = {} if cr is None: p = {} - else: + else: p = cr.datum.parameters() - + inverse_flattening = p.get("inverse_flattening") semi_major_axis = p.get("semi_major_axis") semi_minor_axis = p.get("semi_minor_axis") - earth_radius= p.get("earth_radius") - reference_ellipsoid_name=p.get("reference_ellipsoid_name") - + earth_radius = p.get("earth_radius") + reference_ellipsoid_name = p.get("reference_ellipsoid_name") + if inverse_flattening == 0: # Sphere if semi_major_axis is not None: @@ -131,7 +131,7 @@ def _get_ellipsoid_parameters(cr): if inverse_flattening is not None: kwargs["rf"] = inverse_flattening - + if reference_ellipsoid_name is not None: kwargs["ellps"] = reference_ellipsoid_name @@ -177,6 +177,20 @@ def _create_pyproj_CRS(kwargs, cr): # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs_wkt = {} + crs_wkt = cr.datum.get_parameter('crs_wkt', None) + if crs_wkt is not None: + kwargs_wkt = pyproj.CRS.from_wkt(crs_wkt).to_dict() + + crs_wkt = cr.coordinate_conversion.get_parameter('crs_wkt', None) + if crs_wkt is not None: + kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() + + if kwargs_wkt: + TODO + kwargs = kwargs_wkt | kwargs + if "ellps" in + try: proj = pyproj.CRS(**kwargs) except Exception as error: @@ -187,9 +201,10 @@ def _create_pyproj_CRS(kwargs, cr): return - if is_log_level_info(logger): - print(f"pyproj.CRS arguments: {kwargs}") - + if is_log_level_debug(logger): + proj_string = " ".join([f"+{k}={v}" for k, v in kwargs.items()]) + logger.debug(f"PROJ string: {proj_string}") + return proj @@ -224,9 +239,9 @@ def albers_equal_area(cr): "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } - lat_2 = None standard_parallel = p["standard_parallel"] try: lat_1 = standard_parallel[0] @@ -234,7 +249,7 @@ def albers_equal_area(cr): lat_1 = standard_parallel else: try: - kwargs["lat_2"]= standard_parallel[1] + kwargs["lat_2"] = standard_parallel[1] except Exception: pass @@ -268,6 +283,7 @@ def azimuthal_equidistant(cr): "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -295,10 +311,10 @@ def geostationary(cr): kwargs = { "proj": "geos", "lon_0": p["longitude_of_projection_origin"], - "lat_0": p["latitude_of_projection_origin"], "h": p["perspective_point_height"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } sweep_angle_axis = p.get("sweep_angle_axis") @@ -319,6 +335,9 @@ def geostationary(cr): case _: ok = False + if p.get("latitude_of_projection_origin", 0) != 0: + ok = False + if not ok: if is_log_level_info(logger): logger.info( @@ -359,6 +378,7 @@ def lambert_azimuthal_equal_area(cr): "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -368,22 +388,6 @@ def lambert_conformal_conic(cr): https://proj.org/en/stable/operations/projections/lcc.html - :Example: - - char crs ; - crs:proj4_params = "+proj=lcc +lat_1=30.00 +lat_2=60.00 +lat_0=47.82 +lon_0=74.64 +x_0=-25000. +y_0=-25000. +ellps=sphere +a=6371229. +b=6371229. +units=m +no_defs" ; - crs:grid_mapping_name = "lambert_conformal_conic" ; - crs:standard_parallel = 30., 60. ; - crs:longitude_of_central_meridian = 74.64 ; - crs:latitude_of_projection_origin = 47.82 ; - crs:semi_major_axis = 6371229. ; - crs:inverse_flattening = 0. ; - crs:false_easting = -25000. ; - crs:false_northing = -25000. ; - - (y, x) = (-2450000, -3750000) - (lat, lon) = (18.958610534668, 40.6170616149902) - .. versionadded:: NEXTVERSION :Parameters: @@ -404,9 +408,9 @@ def lambert_conformal_conic(cr): "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } - lat_2 = None standard_parallel = p["standard_parallel"] try: lat_1 = standard_parallel[0] @@ -414,12 +418,12 @@ def lambert_conformal_conic(cr): lat_1 = standard_parallel else: try: - kwargs["lat_2"] = standard_parallel[1] + kwargs["lat_2"] = standard_parallel[1] except Exception: pass kwargs["lat_1"] = lat_1 - print(kwargs) + return _create_pyproj_CRS(kwargs, cr) @@ -447,6 +451,7 @@ def lambert_cylindrical_equal_area(cr): "lon_0": p["longitude_of_central_meridian"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } standard_parallel = p.get("standard_parallel") @@ -460,7 +465,7 @@ def lambert_cylindrical_equal_area(cr): def latitude_longitude(cr): """create a latitude_longitude CRS. - + .. versionadded:: NEXTVERSION :Parameters: @@ -508,6 +513,7 @@ def mercator(cr): "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } standard_parallel = p.get("standard_parallel") @@ -542,10 +548,11 @@ def oblique_mercator(cr): "proj": "omerc", "alpha": p["azimuth_of_central_line"], "lat_0": p["latitude_of_projection_origin"], - "lon_0": p["longitude_of_projection_origin"], + "lonc": p["longitude_of_projection_origin"], "k_0": p["scale_factor_at_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -575,6 +582,7 @@ def orthographic(cr): "lat_0": p["latitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -602,6 +610,7 @@ def polar_stereographic(cr): "proj": "stere", "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } longitude_of_projection_origin = p.get("longitude_of_projection_origin") @@ -664,6 +673,7 @@ def rotated_latitude_longitude(cr): "o_proj": "longlat", "o_lat_p": p["grid_north_pole_latitude"], "o_lon_p": p.get("north_pole_grid_longitude", 0), + "units": "m", } grid_north_pole_longitude = p["grid_north_pole_longitude"] @@ -706,6 +716,7 @@ def sinusoidal(cr): "lon_0": p["longitude_of_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -737,6 +748,7 @@ def stereographic(cr): "k_0": p["scale_factor_at_projection_origin"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -763,11 +775,12 @@ def transverse_mercator(cr): kwargs = { "proj": "tmerc", - "k_0": p["scale_factor_at_central_meridian"], "lon_0": p["longitude_of_central_meridian"], "lat_0": p["latitude_of_projection_origin"], + "k_0": p["scale_factor_at_central_meridian"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -799,6 +812,7 @@ def vertical_perspective(cr): "h": p["perspective_point_height"], "x_0": p.get("false_easting", 0), "y_0": p.get("false_northing", 0), + "units": "m", } return _create_pyproj_CRS(kwargs, cr) @@ -866,5 +880,5 @@ def create_projection_CRS(cr, grid_mapping_name): logger.info( f"{cr!r} has missing coordinate conversion property: {error}" ) # pragma: no cover - + return proj diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 1e4c357761..c8b12d85fe 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -134,7 +134,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # been provided, then get the shape of the ellipsoid from the # projection coordinate reference. cr_latlon = cr - + proj_latlon = create_projection_CRS(cr_latlon, "latitude_longitude") if proj_latlon is None: # Invalid latitude_longitude coordinate reference diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index 263f6d5334..a0f7094ec4 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -2,14 +2,70 @@ import unittest import numpy as np +import pyproj import cf +units = "km" + +f0 = cf.example_field(0)[0, 0] + +key_x, x = f0.dimension_coordinate("X", item=True) +x.del_bounds() +x.standard_name = "projection_x_coordinate" +x.override_units(units, inplace=True) + +key_y, y = f0.dimension_coordinate("Y", item=True) +y.del_bounds() +y.standard_name = "projection_y_coordinate" +y.override_units(units, inplace=True) + +cr = cf.CoordinateReference() +cr.datum.set_parameters({"reference_ellipsoid_name": "WGS84"}) +cr.set_coordinates((key_x, key_y)) +f0.set_construct(cr) + +paris_lon = 2.2945 # La Tour Eiffel, WGS84 +paris_lat = 48.8584 # La Tour Eiffel, WGS84 + + +def check_paris(g, atol=1e13, verbose=False): + if verbose: + print( + [ + g.auxiliary_coordinate("X").array, + g.auxiliary_coordinate("Y").array, + ], + [paris_lon, paris_lat], + ) + ok = np.allclose(g.auxiliary_coordinate("X"), paris_lon, rtol=0, atol=atol) + ok = ok & np.allclose( + g.auxiliary_coordinate("Y"), paris_lat, rtol=0, atol=atol + ) + return ok + + +def set_easting_northing(f, easting, northing): + x = f.dimension_coordinate("X") + x[...] = easting + + y = f.dimension_coordinate("Y") + y[...] = northing + + +def set_coordinate_reference(f, parameters): + cr = f.coordinate_reference() + cr.coordinate_conversion.set_parameters(parameters) + class LatLon2dTest(unittest.TestCase): """Test the creation of 2-d lat/lon coordinatesx.""" - def test_rotated_latitude_longitude(self): + paris_lon = 2.2945 # La Tour Eiffel, WGS84 + paris_lat = 48.8584 # La Tour Eiffel, WGS84 + longlat = pyproj.CRS.from_string("+proj=longlat +ellps=WGS84") + + def test_rotated_latitude_longitude_0(self): """Test rotated_latitude_longitude.""" f = cf.read("rotated_pole.pp")[0] @@ -49,6 +105,508 @@ def test_rotated_latitude_longitude(self): ) ) + def test_albers_equal_area(self): + """Test albers_equal_area.""" + # Get the easting and northing for Paris + lat_1 = 43 + lat_2 = 62 + lat_0 = 30 + lon_0 = 10 + proj = pyproj.CRS( + proj="aea", + lat_1=lat_1, + lat_2=lat_2, + lat_0=lat_0, + lon_0=lon_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + set_coordinate_reference( + f, + { + "grid_mapping_name": "albers_equal_area", + "longitude_of_central_meridian": lon_0, + "latitude_of_projection_origin": lat_0, + "standard_parallel": [lat_1, lat_2], + }, + ) + + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_azimuthal_equidistant(self): + """Test azimuthal_equidistant.""" + # Get the easting and northing for Paris + lat_0 = 48.8584 + lon_0 = 2.2945 + proj = pyproj.CRS( + proj="aeqd", + lat_0=lat_0, + lon_0=lon_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "azimuthal_equidistant", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_geostationary(self): + """Test geostationary.""" + # Get the easting and northing for Paris + h = 35785831 + lon_0 = 0 + sweep = "y" + proj = pyproj.CRS( + proj="geos", + lon_0=lon_0, + h=h, + x_0=0, + y_0=0, + sweep=sweep, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "geostationary", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": 0, + "perspective_point_height": h, + "sweep_angle_axis": sweep, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g, atol=1e-12)) + + def test_lambert_azimuthal_equal_area(self): + """Test lambert_azimuthal_equal_area.""" + # Get the easting and northing for Paris + lat_0 = 52 + lon_0 = 10 + x_0 = 4321000 + y_0 = 3210000 + proj = pyproj.CRS( + proj="laea", + lon_0=lon_0, + lat_0=lat_0, + x_0=x_0, + y_0=y_0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "lambert_azimuthal_equal_area", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + "false_easting": x_0, + "false_northing": y_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g, atol=1e-8)) + + def test_lambert_conformal_conic(self): + """Test lambert_conformal_conic.""" + # Get the easting and northing for Paris + lat_1 = 33 + lat_2 = 45 + lat_0 = 39 + lon_0 = -96 + proj = pyproj.CRS( + proj="lcc", + lon_0=lon_0, + lat_0=lat_0, + lat_1=lat_1, + lat_2=lat_2, + ellps="WGS84", + x_0=0, + y_0=0, + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "lambert_conformal_conic", + "longitude_of_central_meridian": lon_0, + "latitude_of_projection_origin": lat_0, + "standard_parallel": [lat_1, lat_2], + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_lambert_cylindrical_equal_area(self): + """Test lambert_cylindrical_equal_area.""" + # Get the easting and northing for Paris + lon_0 = 0 + lat_ts = 30 + proj = pyproj.CRS( + proj="cea", + lon_0=lon_0, + lat_ts=lat_ts, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "lambert_cylindrical_equal_area", + "longitude_of_central_meridian": lon_0, + "standard_parallel": lat_ts, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_mercator(self): + """Test mercator.""" + # Get the easting and northing for Paris + lon_0 = 0 + lat_ts = 0 + proj = pyproj.CRS( + proj="merc", + lon_0=lon_0, + lat_ts=lat_ts, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "mercator", + "longitude_of_projection_origin": lon_0, + "standard_parallel": lat_ts, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_oblique_mercator(self): + """Test oblique_mercator.""" + # Get the easting and northing for Paris + lat_0 = 45 + lonc = 10 + alpha = 45 + k_0 = 1 + proj = pyproj.CRS( + proj="omerc", + lonc=lonc, + lat_0=lat_0, + alpha=alpha, + k_0=k_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "oblique_mercator", + "azimuth_of_central_line": alpha, + "latitude_of_projection_origin": lat_0, + "longitude_of_projection_origin": lonc, + "scale_factor_at_projection_origin": k_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_orthographic(self): + """Test orthographic.""" + # Get the easting and northing for Paris + lat_0 = 48.8584 + lon_0 = 2.2945 + proj = pyproj.CRS( + proj="ortho", + lon_0=lon_0, + lat_0=lat_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "orthographic", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_polar_stereographic(self): + """Test polar_stereographic.""" + # Get the easting and northing for Paris + lat_ts = 90 + lat_0 = 90 + lon_0 = 0 + proj = pyproj.CRS( + proj="stere", + lon_0=lon_0, + lat_0=lat_0, + lat_ts=lat_ts, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "polar_stereographic", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + "standard_parallel": lat_ts, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_rotated_latitude_longitude(self): + """Test rotated_latitude_longitude.""" + # Get the easting and northing for Paris + lon_0 = 190 + o_lat_p = 38 + o_lon_p = 0 + proj = pyproj.CRS( + proj="ob_tran", + o_proj="longlat", + o_lon_p=o_lon_p, + o_lat_p=o_lat_p, + lon_0=lon_0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "rotated_latitude_longitude", + "grid_north_pole_latitude": o_lat_p, + "grid_north_pole_longitude": lon_0, + "north_pole_grid_longitude": o_lon_p, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_sinusoidal(self): + """Test sinusoidal.""" + # Get the easting and northing for Paris + lon_0 = 0 + proj = pyproj.CRS( + proj="sinu", + lon_0=lon_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "sinusoidal", + "longitude_of_projection_origin": lon_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_stereographic(self): + """Test stereographic.""" + # Get the easting and northing for Paris + lat_0 = 90 + lon_0 = 0 + k_0 = 0.994 + proj = pyproj.CRS( + proj="stere", + lon_0=lon_0, + lat_0=lat_0, + k_0=k_0, + x_0=0, + y_0=0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "stereographic", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + "scale_factor_at_projection_origin": k_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_transverse_mercator(self): + """Test transverse_mercator.""" + # Get the easting and northing for Paris + lat_0=0 + lon_0=3 + k_0=0.9996 + x_0=500000 + y_0=0 + proj = pyproj.CRS( + proj="tmerc", + lon_0=lon_0, + lat_0=lat_0, + k_0=k_0, + x_0=x_0, + y_0=y_0, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "transverse_mercator", + "longitude_of_central_meridian": lon_0, + "latitude_of_projection_origin": lat_0, + "scale_factor_at_central_meridian": k_0, + "false_easting": x_0, + "false_northin": y_0, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + + def test_vertical_perspective(self): + """Test vertical_perspective.""" + # Get the easting and northing for Paris + h=3000000 + lat_0=48.8584 + lon_0=2.2945 + proj = pyproj.CRS( + proj="nsper", + lon_0=lon_0, + lat_0=lat_0, + h=h, + ellps="WGS84", + units=units, + ) + t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + + set_coordinate_reference( + f, + { + "grid_mapping_name": "vertical_perspective", + "longitude_of_projection_origin": lon_0, + "latitude_of_projection_origin": lat_0, + "perspective_point_height": h, + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) diff --git a/cf/test/test_kerchunk.py b/cf/test/test_kerchunk.py index c01bc9c999..aedf513dd7 100644 --- a/cf/test/test_kerchunk.py +++ b/cf/test/test_kerchunk.py @@ -91,6 +91,7 @@ def test_read_bytes(self): kerchunk = fs.get_mapper() self.assertEqual(len(cf.read(kerchunk)), 1) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) cf.environment() From f5f9ea7340a11fcecc353484c103b9fb53397e16 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 10 Jul 2026 10:38:10 +0100 Subject: [PATCH 18/43] dev --- cf/mixin/utils/grid_mapping.py | 30 ++++++++++++++++-------------- cf/test/test_2d_latlon.py | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 62b7d6b2e2..dff0c2ffc6 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -77,7 +77,7 @@ logger = logging.getLogger(__name__) -def _get_ellipsoid_parameters(cr): +def _get_ellipsoid_parameters(cr, crs_wkt): """Get ellipsoid parmaeters from a coordinate reference construct. https://proj.org/en/stable/usage/ellipsoids.html @@ -92,6 +92,10 @@ def _get_ellipsoid_parameters(cr): The coordinate reference construct, or `None`, in which case the CF default ellpsoid is assumed. + crs_wkt: `bool` + Whether or not WKT has been provided via the crs_wkt + parameter. + :Returns: `dict` @@ -135,7 +139,7 @@ def _get_ellipsoid_parameters(cr): if reference_ellipsoid_name is not None: kwargs["ellps"] = reference_ellipsoid_name - if not kwargs: + if not kwargs and not crs_wkt: kwargs = {"ellps": "sphere"} prime_meridian_name = p.get("prime_meridian_name") @@ -170,26 +174,24 @@ def _create_pyproj_CRS(kwargs, cr): """ import pyproj + kwargs_wkt = {} + crs_wkt = cr.coordinate_conversion.get_parameter('crs_wkt', None) + if crs_wkt is not None: + kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() + + crs_wkt = cr.datum.get_parameter('crs_wkt', None) + if crs_wkt is not None: + kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() + # Create the `pyproj.CRS` keywword arguments, which include # parameters for describing the ellipsoid - kwargs = _get_ellipsoid_parameters(cr) | kwargs + kwargs = _get_ellipsoid_parameters(cr, kwargs_wkt) | kwargs # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} - kwargs_wkt = {} - crs_wkt = cr.datum.get_parameter('crs_wkt', None) - if crs_wkt is not None: - kwargs_wkt = pyproj.CRS.from_wkt(crs_wkt).to_dict() - - crs_wkt = cr.coordinate_conversion.get_parameter('crs_wkt', None) - if crs_wkt is not None: - kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() - if kwargs_wkt: - TODO kwargs = kwargs_wkt | kwargs - if "ellps" in try: proj = pyproj.CRS(**kwargs) diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index a0f7094ec4..686c79ef35 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -278,6 +278,10 @@ def test_lambert_conformal_conic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_lambert_cylindrical_equal_area(self): """Test lambert_cylindrical_equal_area.""" # Get the easting and northing for Paris @@ -309,6 +313,10 @@ def test_lambert_cylindrical_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_mercator(self): """Test mercator.""" # Get the easting and northing for Paris @@ -340,6 +348,10 @@ def test_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_oblique_mercator(self): """Test oblique_mercator.""" # Get the easting and northing for Paris @@ -377,6 +389,10 @@ def test_oblique_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_orthographic(self): """Test orthographic.""" # Get the easting and northing for Paris @@ -408,6 +424,10 @@ def test_orthographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_polar_stereographic(self): """Test polar_stereographic.""" # Get the easting and northing for Paris @@ -442,6 +462,10 @@ def test_polar_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_rotated_latitude_longitude(self): """Test rotated_latitude_longitude.""" # Get the easting and northing for Paris From 9e0bdbfdf487e2d446ef6372c6299e57aec4bd76 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 13 Jul 2026 14:07:53 +0100 Subject: [PATCH 19/43] dev --- cf/mixin/fielddomain.py | 1 + cf/mixin/utils/grid_mapping.py | 487 ++++++++++++++++++++------------- cf/mixin/utils/latlon_utils.py | 111 +------- cf/test/test_2d_latlon.py | 309 ++++++++++++--------- 4 files changed, 490 insertions(+), 418 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 2197312a39..a07a82c3d0 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2618,6 +2618,7 @@ def create_latlon_coordinates( cr_latlon = coordinate_references.pop( "grid_mapping_name:latitude_longitude", None ) + if not coordinate_references: if is_log_level_info(logger): logger.info( diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index dff0c2ffc6..b198cec9ec 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -71,14 +71,23 @@ """ import logging +import warnings from cfdm import is_log_level_debug, is_log_level_info +# Suppress warning about lossy WKT-to-PROJ conversion,it only refers +# to lost information that doesn't affect the transformation. +warnings.filterwarnings( + "ignore", + category=UserWarning, + message=".*lose important projection information.*", +) + logger = logging.getLogger(__name__) -def _get_ellipsoid_parameters(cr, crs_wkt): - """Get ellipsoid parmaeters from a coordinate reference construct. +def _ellipsoid_parameters(cr): + """Get ellipsoid parameters from a coordinate reference construct. https://proj.org/en/stable/usage/ellipsoids.html @@ -88,14 +97,10 @@ def _get_ellipsoid_parameters(cr, crs_wkt): :Parameters: - cr: `CoordinateReference` or `None` + cr: `CoordinateReference` The coordinate reference construct, or `None`, in which case the CF default ellpsoid is assumed. - crs_wkt: `bool` - Whether or not WKT has been provided via the crs_wkt - parameter. - :Returns: `dict` @@ -103,68 +108,113 @@ def _get_ellipsoid_parameters(cr, crs_wkt): """ kwargs = {} - if cr is None: - p = {} - else: - p = cr.datum.parameters() - - inverse_flattening = p.get("inverse_flattening") - semi_major_axis = p.get("semi_major_axis") - semi_minor_axis = p.get("semi_minor_axis") - earth_radius = p.get("earth_radius") - reference_ellipsoid_name = p.get("reference_ellipsoid_name") - if inverse_flattening == 0: - # Sphere - if semi_major_axis is not None: - kwargs["R"] = semi_major_axis - elif earth_radius is not None: - kwargs["R"] = earth_radius - elif reference_ellipsoid_name is None: - reference_ellipsoid_name = "sphere" + p = cr.datum.parameters() + crs_wkt = cr.coordinate_conversion.get_parameter("crs_wkt", None) + + inverse_flattening = p.get("inverse_flattening") + semi_major_axis = p.get("semi_major_axis") + semi_minor_axis = p.get("semi_minor_axis") + earth_radius = p.get("earth_radius") + reference_ellipsoid_name = p.get("reference_ellipsoid_name") + + if inverse_flattening == 0: + # Sphere + if semi_major_axis is not None: + kwargs["R"] = semi_major_axis + elif earth_radius is not None: + kwargs["R"] = earth_radius + elif not crs_wkt and reference_ellipsoid_name is None: + reference_ellipsoid_name = "sphere" + else: + # Ellipsoid + if earth_radius is not None: + kwargs["R"] = earth_radius else: - # Ellipsoid - if earth_radius is not None: - kwargs["R"] = earth_radius - else: - if semi_major_axis is not None: - kwargs["a"] = semi_major_axis + if semi_major_axis is not None: + kwargs["a"] = semi_major_axis - if semi_minor_axis is not None: - kwargs["b"] = semi_minor_axis + if semi_minor_axis is not None: + kwargs["b"] = semi_minor_axis - if inverse_flattening is not None: - kwargs["rf"] = inverse_flattening + if inverse_flattening is not None: + kwargs["rf"] = inverse_flattening - if reference_ellipsoid_name is not None: - kwargs["ellps"] = reference_ellipsoid_name + if reference_ellipsoid_name is not None: + kwargs["ellps"] = reference_ellipsoid_name - if not kwargs and not crs_wkt: + if not crs_wkt and not kwargs: + # Default to a sphere, in the absence of other information. kwargs = {"ellps": "sphere"} prime_meridian_name = p.get("prime_meridian_name") if prime_meridian_name is not None: kwargs["pm"] = prime_meridian_name - else: + elif not crs_wkt: kwargs["pm"] = p.get("longitude_of_prime_meridian", 0) return kwargs -def _create_pyproj_CRS(kwargs, cr): +def _crs_wkt_parameters(cr): + """Get parameters from a crs_wkt cooridnate conversion parameter. + + .. versionadded:: NEXTVERSION + + :Parameters: + + cr: `CoordinateReference` + The coordinate reference construct. + + :Returns: + + `dict` + The `pyproj.CRS` parameters derived from coordinate + reference construct crs_wkt parameters, if any. + + """ + + crs_wkt = cr.coordinate_conversion.get_parameter("crs_wkt", None) + if crs_wkt is not None: + import pyproj + + return pyproj.CRS.from_wkt(crs_wkt).to_dict() + + return {} + + +def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): """Create a `pyproj.CRS` instance. .. versionadded:: NEXTVERSION :Parameters: + cr: `CoordinateReference` + The coordinate reference construct from which *kwargs* was + derived. + kwargs: `dict` + A dictionary of keyword arguments for initialising the the `pyproj.CRS` instance. - cr: `CoordinateReference` - The coordinate reference construct from which *kwargs* was - derived. + The keyword arguments should not include a description of + the ellipsoid, as this is automatically derived from *cr*. + + If the *cr* contains a ``crs_wkt`` parameter, either in + its coordinate conversion or its datum component, then it + is converted to `pyproj.CRS` keyword arguments that are + automically included. + + If ``coordinate_conversion_wkt`` and ``datum_wkt`` are + dictionaries of keyword arguments from ``crs_wkt`` + parameters in coordinate conversion or datum components; + and ``ellipsoid`` is a dictionary of keyword arguments + returned by ``_get_ellipoid_parameters(cr)``, then the + final keyword arguments passed to `pyproj.CRS` arex + ``coordinate_conversion_wkt | datum_wkt | ellipsoid | + kwargs`` :Returns: @@ -174,25 +224,16 @@ def _create_pyproj_CRS(kwargs, cr): """ import pyproj - kwargs_wkt = {} - crs_wkt = cr.coordinate_conversion.get_parameter('crs_wkt', None) - if crs_wkt is not None: - kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() - - crs_wkt = cr.datum.get_parameter('crs_wkt', None) - if crs_wkt is not None: - kwargs_wkt |= pyproj.CRS.from_wkt(crs_wkt).to_dict() - - # Create the `pyproj.CRS` keywword arguments, which include - # parameters for describing the ellipsoid - kwargs = _get_ellipsoid_parameters(cr, kwargs_wkt) | kwargs - # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs["units"] = "m" + + kwargs = _crs_wkt_parameters(cr) | _ellipsoid_parameters(cr) | kwargs + + # # The explicit guardrail for spherical Transverse Mercator setups + # if kwargs.get("proj") == "tmerc" and kwargs.get("ellps") == "sphere": + # kwargs["alpha"] = 0 - if kwargs_wkt: - kwargs = kwargs_wkt | kwargs - try: proj = pyproj.CRS(**kwargs) except Exception as error: @@ -203,9 +244,17 @@ def _create_pyproj_CRS(kwargs, cr): return + if ( + latitude_longitude + and cr.coordinate_conversion.get_parameter("grid_mapping_name", None) + != "latitude_longitude" + ): + # Return the CRS defined by the ellipsoid and prime meridian + # of a non-latitude_longitude coordinate reference + proj = proj.geodetic_crs + if is_log_level_debug(logger): - proj_string = " ".join([f"+{k}={v}" for k, v in kwargs.items()]) - logger.debug(f"PROJ string: {proj_string}") + logger.debug(f"pyproj.CRS: {proj}") return proj @@ -216,6 +265,16 @@ def _create_pyproj_CRS(kwargs, cr): # ==================================================================== +def _cc_parameter(p, parameter, crs_wkt, default=None): + if crs_wkt: + return p.get(parameter, default) + + if default is not None: + return p.get(parameter, default) + + return p[parameter] + + def albers_equal_area(cr): """Create an azimuthal_equidistant CRS. @@ -235,27 +294,31 @@ def albers_equal_area(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "aea", - "lon_0": p["longitude_of_central_meridian"], - "lat_0": p["latitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } - standard_parallel = p["standard_parallel"] - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: + standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + if standard_parallel is not None: try: - kwargs["lat_2"] = standard_parallel[1] + lat_1 = standard_parallel[0] except Exception: - pass + lat_1 = standard_parallel + else: + try: + kwargs["lat_2"] = standard_parallel[1] + except Exception: + pass - kwargs["lat_1"] = lat_1 + kwargs["lat_1"] = lat_1 + elif not crs_wkt: + return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -279,13 +342,14 @@ def azimuthal_equidistant(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "aeqd", - "lon_0": p["longitude_of_projection_origin"], - "lat_0": p["latitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -310,13 +374,14 @@ def geostationary(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "geos", - "lon_0": p["longitude_of_projection_origin"], - "h": p["perspective_point_height"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "h": _cc_parameter(p, "perspective_point_height", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } sweep_angle_axis = p.get("sweep_angle_axis") @@ -337,10 +402,7 @@ def geostationary(cr): case _: ok = False - if p.get("latitude_of_projection_origin", 0) != 0: - ok = False - - if not ok: + if not crs_wkt and not ok: if is_log_level_info(logger): logger.info( f"Can't create coordinates for {cr!r}: " @@ -352,6 +414,16 @@ def geostationary(cr): kwargs["sweep"] = sweep_angle_axis + if p.get("latitude_of_projection_origin", 0) != 0: + if is_log_level_info(logger): + logger.info( + f"Can't create coordinates for {cr!r}: " + "Bad 'latitude_of_projection_origin' parameter: " + f"{p['latitude_of_projection_origin']!r}" + ) # pragma: no cover + + return + return _create_pyproj_CRS(kwargs, cr) @@ -374,13 +446,14 @@ def lambert_azimuthal_equal_area(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "laea", - "lat_0": p["latitude_of_projection_origin"], - "lon_0": p["longitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -404,27 +477,31 @@ def lambert_conformal_conic(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "lcc", - "lon_0": p["longitude_of_central_meridian"], - "lat_0": p["latitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } - standard_parallel = p["standard_parallel"] - try: - lat_1 = standard_parallel[0] - except Exception: - lat_1 = standard_parallel - else: + standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + if standard_parallel is not None: try: - kwargs["lat_2"] = standard_parallel[1] + lat_1 = standard_parallel[0] except Exception: - pass + lat_1 = standard_parallel + else: + try: + kwargs["lat_2"] = standard_parallel[1] + except Exception: + pass - kwargs["lat_1"] = lat_1 + kwargs["lat_1"] = lat_1 + elif not crs_wkt: + return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -448,18 +525,19 @@ def lambert_cylindrical_equal_area(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "cea", - "lon_0": p["longitude_of_central_meridian"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } - standard_parallel = p.get("standard_parallel") + standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel - else: + elif not crs_wkt: kwargs["k_0"] = p["scale_factor_at_projection_origin"] return _create_pyproj_CRS(kwargs, cr) @@ -488,7 +566,8 @@ def latitude_longitude(cr): """ kwargs = {"proj": "longlat"} - return _create_pyproj_CRS(kwargs, cr) + + return _create_pyproj_CRS(kwargs, cr, latitude_longitude=True) def mercator(cr): @@ -510,18 +589,19 @@ def mercator(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "merc", - "lon_0": p["longitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } - standard_parallel = p.get("standard_parallel") + standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel - else: + elif not crs_wkt: kwargs["k_0"] = p["scale_factor_at_projection_origin"] return _create_pyproj_CRS(kwargs, cr) @@ -546,15 +626,16 @@ def oblique_mercator(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "omerc", - "alpha": p["azimuth_of_central_line"], - "lat_0": p["latitude_of_projection_origin"], - "lonc": p["longitude_of_projection_origin"], - "k_0": p["scale_factor_at_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "alpha": _cc_parameter(p, "azimuth_of_central_line", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "lonc": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "k_0": _cc_parameter(p, "scale_factor_at_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -578,13 +659,14 @@ def orthographic(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "ortho", - "lon_0": p["longitude_of_projection_origin"], - "lat_0": p["latitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -608,45 +690,53 @@ def polar_stereographic(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "stere", - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } - longitude_of_projection_origin = p.get("longitude_of_projection_origin") + longitude_of_projection_origin = _cc_parameter( + p, "longitude_of_projection_origin", crs_wkt + ) if longitude_of_projection_origin is not None: kwargs["lon_0"] = longitude_of_projection_origin - else: + elif not crs_wkt: kwargs["lon_0"] = p["straight_vertical_longitude_from_pole"] - standard_parallel = p.get("standard_parallel") + standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel - else: + elif not crs_wkt: kwargs["k_0"] = p["scale_factor_at_projection_origin"] - latitude_of_projection_origin = p["latitude_of_projection_origin"] - try: - ok = ( - latitude_of_projection_origin == -90 - or latitude_of_projection_origin == 90 - ) - except Exception: - ok = False - - if not ok: - if is_log_level_info(logger): - logger.info( - f"Can't create coordinates for {cr!r}: " - "Bad 'latitude_of_projection_origin' parameter: " - f"{latitude_of_projection_origin!r}" - ) # pragma: no cover + latitude_of_projection_origin = _cc_parameter( + p, "latitude_of_projection_origin", crs_wkt + ) + if latitude_of_projection_origin is not None: + try: + ok = ( + latitude_of_projection_origin == -90 + or latitude_of_projection_origin == 90 + ) + except Exception: + ok = False - return + if not ok: + if is_log_level_info(logger): + logger.info( + f"Can't create coordinates for {cr!r}: " + "Bad 'latitude_of_projection_origin' parameter: " + f"{latitude_of_projection_origin!r}" + ) # pragma: no cover - kwargs["lat_0"] = latitude_of_projection_origin + return + + kwargs["lat_0"] = latitude_of_projection_origin + elif not crs_wkt: + return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -670,26 +760,32 @@ def rotated_latitude_longitude(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "ob_tran", "o_proj": "longlat", - "o_lat_p": p["grid_north_pole_latitude"], - "o_lon_p": p.get("north_pole_grid_longitude", 0), - "units": "m", + "o_lat_p": _cc_parameter(p, "grid_north_pole_latitude", crs_wkt), + "o_lon_p": _cc_parameter(p, "north_pole_grid_longitude", crs_wkt, 0), } - grid_north_pole_longitude = p["grid_north_pole_longitude"] - try: - kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 - except Exception: - if is_log_level_info(logger): - logger.info( - f"Can't create coordinates for {cr!r}: " - "Bad 'grid_north_pole_longitude' parameter: " - f"{grid_north_pole_longitude!r}" - ) # pragma: no cover + grid_north_pole_longitude = _cc_parameter( + p, "grid_north_pole_longitude", crs_wkt + ) + if grid_north_pole_longitude is not None: + try: + kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 + except Exception: + if is_log_level_info(logger): + logger.info( + f"Can't create coordinates for {cr!r}: " + "Bad 'grid_north_pole_longitude' parameter: " + f"{grid_north_pole_longitude!r}" + ) # pragma: no cover - return + return + elif not crs_wkt: + return # LOG return _create_pyproj_CRS(kwargs, cr) @@ -713,12 +809,13 @@ def sinusoidal(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "sinu", - "lon_0": p["longitude_of_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -743,14 +840,15 @@ def stereographic(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "stere", - "lon_0": p["longitude_of_projection_origin"], - "lat_0": p["latitude_of_projection_origin"], - "k_0": p["scale_factor_at_projection_origin"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "k_0": _cc_parameter(p, "scale_factor_at_projection_origin", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -774,15 +872,15 @@ def transverse_mercator(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p kwargs = { "proj": "tmerc", - "lon_0": p["longitude_of_central_meridian"], - "lat_0": p["latitude_of_projection_origin"], - "k_0": p["scale_factor_at_central_meridian"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "k_0": _cc_parameter(p, "scale_factor_at_central_meridian", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) @@ -807,14 +905,15 @@ def vertical_perspective(cr): """ p = cr.coordinate_conversion.parameters() + crs_wkt = "crs_wkt" in p + kwargs = { "proj": "nsper", - "lat_0": p["latitude_of_projection_origin"], - "lon_0": p["longitude_of_projection_origin"], - "h": p["perspective_point_height"], - "x_0": p.get("false_easting", 0), - "y_0": p.get("false_northing", 0), - "units": "m", + "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), + "h": _cc_parameter(p, "perspective_point_height", crs_wkt), + "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), + "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), } return _create_pyproj_CRS(kwargs, cr) diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index c8b12d85fe..c927181352 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -150,9 +150,19 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): # Create the transform function from source to destination # coordinates # ---------------------------------------------------------------- - transformer = pyproj.Transformer.from_crs( - proj_src, proj_latlon, always_xy=True - ) + try: + transformer = pyproj.Transformer.from_crs( + proj_src, proj_latlon, always_xy=True + ) + except Exception as error: + # Invalid latitude_longitude coordinate reference + if is_log_level_info(logger): + logger.info( + f"Can't create 2-d lat/lon coordinates for {cr!r}: " + f"Error during pyproj.Transformer.from_crs: {error}" + ) # pragma: no cover + + return (None, None) # ---------------------------------------------------------------- # Create 2-d lat/lon coordinate from 1-d grid coordinate centres @@ -179,7 +189,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): if is_log_level_info(logger): logger.info( f"Can't create 2-d lat/lon coordinates for {cr!r}: " - f"Error during pyproj transformation: {error}" + f"Error during pyproj coordinate transformation: {error}" ) # pragma: no cover return (None, None) @@ -341,96 +351,3 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): "axis_x": f.get_data_axes(key_x)[0], "axis_y": f.get_data_axes(key_y)[0], } - - -# def _create_projection_CRS(cr, grid_mapping_name): -# """Create a projection CRS. -# -# .. versionadded:: NEXTVERSION -# -# :Parameters: -# -# cr: `CoordinateReference` or `None` -# The coordinate reference construct that defines the -# projection, or `None` if the there isn't one and the -# projetion is latitude_longitude. -# -# grid_mapping_name: `str` -# The ``grid_mapping_name`` parameter of *cr*. Mut be -# ``'latitude_longitude'`` if *cr* is `None`. -# -# :Returns: -# -# `pyproj.CRS` or `None` -# The projection CRS, or `None` if it coulcn't be created. -# -# """ -# match grid_mapping_name: -# case "albers_equal_area": -# from .grid_mapping import albers_equal_area -# -# proj = albers_equal_area(cr) -# case "azimuthal_equidistant": -# from .grid_mapping import azimuthal_equidistant -# -# proj = azimuthal_equidistant(cr) -# case "geostationary": -# from .grid_mapping import geostationary -# -# proj = geostationary(cr) -# case "lambert_azimuthal_equal_area": -# from .grid_mapping import lambert_azimuthal_equal_area -# -# proj = lambert_azimuthal_equal_area(cr) -# case "lambert_conformal_conic": -# from .grid_mapping import ambert_conformal_conic -# -# proj = lambert_conformal_conic(cr) -# case "lambert_cylindrical_equal_area": -# from .grid_mapping import lambert_cylindrical_equal_area -# -# proj = lambert_cylindrical_equal_area(cr) -# case "latitude_longitude": -# from .grid_mapping import latitude_longitude -# -# proj = latitude_longitude(cr) -# case "mercator": -# from .grid_mapping import mercator -# -# proj = mercator(cr) -# case "oblique_mercator": -# from .grid_mapping import oblique_mercator -# -# proj = oblique_mercator(cr) -# case "orthographic": -# from .grid_mapping import orthographic -# -# proj = orthographic(cr) -# case "polar_stereographic": -# from .grid_mapping import polar_stereographic -# -# proj = polar_stereographic(cr) -# case "rotated_latitude_longitude": -# from .grid_mapping import rotated_latitude_longitude -# -# proj = rotated_latitude_longitude(cr) -# case "sinusoidal": -# from .grid_mapping import sinusoidal -# -# proj = sinusoidal(cr) -# case "stereographic": -# from .grid_mapping import stereographic -# -# proj = stereographic(cr) -# case "transverse_mercator": -# from .grid_mapping import transverse_mercator -# -# proj = transverse_mercator(cr) -# case "vertical_perspective": -# from .grid_mapping import vertical_perspective -# -# proj = vertical_perspective(cr) -# case _: -# proj = None -# -# return proj diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index 686c79ef35..abfe8c7e91 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -6,6 +6,7 @@ import cf +ellps="WGS84" units = "km" f0 = cf.example_field(0)[0, 0] @@ -21,13 +22,14 @@ y.override_units(units, inplace=True) cr = cf.CoordinateReference() -cr.datum.set_parameters({"reference_ellipsoid_name": "WGS84"}) +cr.datum.set_parameters({"reference_ellipsoid_name": ellps}) cr.set_coordinates((key_x, key_y)) f0.set_construct(cr) paris_lon = 2.2945 # La Tour Eiffel, WGS84 paris_lat = 48.8584 # La Tour Eiffel, WGS84 +longlat = pyproj.CRS.from_string("+proj=longlat +ellps=WGS84") def check_paris(g, atol=1e13, verbose=False): if verbose: @@ -53,20 +55,27 @@ def set_easting_northing(f, easting, northing): y[...] = northing -def set_coordinate_reference(f, parameters): +def set_coordinate_conversion(f, parameters): cr = f.coordinate_reference() + + cr.coordinate_conversion.clear_parameters() cr.coordinate_conversion.set_parameters(parameters) +def field_paris(proj): + """Create a field for Paris with a projection grid.""" + t = pyproj.Transformer.from_crs(longlat, proj, always_xy=1) + easting, northing = t.transform(paris_lon, paris_lat) + + f = f0.copy() + set_easting_northing(f, easting, northing) + return f + class LatLon2dTest(unittest.TestCase): """Test the creation of 2-d lat/lon coordinatesx.""" - paris_lon = 2.2945 # La Tour Eiffel, WGS84 - paris_lat = 48.8584 # La Tour Eiffel, WGS84 - longlat = pyproj.CRS.from_string("+proj=longlat +ellps=WGS84") - - def test_rotated_latitude_longitude_0(self): - """Test rotated_latitude_longitude.""" + def test_field_2d_latlon(self): + """Test lat/on bounds.""" f = cf.read("rotated_pole.pp")[0] cr = f.coordinate_reference() @@ -120,15 +129,12 @@ def test_albers_equal_area(self): lon_0=lon_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "albers_equal_area", @@ -141,6 +147,16 @@ def test_albers_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "albers_equal_area", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_azimuthal_equidistant(self): """Test azimuthal_equidistant.""" # Get the easting and northing for Paris @@ -152,16 +168,12 @@ def test_azimuthal_equidistant(self): lon_0=lon_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "azimuthal_equidistant", @@ -172,6 +184,16 @@ def test_azimuthal_equidistant(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "azimuthal_equidistant", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_geostationary(self): """Test geostationary.""" # Get the easting and northing for Paris @@ -185,16 +207,12 @@ def test_geostationary(self): x_0=0, y_0=0, sweep=sweep, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - f = f0.copy() - set_easting_northing(f, easting, northing) - - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "geostationary", @@ -207,6 +225,12 @@ def test_geostationary(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g, atol=1e-12)) + set_coordinate_conversion( + f, {"grid_mapping_name": "geostationary", "crs_wkt": proj.to_wkt()} + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_lambert_azimuthal_equal_area(self): """Test lambert_azimuthal_equal_area.""" # Get the easting and northing for Paris @@ -220,16 +244,12 @@ def test_lambert_azimuthal_equal_area(self): lat_0=lat_0, x_0=x_0, y_0=y_0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "lambert_azimuthal_equal_area", @@ -242,6 +262,16 @@ def test_lambert_azimuthal_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g, atol=1e-8)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "lambert_azimuthal_equal_area", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_lambert_conformal_conic(self): """Test lambert_conformal_conic.""" # Get the easting and northing for Paris @@ -255,18 +285,13 @@ def test_lambert_conformal_conic(self): lat_0=lat_0, lat_1=lat_1, lat_2=lat_2, - ellps="WGS84", + ellps=ellps, x_0=0, y_0=0, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "lambert_conformal_conic", @@ -278,7 +303,13 @@ def test_lambert_conformal_conic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "lambert_conformal_conic", + "crs_wkt": proj.to_wkt(), + }, + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -293,16 +324,12 @@ def test_lambert_cylindrical_equal_area(self): lat_ts=lat_ts, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "lambert_cylindrical_equal_area", @@ -313,7 +340,13 @@ def test_lambert_cylindrical_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "lambert_cylindrical_equal_area", + "crs_wkt": proj.to_wkt(), + }, + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -328,16 +361,12 @@ def test_mercator(self): lat_ts=lat_ts, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "mercator", @@ -348,7 +377,9 @@ def test_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, {"grid_mapping_name": "mercator", "crs_wkt": proj.to_wkt()} + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -367,16 +398,12 @@ def test_oblique_mercator(self): k_0=k_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "oblique_mercator", @@ -389,7 +416,13 @@ def test_oblique_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "oblique_mercator", + "crs_wkt": proj.to_wkt(), + }, + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -404,16 +437,12 @@ def test_orthographic(self): lat_0=lat_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - f = f0.copy() - set_easting_northing(f, easting, northing) - - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "orthographic", @@ -424,7 +453,9 @@ def test_orthographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, {"grid_mapping_name": "orthographic", "crs_wkt": proj.to_wkt()} + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -441,16 +472,12 @@ def test_polar_stereographic(self): lat_ts=lat_ts, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "polar_stereographic", @@ -462,7 +489,13 @@ def test_polar_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - set_coordinate_reference(f, {"crs_wkt": proj.to_wkt()}) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "polar_stereographic", + "crs_wkt": proj.to_wkt(), + }, + ) g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) @@ -478,16 +511,12 @@ def test_rotated_latitude_longitude(self): o_lon_p=o_lon_p, o_lat_p=o_lat_p, lon_0=lon_0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "rotated_latitude_longitude", @@ -499,6 +528,16 @@ def test_rotated_latitude_longitude(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "rotated_latitude_longitude", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_sinusoidal(self): """Test sinusoidal.""" # Get the easting and northing for Paris @@ -508,16 +547,12 @@ def test_sinusoidal(self): lon_0=lon_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "sinusoidal", @@ -527,6 +562,12 @@ def test_sinusoidal(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, {"grid_mapping_name": "sinusoidal", "crs_wkt": proj.to_wkt()} + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_stereographic(self): """Test stereographic.""" # Get the easting and northing for Paris @@ -540,16 +581,12 @@ def test_stereographic(self): k_0=k_0, x_0=0, y_0=0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - f = f0.copy() - set_easting_northing(f, easting, northing) - - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "stereographic", @@ -561,14 +598,20 @@ def test_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, {"grid_mapping_name": "stereographic", "crs_wkt": proj.to_wkt()} + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_transverse_mercator(self): """Test transverse_mercator.""" # Get the easting and northing for Paris - lat_0=0 - lon_0=3 - k_0=0.9996 - x_0=500000 - y_0=0 + lat_0 = 0 + lon_0 = 3 + k_0 = 0.9996012717 + x_0 = 500000 + y_0 = 0 proj = pyproj.CRS( proj="tmerc", lon_0=lon_0, @@ -576,16 +619,12 @@ def test_transverse_mercator(self): k_0=k_0, x_0=x_0, y_0=y_0, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - - f = f0.copy() - set_easting_northing(f, easting, northing) - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "transverse_mercator", @@ -599,27 +638,33 @@ def test_transverse_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "transverse_mercator", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + def test_vertical_perspective(self): """Test vertical_perspective.""" # Get the easting and northing for Paris - h=3000000 - lat_0=48.8584 - lon_0=2.2945 + h = 3000000 + lat_0 = 48.8584 + lon_0 = 2.2945 proj = pyproj.CRS( proj="nsper", lon_0=lon_0, lat_0=lat_0, h=h, - ellps="WGS84", + ellps=ellps, units=units, ) - t = pyproj.Transformer.from_crs(self.longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) - f = f0.copy() - set_easting_northing(f, easting, northing) - - set_coordinate_reference( + f = field_paris(proj) + set_coordinate_conversion( f, { "grid_mapping_name": "vertical_perspective", @@ -631,6 +676,16 @@ def test_vertical_perspective(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + set_coordinate_conversion( + f, + { + "grid_mapping_name": "vertical_perspective", + "crs_wkt": proj.to_wkt(), + }, + ) + g = f.create_latlon_coordinates() + self.assertTrue(check_paris(g)) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) From 2dc12d704f574c28622370ec3dc809653c45d4bd Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 15 Jul 2026 12:20:49 +0100 Subject: [PATCH 20/43] dev --- cf/functions.py | 17 ++- cf/mixin/fielddomain.py | 58 ++++++-- cf/mixin/utils/grid_mapping.py | 247 ++++++++++++++++----------------- cf/mixin/utils/latlon_utils.py | 59 +++++--- cf/test/test_2d_latlon.py | 48 ++++--- cf/test/test_Field.py | 6 +- 6 files changed, 243 insertions(+), 192 deletions(-) diff --git a/cf/functions.py b/cf/functions.py index d4943bcbd4..ff74e3af5e 100644 --- a/cf/functions.py +++ b/cf/functions.py @@ -1,5 +1,4 @@ import atexit -import os import platform import warnings from collections.abc import Iterable @@ -19,7 +18,7 @@ import numpy as np from . import __file__, __version__ -from .constants import OperandBoundsCombination, _stash2standard_name +from .constants import OperandBoundsCombination from .docstring import _docstring_substitution_definitions @@ -2542,14 +2541,17 @@ def load_stash2standard_name( try: import umfive except Exception: - return + raise ImportError( + "Must install 'umfive' to load a STASH to standard name " + "conversion table." + ) umfive.load_stash_table( table=table, delimiter=delimiter, merge=merge, reset=reset ) -def stash2standard_name(reset=False): +def stash2standard_name(): """Return a copy of the loaded STASH to standard name conversion table. @@ -2561,9 +2563,12 @@ def stash2standard_name(reset=False): try: import umfive except Exception: - return {} + raise ImportError( + "Must install 'umfive' to get the STASH to standard name " + "conversion table." + ) - return umfive.stash_table(reset=reset) + return umfive.stash_table() def flat(x): diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index a07a82c3d0..36f7ccc183 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2362,7 +2362,7 @@ def healpix_to_ugrid(self, cache=True, inplace=False): # that the north (south) polar vertex comes out as a single # node in the domain topology. f.create_latlon_coordinates( - two_d=False, pole_longitude=0, cache=cache, inplace=True + two_d=False, longitude_at_pole=0, cache=cache, inplace=True ) # Get the lat/lon coordinates @@ -2456,11 +2456,12 @@ def create_latlon_coordinates( self, one_d=True, two_d=True, - pole_longitude=None, + longitude_at_pole=None, overwrite=False, cache=True, inplace=False, verbose=None, + pole_longitude=None, ): """Create latitude and longitude coordinates. @@ -2470,11 +2471,23 @@ def create_latlon_coordinates( new coordinates are only created if the {{class}} doesn't already include any latitude or longitude coordinates. + .. note:: Latitude and longitude coordinates can only be + created if each relevant coordinate reference + construct has a ``grid_mapping_name`` parameter set + to a valid CF grid mapping name, and this is also + the case when there is a ``crs_wkt`` parameter. See + CF 5.6.1: Use of the CRS Well-known Text Format + (https://doi.org/10.5281/zenodo.14274886). + When it is not possible to create latitude and longitude coordinates, the reason why will be reported if the log level is at ``2``/``'INFO'`` or higher (as set by `cf.log_level` or the *verbose* parameter). + If the log level is at ``3``/``'DEBUG'``/``-1`` then a + description of the `pyproj.CRS` instances used to create 2-d + latitude and longitude coordinates will also be shown. + .. versionadded:: 3.20.0 .. seealso:: `healpix_to_ugrid` @@ -2491,16 +2504,16 @@ def create_latlon_coordinates( latitude and longitude coordinates. If False then 2-d coordinates will not be created. - pole_longitude: `None` or number - Define the longitudes of coordinates or coordinate - bounds that lie exactly on the north or south pole. If - `None` (the default) then the longitudes of such - points are determined by whichever algorithm was used - to create the coordinates, which could result in - different points on a pole having different - longitudes. If set to a number, then the longitudes of - all points on the north or south pole will be given - that value. + longitude_at_pole: `None` or number + Define the treatment of longitudes of coordinates or + coordinate bounds that lie exactly on the north or + south pole. If `None` (the default) then the + longitudes of such points are determined by whichever + algorithm was used to create the coordinates, which + could result in different points on a pole having + different longitudes. If set to a number, then the + longitudes of all points on the north or south pole + will be given that value. overwrite: `bool`, optional If True then remove any existing latitude and @@ -2527,6 +2540,9 @@ def create_latlon_coordinates( {{verbose: `int` or `str` or `None`, optional}} + pole_longitude: Deprecated at version NEXTVERSION + Use *longitude_at_pole* instead. + :Returns: `{{class}}` or `None` @@ -2560,6 +2576,16 @@ def create_latlon_coordinates( Coord references: grid_mapping_name:healpix """ + if pole_longitude is not None: + _DEPRECATION_ERROR_KWARGS( + self, + "create_latlon_coordinates", + {"pole_longitude": pole_longitude}, + message="Use 'longitude_at_pole' instead.", + version="NEXTVERSION", + removed_at="4.0.0", + ) # pragma: no cover + f = _inplace_enabled_define_and_cleanup(self) # ------------------------------------------------------------ @@ -2666,7 +2692,7 @@ def create_latlon_coordinates( ) lat_key, lon_key = _healpix_create_latlon_coordinates( - f, pole_longitude, cache + f, longitude_at_pole, cache ) coords_created = lat_key is not None @@ -2677,7 +2703,11 @@ def create_latlon_coordinates( from .utils import create_2d_latlon_coordinates lat_key, lon_key = create_2d_latlon_coordinates( - f, cr, cr_latlon, cache=cache + f, + cr, + cr_latlon, + longitude_at_pole=88, # longitude_at_pole, + cache=cache, ) coords_created = lat_key is not None diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index b198cec9ec..9cd27eef37 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -2,13 +2,13 @@ :Glossary: -Defintions of `pyproj.CRS` parameters that map to CF grid mapping +Definitions of `pyproj.CRS` parameters that map to CF grid mapping parameters. See https://proj.org/en/stable/operations/projections for details. * a: Semi-major axis of the ellipsoid. -* alpha: Azimuth of centerline clockwise from north at the center +* alpha: Azimuth of centerline clockwise from north at the centre point of the line. If gamma is not given then alpha determines the value of gamma. @@ -99,12 +99,12 @@ def _ellipsoid_parameters(cr): cr: `CoordinateReference` The coordinate reference construct, or `None`, in which - case the CF default ellpsoid is assumed. + case the CF default ellipsoid is assumed. :Returns: `dict` - The `pyproj.CRS` ellpsoid parameters. + The `pyproj.CRS` ellipsoid parameters. """ kwargs = {} @@ -157,7 +157,7 @@ def _ellipsoid_parameters(cr): def _crs_wkt_parameters(cr): - """Get parameters from a crs_wkt cooridnate conversion parameter. + """Get `pyproj.CRS` parameters from a crs_wkt parameter. .. versionadded:: NEXTVERSION @@ -175,12 +175,12 @@ def _crs_wkt_parameters(cr): """ crs_wkt = cr.coordinate_conversion.get_parameter("crs_wkt", None) - if crs_wkt is not None: - import pyproj + if crs_wkt is None: + return {} - return pyproj.CRS.from_wkt(crs_wkt).to_dict() + import pyproj - return {} + return pyproj.CRS.from_wkt(crs_wkt).to_dict() def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): @@ -195,26 +195,15 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): derived. kwargs: `dict` - A dictionary of keyword arguments for initialising the the `pyproj.CRS` instance. The keyword arguments should not include a description of the ellipsoid, as this is automatically derived from *cr*. - If the *cr* contains a ``crs_wkt`` parameter, either in - its coordinate conversion or its datum component, then it - is converted to `pyproj.CRS` keyword arguments that are - automically included. - - If ``coordinate_conversion_wkt`` and ``datum_wkt`` are - dictionaries of keyword arguments from ``crs_wkt`` - parameters in coordinate conversion or datum components; - and ``ellipsoid`` is a dictionary of keyword arguments - returned by ``_get_ellipoid_parameters(cr)``, then the - final keyword arguments passed to `pyproj.CRS` arex - ``coordinate_conversion_wkt | datum_wkt | ellipsoid | - kwargs`` + If the *cr* contains a ``crs_wkt`` parameter then it is + converted to `pyproj.CRS` keyword arguments that are + automatically included. :Returns: @@ -226,14 +215,12 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): # Remove `None` values kwargs = {k: v for k, v in kwargs.items() if v is not None} + + # Specify the units of the 1-d coordinates kwargs["units"] = "m" kwargs = _crs_wkt_parameters(cr) | _ellipsoid_parameters(cr) | kwargs - # # The explicit guardrail for spherical Transverse Mercator setups - # if kwargs.get("proj") == "tmerc" and kwargs.get("ellps") == "sphere": - # kwargs["alpha"] = 0 - try: proj = pyproj.CRS(**kwargs) except Exception as error: @@ -259,14 +246,37 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): return proj -# ==================================================================== -# Functions for creating `pyproj.CRS` instances for each CF grid -# mapping type. -# ==================================================================== +def _cc_parameter(p, parameter, default=None): + """Get a coordinate reference construct parameter. + + If there is a ``crs_wkt`` parameter then *default* will be + returned if the *parameter* does not exist. + + If there is not a ``crs_wkt`` parameter and *default* is not + `None`, then *default* will be returned if the *parameter* does + not exist. + If there is not a ``crs_wkt`` parameter and *default* is `None`, + then a `KeyError` will be raised if the *parameter* does not + exist. -def _cc_parameter(p, parameter, crs_wkt, default=None): - if crs_wkt: + :Parameters: + + p: `dict` + A dictionary of the coordinate reference construct + parameters. + + parameter: `str` + The name of the parameter to get. + + default: optional + + :Returns: + + The parameter value. + + """ + if "crs_wkt" in p: return p.get(parameter, default) if default is not None: @@ -275,6 +285,12 @@ def _cc_parameter(p, parameter, crs_wkt, default=None): return p[parameter] +# ==================================================================== +# Functions for creating `pyproj.CRS` instances for each CF grid +# mapping type. +# ==================================================================== + + def albers_equal_area(cr): """Create an azimuthal_equidistant CRS. @@ -294,17 +310,16 @@ def albers_equal_area(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "aea", - "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_central_meridian"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } - standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + standard_parallel = _cc_parameter(p, "standard_parallel") if standard_parallel is not None: try: lat_1 = standard_parallel[0] @@ -317,8 +332,6 @@ def albers_equal_area(cr): pass kwargs["lat_1"] = lat_1 - elif not crs_wkt: - return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -342,14 +355,13 @@ def azimuthal_equidistant(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "aeqd", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -378,10 +390,10 @@ def geostationary(cr): kwargs = { "proj": "geos", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "h": _cc_parameter(p, "perspective_point_height", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "h": _cc_parameter(p, "perspective_point_height"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } sweep_angle_axis = p.get("sweep_angle_axis") @@ -446,14 +458,13 @@ def lambert_azimuthal_equal_area(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "laea", - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -477,17 +488,16 @@ def lambert_conformal_conic(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "lcc", - "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_central_meridian"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } - standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + standard_parallel = _cc_parameter(p, "standard_parallel") if standard_parallel is not None: try: lat_1 = standard_parallel[0] @@ -500,8 +510,6 @@ def lambert_conformal_conic(cr): pass kwargs["lat_1"] = lat_1 - elif not crs_wkt: - return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -529,12 +537,12 @@ def lambert_cylindrical_equal_area(cr): kwargs = { "proj": "cea", - "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_central_meridian"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } - standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + standard_parallel = _cc_parameter(p, "standard_parallel") if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel elif not crs_wkt: @@ -593,12 +601,12 @@ def mercator(cr): kwargs = { "proj": "merc", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } - standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + standard_parallel = _cc_parameter(p, "standard_parallel") if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel elif not crs_wkt: @@ -626,16 +634,15 @@ def oblique_mercator(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "omerc", - "alpha": _cc_parameter(p, "azimuth_of_central_line", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "lonc": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "k_0": _cc_parameter(p, "scale_factor_at_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "alpha": _cc_parameter(p, "azimuth_of_central_line"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "lonc": _cc_parameter(p, "longitude_of_projection_origin"), + "k_0": _cc_parameter(p, "scale_factor_at_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -659,14 +666,13 @@ def orthographic(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "ortho", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -694,26 +700,26 @@ def polar_stereographic(cr): kwargs = { "proj": "stere", - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } longitude_of_projection_origin = _cc_parameter( - p, "longitude_of_projection_origin", crs_wkt + p, "longitude_of_projection_origin" ) if longitude_of_projection_origin is not None: kwargs["lon_0"] = longitude_of_projection_origin elif not crs_wkt: kwargs["lon_0"] = p["straight_vertical_longitude_from_pole"] - standard_parallel = _cc_parameter(p, "standard_parallel", crs_wkt) + standard_parallel = _cc_parameter(p, "standard_parallel") if standard_parallel is not None: kwargs["lat_ts"] = standard_parallel elif not crs_wkt: kwargs["k_0"] = p["scale_factor_at_projection_origin"] latitude_of_projection_origin = _cc_parameter( - p, "latitude_of_projection_origin", crs_wkt + p, "latitude_of_projection_origin" ) if latitude_of_projection_origin is not None: try: @@ -735,8 +741,6 @@ def polar_stereographic(cr): return kwargs["lat_0"] = latitude_of_projection_origin - elif not crs_wkt: - return # TODO LOG return _create_pyproj_CRS(kwargs, cr) @@ -760,18 +764,15 @@ def rotated_latitude_longitude(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "ob_tran", "o_proj": "longlat", - "o_lat_p": _cc_parameter(p, "grid_north_pole_latitude", crs_wkt), - "o_lon_p": _cc_parameter(p, "north_pole_grid_longitude", crs_wkt, 0), + "o_lat_p": _cc_parameter(p, "grid_north_pole_latitude"), + "o_lon_p": _cc_parameter(p, "north_pole_grid_longitude", 0), } - grid_north_pole_longitude = _cc_parameter( - p, "grid_north_pole_longitude", crs_wkt - ) + grid_north_pole_longitude = _cc_parameter(p, "grid_north_pole_longitude") if grid_north_pole_longitude is not None: try: kwargs["lon_0"] = float(grid_north_pole_longitude) + 180 @@ -784,8 +785,6 @@ def rotated_latitude_longitude(cr): ) # pragma: no cover return - elif not crs_wkt: - return # LOG return _create_pyproj_CRS(kwargs, cr) @@ -809,13 +808,12 @@ def sinusoidal(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "sinu", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -840,15 +838,14 @@ def stereographic(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "stere", - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "k_0": _cc_parameter(p, "scale_factor_at_projection_origin", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "k_0": _cc_parameter(p, "scale_factor_at_projection_origin"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -872,15 +869,14 @@ def transverse_mercator(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "tmerc", - "lon_0": _cc_parameter(p, "longitude_of_central_meridian", crs_wkt), - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "k_0": _cc_parameter(p, "scale_factor_at_central_meridian", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lon_0": _cc_parameter(p, "longitude_of_central_meridian"), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "k_0": _cc_parameter(p, "scale_factor_at_central_meridian"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -905,15 +901,14 @@ def vertical_perspective(cr): """ p = cr.coordinate_conversion.parameters() - crs_wkt = "crs_wkt" in p kwargs = { "proj": "nsper", - "lat_0": _cc_parameter(p, "latitude_of_projection_origin", crs_wkt), - "lon_0": _cc_parameter(p, "longitude_of_projection_origin", crs_wkt), - "h": _cc_parameter(p, "perspective_point_height", crs_wkt), - "x_0": _cc_parameter(p, "false_easting", crs_wkt, 0), - "y_0": _cc_parameter(p, "false_northing", crs_wkt, 0), + "lat_0": _cc_parameter(p, "latitude_of_projection_origin"), + "lon_0": _cc_parameter(p, "longitude_of_projection_origin"), + "h": _cc_parameter(p, "perspective_point_height"), + "x_0": _cc_parameter(p, "false_easting", 0), + "y_0": _cc_parameter(p, "false_northing", 0), } return _create_pyproj_CRS(kwargs, cr) @@ -925,19 +920,17 @@ def create_projection_CRS(cr, grid_mapping_name): :Parameters: - cr: `CoordinateReference` or `None` + cr: `CoordinateReference` The coordinate reference construct that defines the - projection, or `None` if the there isn't one and the - projection is latitude_longitude. + coordinate system. grid_mapping_name: `str` - The ``grid_mapping_name`` parameter of *cr*. Mut be - ``'latitude_longitude'`` if *cr* is `None`. + The grid mapping name. :Returns: `pyproj.CRS` or `None` - The projection CRS, or `None` if it coulcn't be created. + The projection CRS, or `None` if it couldn't be created. """ proj = None @@ -977,6 +970,8 @@ def create_projection_CRS(cr, grid_mapping_name): proj = vertical_perspective(cr) except KeyError as error: + # Trap a KeyError arising from a missing mandatory coordinate + # reference parameter if is_log_level_info(logger): logger.info( f"{cr!r} has missing coordinate conversion property: {error}" diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index c927181352..983e552f3d 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -9,37 +9,27 @@ from .grid_mapping import create_projection_CRS -# from .grid_mapping import ( -# albers_equal_area, -# azimuthal_equidistant, -# geostationary, -# lambert_azimuthal_equal_area, -# lambert_conformal_conic, -# lambert_cylindrical_equal_area, -# latitude_longitude, -# mercator, -# oblique_mercator, -# orthographic, -# polar_stereographic, -# rotated_latitude_longitude, -# sinusoidal, -# stereographic, -# transverse_mercator, -# vertical_perspective, -# ) - logger = logging.getLogger(__name__) -def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): +def create_2d_latlon_coordinates( + f, cr, cr_latlon, longitude_at_pole=None, cache=True +): """Create 2-d latitude and longitude coordinates and bounds. + Creates the 2-d latitude and longitude coordinate constructs that + are implied by the coordinate reference constructs. + When it is not possible to create latitude and longitude coordinates, the reason why will be reported if the log level is at ``2``/``'INFO'`` or higher. - See CF Appendix F: Grid Mappings. - https://doi.org/10.5281/zenodo.14274886 + If the log level is at ``3``/``'DEBUG'``/``-1`` then a description + of the `pyproj.CRS` instances used to create 2-d latitude and + longitude coordinates will also be shown. + + See CF Appendix F: Grid Mappings + (https://doi.org/10.5281/zenodo.14274886). .. versionadded:: NEXTVERSION @@ -56,7 +46,18 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): cr_latlon: `CoordinateReference` or `None` The coordinate reference construct for the latitude_longitude grid mapping, or `None` is there isn't - one. + one, in which case a spherical latitude_longitude grid + mapping is assumed. + + longitude_at_pole: `None` or number + Define the treatment of longitudes of coordinates or + coordinate bounds that lie exactly on the north or south + pole. If `None` (the default) then the longitudes of such + points are determined by whichever algorithm was used to + create the coordinates, which could result in different + points on a pole having different longitudes. If set to a + number, then the longitudes of all points on the north or + south pole will be given that value. cache: `bool`, optional If True (the default) then cache in memory the first and @@ -196,6 +197,10 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): else: del x_mesh, y_mesh + if longitude_at_pole is not None: + # Set the longitude at the poles + lon = np.where((lat == -90) | (lat == 90), longitude_at_pole, lon) + lat = f._Data(lat, "degrees_north") lon = f._Data(lon, "degrees_east") @@ -246,6 +251,14 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon=None, cache=True): else: del x_mesh, y_mesh + if longitude_at_pole is not None: + # Set the longitude at the poles + lon_bounds = np.where( + (lat_bounds == -90) | (lat_bounds == 90), + longitude_at_pole, + lon_bounds, + ) + lat_bounds = f._Bounds(data=f._Data(lat_bounds)) lon_bounds = f._Bounds(data=f._Data(lon_bounds)) diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index abfe8c7e91..5de4cc0e28 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -6,7 +6,7 @@ import cf -ellps="WGS84" +ellps = "WGS84" units = "km" f0 = cf.example_field(0)[0, 0] @@ -31,6 +31,7 @@ longlat = pyproj.CRS.from_string("+proj=longlat +ellps=WGS84") + def check_paris(g, atol=1e13, verbose=False): if verbose: print( @@ -66,15 +67,16 @@ def field_paris(proj): """Create a field for Paris with a projection grid.""" t = pyproj.Transformer.from_crs(longlat, proj, always_xy=1) easting, northing = t.transform(paris_lon, paris_lat) - + f = f0.copy() set_easting_northing(f, easting, northing) - return f - + return f + + class LatLon2dTest(unittest.TestCase): """Test the creation of 2-d lat/lon coordinatesx.""" - def test_field_2d_latlon(self): + def test_Field_2d_create_latlon_coordinates_bounds(self): """Test lat/on bounds.""" f = cf.read("rotated_pole.pp")[0] @@ -114,7 +116,7 @@ def test_field_2d_latlon(self): ) ) - def test_albers_equal_area(self): + def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): """Test albers_equal_area.""" # Get the easting and northing for Paris lat_1 = 43 @@ -157,7 +159,7 @@ def test_albers_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_azimuthal_equidistant(self): + def test_Field_2d_create_latlon_coordinates_azimuthal_equidistant(self): """Test azimuthal_equidistant.""" # Get the easting and northing for Paris lat_0 = 48.8584 @@ -194,7 +196,7 @@ def test_azimuthal_equidistant(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_geostationary(self): + def test_Field_2d_create_latlon_coordinates_geostationary(self): """Test geostationary.""" # Get the easting and northing for Paris h = 35785831 @@ -231,7 +233,9 @@ def test_geostationary(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_lambert_azimuthal_equal_area(self): + def test_Field_2d_create_latlon_coordinates_lambert_azimuthal_equal_area( + self, + ): """Test lambert_azimuthal_equal_area.""" # Get the easting and northing for Paris lat_0 = 52 @@ -272,7 +276,7 @@ def test_lambert_azimuthal_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_lambert_conformal_conic(self): + def test_Field_2d_create_latlon_coordinates_lambert_conformal_conic(self): """Test lambert_conformal_conic.""" # Get the easting and northing for Paris lat_1 = 33 @@ -313,7 +317,9 @@ def test_lambert_conformal_conic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_lambert_cylindrical_equal_area(self): + def test_Field_2d_create_latlon_coordinates_lambert_cylindrical_equal_area( + self, + ): """Test lambert_cylindrical_equal_area.""" # Get the easting and northing for Paris lon_0 = 0 @@ -350,7 +356,7 @@ def test_lambert_cylindrical_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_mercator(self): + def test_Field_2d_create_latlon_coordinates_mercator(self): """Test mercator.""" # Get the easting and northing for Paris lon_0 = 0 @@ -383,7 +389,7 @@ def test_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_oblique_mercator(self): + def test_Field_2d_create_latlon_coordinates_oblique_mercator(self): """Test oblique_mercator.""" # Get the easting and northing for Paris lat_0 = 45 @@ -426,7 +432,7 @@ def test_oblique_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_orthographic(self): + def test_Field_2d_create_latlon_coordinates_orthographic(self): """Test orthographic.""" # Get the easting and northing for Paris lat_0 = 48.8584 @@ -459,7 +465,7 @@ def test_orthographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_polar_stereographic(self): + def test_Field_2d_create_latlon_coordinates_polar_stereographic(self): """Test polar_stereographic.""" # Get the easting and northing for Paris lat_ts = 90 @@ -499,7 +505,9 @@ def test_polar_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_rotated_latitude_longitude(self): + def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( + self, + ): """Test rotated_latitude_longitude.""" # Get the easting and northing for Paris lon_0 = 190 @@ -538,7 +546,7 @@ def test_rotated_latitude_longitude(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_sinusoidal(self): + def test_Field_2d_create_latlon_coordinates_sinusoidal(self): """Test sinusoidal.""" # Get the easting and northing for Paris lon_0 = 0 @@ -568,7 +576,7 @@ def test_sinusoidal(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_stereographic(self): + def test_Field_2d_create_latlon_coordinates_stereographic(self): """Test stereographic.""" # Get the easting and northing for Paris lat_0 = 90 @@ -604,7 +612,7 @@ def test_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_transverse_mercator(self): + def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): """Test transverse_mercator.""" # Get the easting and northing for Paris lat_0 = 0 @@ -648,7 +656,7 @@ def test_transverse_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) - def test_vertical_perspective(self): + def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): """Test vertical_perspective.""" # Get the easting and northing for Paris h = 3000000 diff --git a/cf/test/test_Field.py b/cf/test/test_Field.py index d399f3e18f..b2db15d2f4 100644 --- a/cf/test/test_Field.py +++ b/cf/test/test_Field.py @@ -3285,8 +3285,8 @@ def test_Field_healpix_to_ugrid(self): self.f0.healpix_to_ugrid() @unittest.skipUnless(healpix_available, "Requires 'healpix' package.") - def test_Field_create_latlon_coordinates(self): - """Test Field.create_latlon_coordinates.""" + def test_Field_create_latlon_coordinates_healpix(self): + """Test Field.create_latlon_coordinates with HEALPix.""" # ------------------------------------------------------------ # HEALPix field # ------------------------------------------------------------ @@ -3321,7 +3321,7 @@ def test_Field_create_latlon_coordinates(self): np.allclose(longitude[32:48:4, 2], longitude[32:48:4, 0]) ) - g = f.create_latlon_coordinates(pole_longitude=3.14) + g = f.create_latlon_coordinates(longitude_at_pole=3.14) longitude = g.auxiliary_coordinate("X").bounds.array # North pole self.assertTrue(np.allclose(longitude[3:16:4, 0], 3.14)) From 8aaac63689eb182205bebc07305772f067010550 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 15 Jul 2026 14:12:08 +0100 Subject: [PATCH 21/43] dev --- cf/test/test_pp.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/cf/test/test_pp.py b/cf/test/test_pp.py index aba610fcff..b538c8aaf0 100644 --- a/cf/test/test_pp.py +++ b/cf/test/test_pp.py @@ -138,14 +138,13 @@ def test_PP_um_version(self): f = cf.read(self.ppfile, um={"um_version": "6.6.3"})[0] self.assertEqual(f.get_property("um_version"), "6.6.3") - # def test_PP_file_object(self): - # # Can't yet read PP/UM from file-like objects - # with open(self.ppfile, "rb") as fh: - # with self.assertRaises(NotImplementedError): - # cf.read(fh) - # - # # Check that the file has been rewound - # self.assertEqual(fh.tell(), 0) + def test_PP_file_object(self): + # Can't yet read PP/UM from file-like objects + with open(self.ppfile, "rb") as fh: + f = cf.read(fh) + + # Check that the file has been rewound + self.assertEqual(fh.tell(), 0) if __name__ == "__main__": From 6546ebd3933b9cd35fd512e5dabc3a7eba838df6 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Wed, 15 Jul 2026 14:22:01 +0100 Subject: [PATCH 22/43] dev --- cf/read_write/read.py | 105 - cf/read_write/um/__init__.py | 1 - cf/read_write/um/umread.py | 3872 ---------------------------------- cf/test/test_pp.py | 4 +- 4 files changed, 2 insertions(+), 3980 deletions(-) delete mode 100644 cf/read_write/um/__init__.py delete mode 100644 cf/read_write/um/umread.py diff --git a/cf/read_write/read.py b/cf/read_write/read.py index 8ebdc36596..8d7472ede8 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -1,9 +1,7 @@ import logging -from functools import partial from re import Pattern import cfdm -from cfdm.read_write.exceptions import DatasetTypeError from ..aggregate import aggregate as cf_aggregate from ..cfimplementation import implementation @@ -12,7 +10,6 @@ from ..fieldlist import FieldList from ..functions import _DEPRECATION_ERROR_FUNCTION_KWARGS from ..query import Query -from .um import UMRead logger = logging.getLogger(__name__) @@ -176,24 +173,6 @@ class read(cfdm.read): .. versionadded:: 1.5 - legacy_um_backend: `bool`, optional - If True then read the datasets with the legacy UM backend - that is embedded within the cf library, which was the only - backend available prior to version NEXTVERSION. From - version NEXTVERSION onwards, the `umfive` UM backend - provided by `xnetcdf` is used when *legacy_um_backend* is - False (the default). - - .. note:: The *legacy_um_backend* parameter will be - removed at a future version, at which time only - the `umfive` UM backend (provided via `xnetcdf`) - will be available. If there are questions about - the parsing of UM datasets, please raise an - issue at - https://github.com/NCAS-CMS/umfive/issues. - - .. versionadded:: NEXTVERSION - aggregate: `bool` or `dict`, optional If True (the default) or a dictionary (possibly empty) then aggregate the field constructs read in from all input @@ -637,87 +616,3 @@ def _initialise(self): self.aggregate = aggregate self.aggregate_options = aggregate_options - - def _read(self, dataset): - """Read a given dataset into field or domain constructs. - - The constructs are stored in the `dataset_contents` attribute. - - Called by `__new__`. - - .. versionadded:: 3.18.0 - - :Parameters: - - dataset: `str` - The pathname of the dataset to be read. - - :Returns: - - `None` - - """ - dataset_type = self.dataset_type - - kwargs = self.kwargs - legacy_um_backend = bool(kwargs.get("legacy_um_backend")) - - # ------------------------------------------------------------ - # Try to read as a netCDF dataset - # ------------------------------------------------------------ - if not legacy_um_backend: - super()._read(dataset) - else: - # ------------------------------------------------------------ - # Read as a PP/UM dataset using the legacy UM backend - # ------------------------------------------------------------ - logger.warning( - "The 'legacy_um_backend' parameter will be removed " - "at a future version, at which time only the `umfive` " - "UM backend (provided via `xnetcdf`) will be available. " - "If there are questions about the parsing of UM datasets, " - "please raise an issue at " - "https://github.com/NCAS-CMS/umfive/issues" - ) - - if dataset_type is None or dataset_type.intersection( - self.UM_dataset_types - ): - if not hasattr(self, "um_read"): - # Initialise the UM read function - kwargs = self.kwargs - um_kwargs = { - key: kwargs[key] - for key in ( - "height_at_top_of_model", - "squeeze", - "unsqueeze", - "domain", - "dataset_type", - "unpack", - "verbose", - "filesystem", - "storage_options", - ) - } - um_kwargs["set_standard_name"] = False - um_kwargs["select"] = self.select - um = self.um - um_kwargs["um_version"] = um.get("version") - um_kwargs["fmt"] = um.get("fmt") - um_kwargs["word_size"] = um.get("word_size") - um_kwargs["endian"] = um.get("endian") - - self.um_read = partial( - UMRead(self.implementation).read, **um_kwargs - ) - - try: - # Try to read the dataset - self.dataset_contents = self.um_read(dataset) - except DatasetTypeError as error: - if dataset_type is None: - self.dataset_format_errors.append(error) - else: - # Successfully read the dataset - self.unique_dataset_categories.add("UM") diff --git a/cf/read_write/um/__init__.py b/cf/read_write/um/__init__.py deleted file mode 100644 index 2e8a395343..0000000000 --- a/cf/read_write/um/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .umread import UMRead diff --git a/cf/read_write/um/umread.py b/cf/read_write/um/umread.py deleted file mode 100644 index f49c99bbe2..0000000000 --- a/cf/read_write/um/umread.py +++ /dev/null @@ -1,3872 +0,0 @@ -import itertools -import logging -import textwrap -from datetime import datetime -from uuid import uuid4 - -import cfdm -import numpy as np -from cfdm import Constructs, is_log_level_info -from cfdm.read_write.exceptions import DatasetTypeError - -from cf import __Conventions__, __version__ -from cf.constants import _stash2standard_name -from cf.data import Data -from cf.data.array import UMArray -from cf.decorators import ( - _manage_log_level_via_verbose_attr, - _manage_log_level_via_verbosity, -) -from cf.functions import abspath -from cf.functions import atol as cf_atol -from cf.functions import load_stash2standard_name -from cf.functions import rtol as cf_rtol -from cf.umread_lib.umfile import File -from cf.units import Units - -logger = logging.getLogger(__name__) - -_cached_runid = {} -_cached_latlon = {} -_cached_ctime = {} -_cached_size_1_height_coordinate = {} -_cached_date2num = {} -_cached_model_level_number_coordinate = {} -_cached_regular_array = {} -_cached_regular_bounds = {} - -# -------------------------------------------------------------------- -# Constants -# -------------------------------------------------------------------- -_pi_over_180 = np.pi / 180.0 - -# PP missing data indicator -_pp_rmdi = -1.0e30 - -# No no-missing-data value of BMDI (as described in UMDP F3 v805) -_BMDI_no_missing_data_value = -1.0e30 - -# Reference surface pressure in Pascals -_pstar = 1.0e5 - -# -------------------------------------------------------------------- -# Characters used in decoding LBEXP into a runid -# -------------------------------------------------------------------- -_characters = ( - "a", - "b", - "c", - "d", - "e", - "f", - "g", - "h", - "i", - "j", - "k", - "l", - "m", - "n", - "o", - "p", - "q", - "r", - "s", - "t", - "u", - "v", - "w", - "x", - "y", - "z", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", -) - -_n_characters = len(_characters) - -# # -------------------------------------------------------------------- -# # Number matching regular expression -# # -------------------------------------------------------------------- -# _number_regex = '([-+]?\d*\.?\d+(e[-+]?\d+)?)' - -_Units = { - None: Units(), - "": Units(""), - "1": Units("1"), - "Pa": Units("Pa"), - "m": Units("m"), - "hPa": Units("hPa"), - "K": Units("K"), - "degrees": Units("degrees"), - "degrees_east": Units("degrees_east"), - "degrees_north": Units("degrees_north"), - "days": Units("days"), - "gregorian 1752-09-13": Units("days since 1752-09-13", "gregorian"), - "365_day 1752-09-13": Units("days since 1752-09-13", "365_day"), - "360_day 0-1-1": Units("days since 0-1-1", "360_day"), -} - -# -------------------------------------------------------------------- -# Names of PP integer and real header items -# -------------------------------------------------------------------- -_header_names = ( - "LBYR", - "LBMON", - "LBDAT", - "LBHR", - "LBMIN", - "LBDAY", - "LBYRD", - "LBMOND", - "LBDATD", - "LBHRD", - "LBMIND", - "LBDAYD", - "LBTIM", - "LBFT", - "LBLREC", - "LBCODE", - "LBHEM", - "LBROW", - "LBNPT", - "LBEXT", - "LBPACK", - "LBREL", - "LBFC", - "LBCFC", - "LBPROC", - "LBVC", - "LBRVC", - "LBEXP", - "LBEGIN", - "LBNREC", - "LBPROJ", - "LBTYP", - "LBLEV", - "LBRSVD1", - "LBRSVD2", - "LBRSVD3", - "LBRSVD4", - "LBSRCE", - "LBUSER1", - "LBUSER2", - "LBUSER3", - "LBUSER4", - "LBUSER5", - "LBUSER6", - "LBUSER7", - "BRSVD1", - "BRSVD2", - "BRSVD3", - "BRSVD4", - "BDATUM", - "BACC", - "BLEV", - "BRLEV", - "BHLEV", - "BHRLEV", - "BPLAT", - "BPLON", - "BGOR", - "BZY", - "BDY", - "BZX", - "BDX", - "BMDI", - "BMKS", -) - -# -------------------------------------------------------------------- -# Positions of PP header items in their arrays -# -------------------------------------------------------------------- -( - lbyr, - lbmon, - lbdat, - lbhr, - lbmin, - lbday, - lbyrd, - lbmond, - lbdatd, - lbhrd, - lbmind, - lbdayd, - lbtim, - lbft, - lblrec, - lbcode, - lbhem, - lbrow, - lbnpt, - lbext, - lbpack, - lbrel, - lbfc, - lbcfc, - lbproc, - lbvc, - lbrvc, - lbexp, - lbegin, - lbnrec, - lbproj, - lbtyp, - lblev, - lbrsvd1, - lbrsvd2, - lbrsvd3, - lbrsvd4, - lbsrce, - lbuser1, - lbuser2, - lbuser3, - lbuser4, - lbuser5, - lbuser6, - lbuser7, -) = tuple(range(45)) - -( - brsvd1, - brsvd2, - brsvd3, - brsvd4, - bdatum, - bacc, - blev, - brlev, - bhlev, - bhrlev, - bplat, - bplon, - bgor, - bzy, - bdy, - bzx, - bdx, - bmdi, - bmks, -) = tuple(range(19)) - -# -------------------------------------------------------------------- -# Map PP axis codes to CF standard names (The full list of field code -# keys may be found at -# http://cms.ncas.ac.uk/html_umdocs/wave/@header.) -# -------------------------------------------------------------------- -_coord_standard_name = { - 0: None, # Sigma (or eta, for hybrid coordinate data). - 1: "air_pressure", # Pressure (mb). - 2: "height", # Height above sea level (km) - # Eta (U.M. hybrid coordinates) only: - 3: "atmosphere_hybrid_sigma_pressure_coordinate", - 4: "depth", # Depth below sea level (m) - 5: "model_level_number", # Model level. - 6: "air_potential_temperature", # Theta - 7: "atmosphere_sigma_coordinate", # Sigma only. - 8: None, # Sigma-theta - 10: "latitude", # Latitude (degrees N). - 11: "longitude", # Longitude (degrees E). - # Site number (set of parallel rows or columns e.g.Time series): - 13: None, # "region", - 14: "atmosphere_hybrid_height_coordinate", - 15: "height", - 20: "time", # Time (days) (Gregorian calendar (not 360 day year)) - 21: "time", # Time (months) - 22: "time", # Time (years) - 23: "time", # Time (model days with 360 day model calendar) - 40: None, # pseudolevel - 99: None, # Other - -10: "grid_latitude", # Rotated latitude (degrees). - -11: "grid_longitude", # Rotated longitude (degrees). - -20: "radiation_wavelength", -} - -# -------------------------------------------------------------------- -# Map PP axis codes to CF long names -# -------------------------------------------------------------------- -_coord_long_name = {13: "site"} - -# -------------------------------------------------------------------- -# Map PP axis codes to UDUNITS strings -# -------------------------------------------------------------------- -# _coord_units = { -_axiscode_to_units = { - 0: "1", # Sigma (or eta, for hybrid coordinate data) - 1: "hPa", # air_pressure - 2: "m", # altitude - 3: "1", # atmosphere_hybrid_sigma_pressure_coordinate - 4: "m", # depth - 5: "1", # model_level_number - 6: "K", # air_potential_temperature - 7: "1", # atmosphere_sigma_coordinate - 10: "degrees_north", # latitude - 11: "degrees_east", # longitude - 13: "", # region - 14: "1", # atmosphere_hybrid_height_coordinate - 15: "m", # height - 20: "days", # time (gregorian) - 23: "days", # time (360_day) - 40: "1", # pseudolevel - -10: "degrees", # rotated latitude (not an official axis code) - -11: "degrees", # rotated longitude (not an official axis code) -} - -# -------------------------------------------------------------------- -# Map PP axis codes to Units objects -# -------------------------------------------------------------------- -_axiscode_to_Units = { - 0: _Units["1"], # Sigma (or eta, for hybrid coordinate data) - 1: _Units["hPa"], # air_pressure - 2: _Units["m"], # altitude - 3: _Units["1"], # atmosphere_hybrid_sigma_pressure_coordinate - 4: _Units["m"], # depth - 5: _Units["1"], # model_level_number - 6: _Units["K"], # air_potential_temperature - 7: _Units["1"], # atmosphere_sigma_coordinate - 10: _Units["degrees_north"], # latitude - 11: _Units["degrees_east"], # longitude - 13: _Units[""], # region - 14: _Units["1"], # atmosphere_hybrid_height_coordinate - 15: _Units["m"], # height - 20: _Units["days"], # time (gregorian) - 23: _Units["days"], # time (360_day) - 40: _Units["1"], # pseudolevel - -10: _Units["degrees"], # rotated latitude (not an official axis code) - -11: _Units["degrees"], # rotated longitude (not an official axis code) -} - -# -------------------------------------------------------------------- -# Map PP axis codes to CF axis attributes -# -------------------------------------------------------------------- -_coord_axis = { - 1: "Z", # air_pressure - 2: "Z", # altitude - 3: "Z", # atmosphere_hybrid_sigma_pressure_coordinate - 4: "Z", # depth - 5: "Z", # model_level_number - 6: "Z", # air_potential_temperature - 7: "Z", # atmosphere_sigma_coordinate - 10: "Y", # latitude - 11: "X", # longitude - 13: None, # region - 14: "Z", # atmosphere_hybrid_height_coordinate - 15: "Z", # height - 20: "T", # time (gregorian) - 23: "T", # time (360_day) - 40: None, # pseudolevel - -10: "Y", # rotated latitude (not an official axis code) - -11: "X", # rotated longitude (not an official axis code) -} - -# -------------------------------------------------------------------- -# Map PP axis codes to CF positive attributes -# -------------------------------------------------------------------- -_coord_positive = { - 1: "down", # air_pressure - 2: "up", # altitude - 3: "down", # atmosphere_hybrid_sigma_pressure_coordinate - 4: "down", # depth - 5: None, # model_level_number - 6: "up", # air_potential_temperature - 7: "down", # atmosphere_sigma_coordinate - 10: None, # latitude - 11: None, # longitude - 13: None, # region - 14: "up", # atmosphere_hybrid_height_coordinate - 15: "up", # height - 20: None, # time (gregorian) - 23: None, # time (360_day) - 40: None, # pseudolevel - -10: None, # rotated latitude (not an official axis code) - -11: None, # rotated longitude (not an official axis code) -} - -# -------------------------------------------------------------------- -# Map LBVC codes to PP axis codes. The full list of field code keys -# may be found at http://cms.ncas.ac.uk/html_umdocs/wave/@fcodes -# -------------------------------------------------------------------- -_lbvc_to_axiscode = { - 1: 2, # altitude (Height) - 2: 4, # depth (Depth) - 3: None, # (Geopotential (= g*height)) - 4: None, # (ICAO height) - 6: 4, # model_level_number # Changed from 5 !!! - 7: None, # (Exner pressure) - 8: 1, # air_pressure (Pressure) - 9: 3, # atmosphere_hybrid_sigma_pressure_coordinate (Hybrid pressure) - # dch check: - 10: 7, # atmosphere_sigma_coordinate (Sigma (= p/surface p)) - 16: None, # (Temperature T) - 19: 6, # air_potential_temperature (Potential temperature) - 27: None, # (Atmospheric) density - 28: None, # (d(p*)/dt . p* = surface pressure) - 44: None, # (Time in seconds) - 65: 14, # atmosphere_hybrid_height_coordinate (Hybrid height) - 129: None, # Surface - 176: 10, # latitude (Latitude) - 177: 11, # longitude (Longitude) -} - -# -------------------------------------------------------------------- -# Map model identifier codes to model names. The model identifier code -# is the last four digits of LBSRCE. -# -------------------------------------------------------------------- -_lbsrce_model_codes = {1111: "UM"} - -# -------------------------------------------------------------------- -# Names of PP extra data codes -# -------------------------------------------------------------------- -_extra_data_name = { - 1: "x", - 2: "y", - 3: "y_domain_lower_bound", - 4: "x_domain_lower_bound", - 5: "y_domain_upper_bound", - 6: "x_domain_upper_bound", - 7: "z_domain_lower_bound", - 8: "x_domain_upper_bound", - 9: "title", - 10: "domain_title", - 11: "x_lower_bound", - 12: "x_upper_bound", - 13: "y_lower_bound", - 14: "y_upper_bound", -} - -# -------------------------------------------------------------------- -# LBCODE values for unrotated latitude longitude grids -# -------------------------------------------------------------------- -_true_latitude_longitude_lbcodes = set((1, 2)) - -# -------------------------------------------------------------------- -# LBCODE values for rotated latitude longitude grids -# -------------------------------------------------------------------- -_rotated_latitude_longitude_lbcodes = set((101, 102, 111)) - -# _axis = {'t' : 'dim0', -# 'z' : 'dim1', -# 'y' : 'dim2', -# 'x' : 'dim3', -# 'r' : 'dim4', -# 'p' : 'dim5', -# 'area': None, -# } - -_axis = {"area": "area"} - -_autocyclic_false = {"no-op": True, "X": False, "cyclic": False} - - -class UMField: - """Represents Fields derived from a UM fields file.""" - - def __init__( - self, - filename, - var, - fmt, - byte_ordering, - word_size, - um_version, - set_standard_name, - height_at_top_of_model, - verbose=None, - implementation=None, - select=None, - info=False, - squeeze=False, - unsqueeze=False, - unpack=True, - storage_protocol=None, - storage_options=None, - **kwargs, - ): - """**Initialisation** - - :Parameters: - - filename: `str` - The name of the PP/UM file. - - .. versionadded:: 3.20.0 - - var: `umfile.Var` - - byte_ordering: `str` - ``'little_endian'` or ``'big_endian'``. - - word_size: `int` - Word size in bytes (4 or 8). - - fmt: `str` - ``'PP'` or ``'FF'`` - - um_version: number - - set_standard_name: `bool` - If True then set the standard_name CF property. - - height_at_top_of_model: `float` - - verbose: `int` or `str` or `None`, optional - If an integer from ``-1`` to ``3``, or an equivalent string - equal ignoring case to one of: - - * ``'DISABLE'`` (``0``) - * ``'WARNING'`` (``1``) - * ``'INFO'`` (``2``) - * ``'DETAIL'`` (``3``) - * ``'DEBUG'`` (``-1``) - - set for the duration of the method call only as the minimum - cut-off for the verboseness level of displayed output (log) - messages, regardless of the globally-configured `cf.log_level`. - Note that increasing numerical value corresponds to increasing - verbosity, with the exception of ``-1`` as a special case of - maximal and extreme verbosity. - - Otherwise, if `None` (the default value), output messages will - be shown according to the value of the `cf.log_level` setting. - - Overall, the higher a non-negative integer or equivalent string - that is set (up to a maximum of ``3``/``'DETAIL'``) for - increasing verbosity, the more description that is printed - about the read process. - - squeeze: `bool`, optional - If True then remove all size 1 dimensions from field - construct data arrays, regardless of how the data are - stored in the dataset. If False (the default) then the - presence or not of size 1 dimensions is determined by - how the data are stored in its dataset. - - .. versionadded:: 3.17.0 - - unsqueeze: `bool`, optional - If True then ensure that field construct data arrays - span all of the size 1 dimensions, regardless of how - the data are stored in the dataset. If False (the - default) then the presence or not of size 1 dimensions - is determined by how the data are stored in its - dataset. - - .. versionadded:: 3.17.0 - - unpack: `bool`, optional - If True, the default, then unpack arrays by convention - when the data is read from disk. - - Unpacking is determined by netCDF conventions for the - following variable attributes ``add_offset`` and - ``scale_factor``, as applied to lookup header entries - BDATUM and BMKS respectively. - - .. versionadded:: 3.17.0 - - storage_protocol: `None` or `str`, optional - The `fsspec` file system protocol (e.g, ``'file'``, - ``'s3'``, ``'http'``). If `None` (the default) then a - local file system is assumed. - - .. versionadded:: 3.20.0 - - storage_options: `dict` or `None`, optional - Key/value pairs to be passed on to the creation of - `s3fs.S3FileSystem` file systems to control the - opening of files in S3 object stores. Ignored for - files not in an S3 object store, i.e. those whose - names do not start with ``s3:``. - - By default, or if `None`, then *storage_options* is - taken as ``{}``. - - If the ``'endpoint_url'`` key is not in - *storage_options* or is not in a dictionary defined by - the ``'client_kwargs`` key (which is always the case - when *storage_options* is `None`), then one will be - automatically inserted for accessing an S3 file. For - example, for a file name of - ``'s3://store/data/file.nc'``, an ``'endpoint_url'`` - key with value ``'https://store'`` would be created. - - *Parameter example:* - For a file name of ``'s3://store/data/file.nc'``, - the following are equivalent: ``None``, ``{}``, and - ``{'endpoint_url': 'https://store'}``, - ``{'client_kwargs': {'endpoint_url': - 'https://store'}}`` - - *Parameter example:* - ``{'key': 'scaleway-api-key...', 'secret': - 'scaleway-secretkey...', 'endpoint_url': - 'https://s3.fr-par.scw.cloud', 'client_kwargs': - {'region_name': 'fr-par'}}`` - - .. versionadded:: 3.20.0 - - kwargs: *optional* - Keyword arguments providing extra CF properties for each - return field construct. - - """ - if squeeze and unsqueeze: - raise ValueError("'squeeze' and 'unsqueeze' can not both be True") - - self._bool = False - - self.info = info - - self.implementation = implementation - - self.verbose = verbose - - self.fmt = fmt - self.height_at_top_of_model = height_at_top_of_model - self.byte_ordering = byte_ordering - self.word_size = word_size - self.unpack = unpack - - self.atol = cf_atol() - - self.field = self.implementation.initialise_Field() - - cf_properties = {} - attributes = {} - - self.fields = [] - - self.filename = filename - self.storage_protocol = storage_protocol - self.storage_options = storage_options - - groups = var.group_records_by_extra_data() - - n_groups = len(groups) - - if n_groups == 1: - # There is one group of records - groups_nz = [var.nz] - groups_nt = [var.nt] - elif n_groups > 1: - # There are multiple groups of records, distinguished by - # different extra data. - groups_nz = [] - groups_nt = [] - groups2 = [] - for group in groups: - group_size = len(group) - if group_size == 1: - # There is only one record in this group - split_group = False - nz = 1 - elif group_size > 1: - # There are multiple records in this group - # Find the lengths of runs of identical times - times = [ - (self.header_vtime(rec), self.header_dtime(rec)) - for rec in group - ] - lengths = [ - len(tuple(g)) for k, g in itertools.groupby(times) - ] - if len(set(lengths)) == 1: - # Each run of identical times has the same - # length, so it is possible that this group - # forms a variable of nz x nt records. - split_group = False - nz = lengths.pop() - z0 = [self.z for rec in group[:nz]] - for i in range(nz, group_size, nz): - z1 = [ - self.header_z(rec) for rec in group[i : i + nz] - ] - if z1 != z0: - split_group = True - break - else: - # Different runs of identical times have - # different lengths, so it is not possible for - # this group to form a variable of nz x nt - # records. - split_group = True - nz = 1 - - if split_group: - # This group doesn't form a complete nz x nt - # matrix, so split it up into 1 x 1 groups. - groups2.extend([[rec] for rec in group]) - groups_nz.extend([1] * group_size) - groups_nt.extend([1] * group_size) - else: - # This group forms a complete nz x nt matrix, so - # it may be considered as a variable in its own - # right and doesn't need to be split up. - groups2.append(group) - groups_nz.append(nz) - groups_nt.append(group_size / nz) - - groups = groups2 - - rec0 = groups[0][0] - - int_hdr = rec0.int_hdr - self.int_hdr_dtype = int_hdr.dtype - self.real_hdr_dtype = rec0.real_hdr.dtype - int_hdr = int_hdr.tolist() - - real_hdr = rec0.real_hdr.tolist() - self.int_hdr = int_hdr - self.real_hdr = real_hdr - - # ------------------------------------------------------------ - # Set some metadata quantities which are guaranteed to be the - # same for all records in a variable - # ------------------------------------------------------------ - LBNPT = int_hdr[lbnpt] - LBROW = int_hdr[lbrow] - LBTIM = int_hdr[lbtim] - LBCODE = int_hdr[lbcode] - LBPROC = int_hdr[lbproc] - LBVC = int_hdr[lbvc] - stash = int_hdr[lbuser4] - LBUSER5 = int_hdr[lbuser5] - submodel = int_hdr[lbuser7] - BPLAT = real_hdr[bplat] - BPLON = real_hdr[bplon] - BDX = real_hdr[bdx] - BDY = real_hdr[bdy] - - if not LBROW or not LBNPT: - logger.warn( - f"WARNING: Skipping STASH code {stash} with LBROW={LBROW}, " - f"LBNPT={LBNPT}, LBPACK={int_hdr[lbpack]} " - "(possibly runlength encoded)" - ) # pragma: no cover - self.field = (None,) - return - - if stash: - section, item = divmod(stash, 1000) - um_stash_source = "m%02ds%02di%03d" % (submodel, section, item) - else: - um_stash_source = None - - header_um_version, source = divmod(int_hdr[lbsrce], 10000) - - if header_um_version > 0 and int(um_version) == um_version: - model_um_version = header_um_version - self.um_version = header_um_version - else: - model_um_version = None - self.um_version = um_version - - # Set source - source = _lbsrce_model_codes.setdefault(source, None) - if source is not None and model_um_version is not None: - source += f" vn{model_um_version}" - - # Only process the requested fields - ok = True - if select: - values1 = ( - f"stash_code={stash}", - f"lbproc={LBPROC}", - f"lbtim={LBTIM}", - f"runid={self.decode_lbexp()}", - f"submodel={submodel}", - ) - if um_stash_source is not None: - values1 += (f"um_stash_source={um_stash_source}",) - if source: - values1 += (f"source={source}",) - - ok = False - for value0 in select: - for value1 in values1: - ok = Constructs._matching_values( - value0, None, value1, basic=True - ) - if ok: - break - - if ok: - break - - if not ok: - # This PP/UM field does not match the requested selection - self.field = (None,) - return - - # Still here? - self.lbnpt = LBNPT - self.lbrow = LBROW - self.lbtim = LBTIM - self.lbproc = LBPROC - self.lbvc = LBVC - self.bplat = BPLAT - self.bplon = BPLON - self.bdx = BDX - self.bdy = BDY - - # ------------------------------------------------------------ - # Set some derived metadata quantities which are (as good as) - # guaranteed to be the same for all records in a variable - # ------------------------------------------------------------ - self.lbtim_ia, ib = divmod(LBTIM, 100) - self.lbtim_ib, ic = divmod(ib, 10) - - if ic == 1: - calendar = "gregorian" - elif ic == 4: - calendar = "365_day" - else: - calendar = "360_day" - - self.calendar = calendar - self.reference_time_Units() - - if source: - cf_properties["source"] = source - - # ------------------------------------------------------------ - # Set the T, Z, Y and X axis codes. These are guaranteed to be - # the same for all records in a variable. - # ------------------------------------------------------------ - if LBCODE == 1 or LBCODE == 2: - # 1 = Unrotated regular lat/long grid - # 2 = Regular lat/lon grid boxes (grid points are box - # centres) - ix = 11 - iy = 10 - elif LBCODE == 101 or LBCODE == 102: - # 101 = Rotated regular lat/long grid - # 102 = Rotated regular lat/lon grid boxes (grid points - # are box centres) - ix = -11 # rotated longitude (not an official axis code) - iy = -10 # rotated latitude (not an official axis code) - elif LBCODE >= 10000: - # Cross section - ix, iy = divmod(divmod(LBCODE, 10000)[1], 100) - else: - ix = None - iy = None - - iz = _lbvc_to_axiscode.setdefault(LBVC, None) - - # Set it from the calendar type - if iy in (20, 23) or ix in (20, 23): - # Time is dealt with by x or y - it = None - elif calendar == "gregorian": - it = 20 - else: - it = 23 - - self.ix = ix - self.iy = iy - self.iz = iz - self.it = it - - self.cf_info = {} - - # Set a identifying name based on the submodel and STASHcode - # (or field code). - # stash = int_hdr[lbuser4]# - self.stash = stash - - # The STASH code has been set in the PP header, so try to find - # its standard_name from the conversion table - stash_records = _stash2standard_name.get((submodel, stash), None) - - um_Units = None - um_condition = None - - long_name = None - standard_name = None - - if stash_records: - um_version = self.um_version - for ( - long_name, - units, - valid_from, - valid_to, - standard_name, - cf_info, - um_condition, - ) in stash_records: - # Check that conditions are met - if not self.test_um_version(valid_from, valid_to, um_version): - continue - - if um_condition: - if not self.test_um_condition( - um_condition, LBCODE, BPLAT, BPLON - ): - continue - - # Still here? Then we have our standard_name, etc. - # if standard_name: - # if set_standard_name: - # cf_properties['standard_name'] = standard_name - # else: - # attributes['_standard_name'] = standard_name - if standard_name and set_standard_name: - cf_properties["standard_name"] = standard_name - - cf_properties["long_name"] = long_name.rstrip() - - um_Units = _Units.get(units, None) - if um_Units is None: - um_Units = Units(units) - _Units[units] = um_Units - - self.um_Units = um_Units - self.cf_info = cf_info - - break - - if um_stash_source is not None: - cf_properties["um_stash_source"] = um_stash_source - identity = f"UM_{um_stash_source}_vn{self.um_version}" - else: - identity = f"UM_{submodel}_fc{int_hdr[lbfc]}_vn{self.um_version}" - - if um_Units is None: - self.um_Units = _Units[None] - - if um_condition: - identity += f"_{um_condition}" - - cf_properties["um_identity"] = identity - - if long_name is None: - cf_properties["long_name"] = identity - - for recs, nz, nt in zip(groups, groups_nz, groups_nt): - self.recs = recs - self.nz = nz - self.nt = nt - self.z_recs = recs[:nz] - self.t_recs = recs[::nz] - - LBUSER5 = recs[0].int_hdr.item(lbuser5) - - # self.cell_method_axis_name = {'area': 'area'} - - self.down_axes = set() - self.z_axis = "z" - - # -------------------------------------------------------- - # Get the extra data for this group - # -------------------------------------------------------- - extra = recs[0].get_extra_data() - self.extra = extra - - # -------------------------------------------------------- - # Create the 'T' dimension coordinate - # -------------------------------------------------------- - axiscode = it - if axiscode is not None: - c = self.time_coordinate(axiscode) - - # -------------------------------------------------------- - # Create the 'Z' dimension coordinate - # -------------------------------------------------------- - axiscode = iz - if axiscode is not None: - # Get 'Z' coordinate from LBVC - if axiscode == 3: - c = self.atmosphere_hybrid_sigma_pressure_coordinate( - axiscode - ) - elif axiscode == 2 and "height" in self.cf_info: - # Create the height coordinate from the information - # given in the STASH to standard_name conversion table - height, units = self.cf_info["height"] - c = self.size_1_height_coordinate(axiscode, height, units) - elif axiscode == 14: - c = self.atmosphere_hybrid_height_coordinate(axiscode) - else: - c = self.z_coordinate(axiscode) - - # Create a model_level_number auxiliary coordinate - LBLEV = int_hdr[lblev] - if LBVC in (2, 9, 65) or LBLEV in (7777, 8888): # CHECK! - self.LBLEV = LBLEV - c = self.model_level_number_coordinate(aux=bool(c)) - - # -------------------------------------------------------- - # Create the 'Y' dimension coordinate - # -------------------------------------------------------- - axiscode = iy - yc = None - if axiscode is not None: - if axiscode in (20, 23): - # 'Y' axis is time-since-reference-date - if extra.get("y", None) is not None: - c = self.time_coordinate_from_extra_data(axiscode, "y") - else: - LBUSER3 = int_hdr[lbuser3] - if LBUSER3 == LBROW: - self.lbuser3 = LBUSER3 - c = self.time_coordinate_from_um_timeseries( - axiscode, "y" - ) - else: - ykey, yc, yaxis = self.xy_coordinate(axiscode, "y") - if axiscode == 13: - _axis["site_axis"] = yaxis - self.site_coordinates_from_extra_data() - - # -------------------------------------------------------- - # Create the 'X' dimension coordinate - # -------------------------------------------------------- - axiscode = ix - xc = None - xkey = None - if axiscode is not None: - if axiscode in (20, 23): - # X axis is time since reference date - if extra.get("x", None) is not None: - c = self.time_coordinate_from_extra_data(axiscode, "x") - else: - LBUSER3 = int_hdr[lbuser3] - if LBUSER3 == LBNPT: - self.lbuser3 = LBUSER3 - c = self.time_coordinate_from_um_timeseries( - axiscode, "x" - ) - else: - xkey, xc, xaxis = self.xy_coordinate(axiscode, "x") - if axiscode == 13: - _axis["site_axis"] = xaxis - self.site_coordinates_from_extra_data() - - # -10: rotated latitude (not an official axis code) - # -11: rotated longitude (not an official axis code) - - if (iy, ix) == (-10, -11) or (iy, ix) == (-11, -10): - # ---------------------------------------------------- - # Create a ROTATED_LATITUDE_LONGITUDE coordinate - # reference - # ---------------------------------------------------- - ref = self.implementation.initialise_CoordinateReference() - - cc = self.implementation.initialise_CoordinateConversion( - parameters={ - "grid_mapping_name": "rotated_latitude_longitude", - "grid_north_pole_latitude": BPLAT, - "grid_north_pole_longitude": BPLON, - } - ) - - self.implementation.set_coordinate_conversion(ref, cc) - - self.implementation.set_coordinate_reference( - self.field, ref, copy=False - ) - - # ---------------------------------------------------- - # Create UNROTATED, 2-D LATITUDE and LONGITUDE - # auxiliary coordinates - # ---------------------------------------------------- - aux_keys = self.latitude_longitude_2d_aux_coordinates(yc, xc) - - self.implementation.set_coordinate_reference_coordinates( - ref, [ykey, xkey] + aux_keys - ) - - # -------------------------------------------------------- - # Create a RADIATION WAVELENGTH dimension coordinate - # -------------------------------------------------------- - try: - rwl, rwl_units = self.cf_info["below"] - except (KeyError, TypeError): - pass - else: - c = self.radiation_wavelength_coordinate(rwl, rwl_units) - - # Set LBUSER5 to zero so that it is not confused for a - # pseudolevel - LBUSER5 = 0 - - # -------------------------------------------------------- - # Create a PSEUDOLEVEL dimension coordinate. This must be - # done *after* the possible creation of a radiation - # wavelength dimension coordinate. - # -------------------------------------------------------- - if LBUSER5 != 0: - self.pseudolevel_coordinate(LBUSER5) - - attributes["int_hdr"] = int_hdr[:] - attributes["real_hdr"] = real_hdr[:] - attributes["file"] = filename - attributes["id"] = identity - - cf_properties["Conventions"] = __Conventions__ - cf_properties["runid"] = self.decode_lbexp() - cf_properties["lbproc"] = str(LBPROC) - cf_properties["lbtim"] = str(LBTIM) - cf_properties["stash_code"] = str(stash) - cf_properties["submodel"] = str(submodel) - - # Convert the UM version to a string and provide it as a - # CF property. E.g. 405 -> '4.5', 606.3 -> '6.6.3', 1002 - # -> '10.2' - # - # Note: We don't just do `divmod(self.um_version, 100)` - # because if self.um_version has a fractional part - # then it would likely get altered in the divmod - # calculation. - a, b = divmod(int(self.um_version), 100) - fraction = str(self.um_version).split(".")[-1] - um = f"{a}.{b}" - if fraction != "0" and fraction != str(self.um_version): - um += f".{fraction}" - - cf_properties["um_version"] = um - - # -------------------------------------------------------- - # Set the data and extra data - # -------------------------------------------------------- - data = self.create_data() - - # -------------------------------------------------------- - # Insert data into the field - # -------------------------------------------------------- - field = self.field - - self.implementation.set_data( - field, self.data, axes=self.data_axes, copy=False - ) - - # -------------------------------------------------------- - # Insert attributes and CF properties into the field - # -------------------------------------------------------- - fill_value = data.fill_value - if fill_value is not None: - cf_properties["_FillValue"] = data.fill_value - - # Add kwargs to the CF properties - cf_properties.update(kwargs) - - self.implementation.set_properties( - field, cf_properties, copy=False - ) - - field.id = identity - - if standard_name and not set_standard_name: - field._custom["standard_name"] = standard_name - - self.implementation.nc_set_variable(field, identity) - - # -------------------------------------------------------- - # Create and insert cell methods - # -------------------------------------------------------- - cell_methods = self.create_cell_methods() - for cm in cell_methods: - self.implementation.set_cell_method(field, cm) - - logger.info(f"down_axes = {self.down_axes}") # pragma: no cover - - # Force cyclic X axis for particular values of LBHEM - if xkey is not None and int_hdr[lbhem] in (0, 1, 2, 4): - field.cyclic( - xkey, - iscyclic=True, - config={ - "axis": xaxis, - "coord": xc, - "period": Data(360.0, xc.Units), - }, - ) - - self.fields.append(field) - - # ------------------------------------------------------------ - # Squeeze/unsqueeze size 1 axes in field constructs - # ------------------------------------------------------------ - if unsqueeze: - for f in self.fields: - f.unsqueeze(inplace=True) - elif squeeze: - for f in self.fields: - f.squeeze(inplace=True) - - self._bool = True - - def __bool__(self): - """x.__bool__() <==> bool(x)""" - return self._bool - - def __repr__(self): - """x.__repr__() <==> repr(x)""" - return self.fdr() - - def __str__(self): - """x.__str__() <==> str(x)""" - out = [self.fdr()] - - attrs = ( - "endian", - "reftime", - "vtime", - "dtime", - "um_version", - "source", - "it", - "iz", - "ix", - "iy", - "site_time_cross_section", - "timeseries", - "file", - ) - - for attr in attrs: - out.append(f"{attr}={getattr(self, attr, None)}") - - out.append("") - - return "\n".join(out) - - def _reorder_z_axis(self, indices, z_axis, pmaxes): - """Reorder the Z axis `Rec` instances. - - :Parameters: - - indices: `list` - Aggregation axis indices. See `create_data` for - details. - - z_axis: `int` - The identifier of the Z axis. - - pmaxes: sequence of `int` - The aggregation axes, which include the Z axis. - - :Returns: - - `list` - - **Examples** - - >>> _reorder_z_axis([(0, ), (1, )], 0, [0]) - [(0, ), (1, )] - - >>> _reorder_z_axis( - ... [(0, 0, ), - ... (0, 1, ), - ... (1, 0, ), - ... (1, 1, )], - ... 1, [0, 1] - ... ) - [(0, 0, ), (0, 1, ), (1, 0, ), (1, 1, )] - - """ - indices_new = [] - zpos = pmaxes.index(z_axis) - aaa0 = indices[0] - indices2 = [aaa0] - for aaa in indices[1:]: - if aaa[zpos] > aaa0[zpos]: - indices2.append(aaa) - else: - indices_new.extend(indices2[::-1]) - aaa0 = aaa - indices2 = [aaa0] - - indices_new.extend(indices2[::-1]) - - indices = [a[:-1] + b[-1:] for a, b in zip(indices, indices_new)] - return indices - - def atmosphere_hybrid_height_coordinate(self, axiscode): - """`atmosphere_hybrid_height_coordinate` when not an array axis. - - **From appendix A of UMDP F3** - - From UM Version 5.2, the method of defining the model levels in PP - headers was revised. At vn5.0 and 5.1, eta values were used in the - PP headers to specify the levels of model data, which was of - limited use when plotting data on model levels. From 5.2, the PP - headers were redefined to give information on the height of the - level. Given a 2D orography field, the height field for a given - level can then be derived. The height coordinates for PP-output - are defined as: - - Z(i,j,k)=Zsea(k)+C(k)*orography(i,j) - - where Zsea(k) and C(k) are height based hybrid coefficients. - - Zsea(k) = eta_value(k)*Height_at_top_of_model - - C(k)=[1-eta_value(k)/eta_value(first_constant_rho_level)]**2 for - levels less than or equal to first_constant_rho_level - C(k)=0.0 for levels greater than first_constant_rho_level - - where eta_value(k) is the eta_value for theta or rho level k. The - eta_value is a terrain-following height coordinate; full details - are given in UMDP15, Appendix B. - - The PP headers store Zsea and C as follows :- - - * 46 = bulev = brsvd1 = Zsea of upper layer boundary - * 47 = bhulev = brsvd2 = C of upper layer boundary - * 52 = blev = Zsea of level - * 53 = brlev = Zsea of lower layer boundary - * 54 = bhlev = C of level - * 55 = bhrlev = C of lower layer boundary - - :Parameters: - - axiscode: `int` - - :Returns: - - `DimensionCoordinate` or `None` - - """ - field = self.field - - # "a" domain ancillary - array = np.array( - [rec.real_hdr[blev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, # Zsea - ) - bounds0 = np.array( - [rec.real_hdr[brlev] for rec in self.z_recs], # Zsea lower - dtype=self.real_hdr_dtype, - ) - bounds1 = np.array( - [rec.real_hdr[brsvd1] for rec in self.z_recs], # Zsea upper - dtype=self.real_hdr_dtype, - ) - bounds = self.create_bounds_array(bounds0, bounds1) - - # Insert new Z axis - da = self.implementation.initialise_DomainAxis(size=array.size) - axis_key = self.implementation.set_domain_axis( - self.field, da, copy=False - ) - _axis["z"] = axis_key - - ac = self.implementation.initialise_DomainAncillary() - ac = self.coord_data(ac, array, bounds, units=_Units["m"]) - ac.id = "UM_atmosphere_hybrid_height_coordinate_a" - self.implementation.set_properties( - ac, {"long_name": "height based hybrid coeffient a"}, copy=False - ) - key_a = self.implementation.set_domain_ancillary( - field, ac, axes=[_axis["z"]], copy=False - ) - - # Height at top of atmosphere - toa_height = self.height_at_top_of_model - if toa_height is None: - pseudolevels = any( - [ - rec.int_hdr.item( - lbuser5, - ) - for rec in self.z_recs - ] - ) - if pseudolevels: - # Pseudolevels and atmosphere hybrid height - # coordinates are both present => can't reliably infer - # height. This is due to a current limitation in the C - # library that means it can only create Z-T - # aggregations, rather than the required Z-T-P - # aggregations. - toa_height = -1 - - if toa_height is None: - toa_height = bounds1.max() - if toa_height <= 0: - toa_height = None - elif toa_height <= 0: - toa_height = None - else: - toa_height = float(toa_height) - - # atmosphere_hybrid_height_coordinate dimension coordinate - if toa_height is None: - dc = None - else: - array = array / toa_height - bounds = bounds / toa_height - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, bounds, units=_Units["1"]) - self.implementation.set_properties( - dc, - {"standard_name": "atmosphere_hybrid_height_coordinate"}, - copy=False, - ) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_positive(dc, axiscode, _axis["z"]) - key_dc = self.implementation.set_dimension_coordinate( - field, - dc, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - # "b" domain ancillary - array = np.array( - [rec.real_hdr[bhlev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds0 = np.array( - [rec.real_hdr[bhrlev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds1 = np.array( - [rec.real_hdr[brsvd2] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds = self.create_bounds_array(bounds0, bounds1) - - ac = self.implementation.initialise_DomainAncillary() - ac = self.coord_data(ac, array, bounds, units=_Units["1"]) - ac.id = "UM_atmosphere_hybrid_height_coordinate_b" - self.implementation.set_properties( - ac, {"long_name": "height based hybrid coeffient b"}, copy=False - ) - key_b = self.implementation.set_domain_ancillary( - field, ac, axes=[_axis["z"]], copy=False - ) - - # atmosphere_hybrid_height_coordinate coordinate reference - ref = self.implementation.initialise_CoordinateReference() - cc = self.implementation.initialise_CoordinateConversion( - parameters={ - "standard_name": "atmosphere_hybrid_height_coordinate" - }, - domain_ancillaries={"a": key_a, "b": key_b, "orog": None}, - ) - self.implementation.set_coordinate_conversion(ref, cc) - if dc is not None: - self.implementation.set_coordinate_reference_coordinates( - ref, (key_dc,) - ) - - self.implementation.set_coordinate_reference(field, ref, copy=False) - - return dc - - def depth_coordinate(self, axiscode): - """`atmosphere_hybrid_height_coordinate_*k` depth coordinate. - - Only applicable when not an array axis. - - :Parameters: - - axiscode: `int` - - :Returns: - - `DimensionCoordinate` or `None` - - """ - dc = self.model_level_number_coordinate(aux=False) - - field = self.field - - array = np.array( - [rec.real_hdr[blev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds0 = np.array( - [rec.real_hdr[brlev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds1 = np.array( - [rec.real_hdr[brsvd1] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds = self.create_bounds_array(bounds0, bounds1) - - # Create Z domain axis construct - da = self.implementation.initialise_DomainAxis(size=array.size) - axisZ = self.implementation.set_domain_axis(field, da, copy=False) - _axis["z"] = axisZ - - # ac = AuxiliaryCoordinate() - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, array, bounds, units=_Units["m"]) - ac.id = "UM_atmosphere_hybrid_height_coordinate_ak" - ac.long_name = "atmosphere_hybrid_height_coordinate_ak" - # field.insert_aux(ac, axes=[zdim], copy=False) - self.implementation.set_auxiliary_coordinate( - field, - ac, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - array = np.array( - [rec.real_hdr[bhlev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds0 = np.array( - [rec.real_hdr[bhrlev] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds1 = np.array( - [rec.real_hdr[brsvd2] for rec in self.z_recs], - dtype=self.real_hdr_dtype, - ) - bounds = self.create_bounds_array(bounds0, bounds1) - - # ac = AuxiliaryCoordinate() - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, array, bounds, units=_Units["1"]) - ac.id = "UM_atmosphere_hybrid_height_coordinate_bk" - ac.long_name = "atmosphere_hybrid_height_coordinate_bk" - self.implementation.set_auxiliary_coordinate( - field, - ac, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - return dc - - def atmosphere_hybrid_sigma_pressure_coordinate(self, axiscode): - """`atmosphere_hybrid_sigma_pressure_coordinate` - - Only applicable when not an array axis. - - 46 BULEV Upper layer boundary or BRSVD(1) - - 47 BHULEV Upper layer boundary or BRSVD(2) - - For hybrid levels: - - BULEV is B-value at half-level above. - - BHULEV is A-value at half-level above. - - For hybrid height levels (vn5.2-, Smooth heights) - - BULEV is Zsea of upper layer boundary - * If rho level: Zsea for theta level above - * If theta level: Zsea for rho level above - - BHLEV is C of upper layer boundary - * If rho level: C for theta level above - * If theta level: C for rho level above - - :Parameters: - - axiscode: `int` - - :Returns: - - `DimensionCoordinate` - - """ - array = [] - bounds = [] - ak_array = [] - ak_bounds = [] - bk_array = [] - bk_bounds = [] - - for rec in self.z_recs: - BLEV, BRLEV, BHLEV, BHRLEV, BULEV, BHULEV = self.header_bz(rec) - - array.append(BLEV + BHLEV / _pstar) - bounds.append([BRLEV + BHRLEV / _pstar, BULEV + BHULEV / _pstar]) - - ak_array.append(BHLEV) - ak_bounds.append((BHRLEV, BHULEV)) - - bk_array.append(BLEV) - bk_bounds.append((BRLEV, BULEV)) - - array = np.array(array, dtype=float) - bounds = np.array(bounds, dtype=float) - ak_array = np.array(ak_array, dtype=float) - ak_bounds = np.array(ak_bounds, dtype=float) - bk_array = np.array(bk_array, dtype=float) - bk_bounds = np.array(bk_bounds, dtype=float) - - field = self.field - - # Insert new Z axis - da = self.implementation.initialise_DomainAxis(size=array.size) - axis_key = self.implementation.set_domain_axis(field, da, copy=False) - _axis["z"] = axis_key - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data( - dc, - array, - bounds, - units=_axiscode_to_Units.setdefault(axiscode, None), - ) - dc = self.coord_positive(dc, axiscode, _axis["z"]) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - self.implementation.set_dimension_coordinate( - field, - dc, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, ak_array, ak_bounds, units=_Units["Pa"]) - ac.id = "UM_atmosphere_hybrid_sigma_pressure_coordinate_ak" - ac.long_name = "atmosphere_hybrid_sigma_pressure_coordinate_ak" - - self.implementation.set_auxiliary_coordinate( - field, - ac, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, bk_array, bk_bounds, units=_Units["1"]) - - self.implementation.set_auxiliary_coordinate( - field, - ac, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - ac.id = "UM_atmosphere_hybrid_sigma_pressure_coordinate_bk" - ac.long_name = "atmosphere_hybrid_sigma_pressure_coordinate_bk" - - return dc - - def create_bounds_array(self, bounds0, bounds1): - """Stack two 1-d arrays to create a bounds array. - - The returned array will have a trailing dimension of size 2. - - The leading dimension size and data type are taken from - *bounds0*. - - :Parameters: - - bounds0: `numpy.ndarray` - The bounds which are to occupy ``[:, 0]`` in the - returned bounds array. - - bounds1: `numpy.ndarray` - The bounds which are to occupy ``[:, 1]`` in the - returned bounds array. - - :Returns: - - `numpy.ndarray` - - """ - bounds = np.empty((bounds0.size, 2), dtype=bounds0.dtype) - bounds[:, 0] = bounds0 - bounds[:, 1] = bounds1 - return bounds - - def create_cell_methods(self): - """Create the cell methods. - - **UMDP F3** - - LBPROC Processing code. This indicates what processing has - been done to the basic field. It should be 0 if no processing - has been done, otherwise add together the relevant numbers - from the list below: - - 1 Difference from another experiment. - 2 Difference from zonal (or other spatial) mean. - 4 Difference from time mean. - 8 X-derivative (d/dx) - 16 Y-derivative (d/dy) - 32 Time derivative (d/dt) - 64 Zonal mean field - 128 Time mean field - 256 Product of two fields - 512 Square root of a field - 1024 Difference between fields at levels BLEV and BRLEV - 2048 Mean over layer between levels BLEV and BRLEV - 4096 Minimum value of field during time period - 8192 Maximum value of field during time period - 16384 Magnitude of a vector, not specifically wind speed - 32768 Log10 of a field - 65536 Variance of a field - 131072 Mean over an ensemble of parallel runs - - :Returns: - - `list` of `str` - The cell methods. - - """ - cell_methods = [] - - LBPROC = self.lbproc - LBTIM_IB = self.lbtim_ib - tmean_proc = 0 - - # ------------------------------------------------------------ - # Ensemble mean cell method - # ------------------------------------------------------------ - if 131072 <= LBPROC < 262144: - cell_methods.append("realization: mean") - LBPROC -= 131072 - - if LBTIM_IB in (2, 3) and LBPROC in (128, 192, 2176, 4224, 8320): - tmean_proc = 128 - LBPROC -= 128 - - # ------------------------------------------------------------ - # Area cell methods - # ------------------------------------------------------------ - # -10: rotated latitude (not an official axis code) - # -11: rotated longitude (not an official axis code) - if self.ix in (10, 11, 12, -10, -11) and self.iy in ( - 10, - 11, - 12, - -10, - -11, - ): - cf_info = self.cf_info - - if "where" in cf_info: - cell_methods.append("area: mean") - - cell_methods.append(cf_info["where"]) - if "over" in cf_info: - cell_methods.append(cf_info["over"]) - - if LBPROC == 64: - cell_methods.append("x: mean") - - # dch : do special zonal mean as as in pp_cfwrite - - # ------------------------------------------------------------ - # Vertical cell methods - # ------------------------------------------------------------ - if LBPROC == 2048: - cell_methods.append("z: mean") - - # ------------------------------------------------------------ - # Time cell methods - # ------------------------------------------------------------ - if "t" in _axis: - axis = "t" - else: - axis = "time" - - if LBTIM_IB == 0 or LBTIM_IB == 1: - if axis == "t": - cell_methods.append(axis + ": point") - elif LBPROC == 4096: - cell_methods.append(axis + ": minimum") - elif LBPROC == 8192: - cell_methods.append(axis + ": maximum") - if tmean_proc == 128: - if LBTIM_IB == 2: - cell_methods.append(axis + ": mean") - elif LBTIM_IB == 3: - cell_methods.append(axis + ": mean within years") - cell_methods.append(axis + ": mean over years") - - if not cell_methods: - return [] - - cell_methods = self.implementation.initialise_CellMethod().create( - " ".join(cell_methods) - ) - - for cm in cell_methods: - cm.change_axes(_axis, inplace=True) - - return cell_methods - - def coord_axis(self, c, axiscode): - """Map axis codes to CF axis attributes for the coordinate.""" - axis = _coord_axis.setdefault(axiscode, None) - if axis is not None: - c.axis = axis - - return c - - def coord_data( - self, - c, - array=None, - bounds=None, - units=None, - fill_value=None, - climatology=False, - ): - """Set the data array of a coordinate construct. - - :Parameters: - - c: Coordinate construct - - data: array-like, optional - The data array. - - bounds: array-like, optional - The Cell bounds for the data array. - - units: `Units`, optional - The units of the data array. - - fill_value: optional - - climatology: `bool`, optional - Whether or not the coordinate construct is a time - climatology. By default it is not. - - :Returns: - - Coordinate construct - - """ - if array is not None: - data = Data(array, units, fill_value=fill_value) - self.implementation.set_data(c, data, copy=False) - - if bounds is not None: - data = Data(bounds, units, fill_value=fill_value) - bounds = self.implementation.initialise_Bounds() - self.implementation.set_data(bounds, data, copy=False) - self.implementation.set_bounds(c, bounds, copy=False) - - return c - - def coord_names(self, coord, axiscode): - """Map axis codes to CF standard names for the coordinate. - - :Parameters: - - coord: Coordinate construct - - axiscode: `int` - - :Returns: - - Coordinate construct - - """ - standard_name = _coord_standard_name.setdefault(axiscode, None) - - if standard_name is not None: - coord.set_property("standard_name", standard_name, copy=False) - coord.ncvar = standard_name - else: - long_name = _coord_long_name.setdefault(axiscode, None) - if long_name is not None: - coord.set_property("long_name", long_name, copy=False) - - return coord - - def coord_positive(self, c, axiscode, domain_axis_key): - """Map axis codes to CF positive attributes for the coordinate. - - :Parameters: - - c: `Coordinate` - A 1-d coordinate construct - - axiscode: `int` - - domain_axis_key: `str` - - :Returns: - - Coordinate construct - - """ - positive = _coord_positive.setdefault(axiscode, None) - if positive is not None: - c.positive = positive - if positive == "down" and axiscode != 4: - self.down_axes.add(domain_axis_key) - c.flip(inplace=True) - - return c - - def ctime(self, rec): - """Return elapsed time since the clock time of the given - record.""" - import cftime - - reftime = self.refUnits - LBVTIME = tuple(self.header_vtime(rec)) - LBDTIME = tuple(self.header_dtime(rec)) - - key = (LBVTIME, LBDTIME, self.refunits, self.calendar) - ctime = _cached_ctime.get(key, None) - if ctime is None: - LBDTIME = list(LBDTIME) - LBDTIME[0] = LBVTIME[0] - - ctime = cftime.datetime(*LBDTIME, calendar=self.calendar) - - if ctime < cftime.datetime(*LBVTIME, calendar=self.calendar): - LBDTIME[0] += 1 - ctime = cftime.datetime(*LBDTIME, calendar=self.calendar) - - ctime = Data(ctime, reftime).array.item() - _cached_ctime[key] = ctime - - return ctime - - def header_vtime(self, rec): - """Return the list [LBYR, LBMON, LBDAT, LBHR, LBMIN] for the - given record. - - :Parameters: - - rec: - - :Returns: - - `list` - - **Examples** - - >>> u.header_vtime(rec) - [1991, 1, 1, 0, 0] - - """ - return rec.int_hdr[lbyr : lbmin + 1] - - def header_dtime(self, rec): - """Return the list [LBYRD, LBMOND, LBDATD, LBHRD, LBMIND] for - the given record. - - :Parameters: - - rec: - - :Returns: - - `list` - - **Examples** - - >>> u.header_dtime(rec) - [1991, 2, 1, 0, 0] - - """ - return rec.int_hdr[lbyrd : lbmind + 1] - - def header_bz(self, rec): - """Return the list [BLEV, BRLEV, BHLEV, BHRLEV, BULEV, BHULEV] - for the given record. - - :Parameters: - - rec: - - :Returns: - - `list` - - **Examples** - - >>> u.header_bz(rec) - - """ - real_hdr = rec.real_hdr - return ( - real_hdr[blev : bhrlev + 1].tolist() - + real_hdr[ # BLEV, BRLEV, BHLEV, BHRLEV - brsvd1 : brsvd2 + 1 - ].tolist() # BULEV, BHULEV - ) - - def header_lz(self, rec): - """Return the list [LBLEV, LBUSER5] for the given record. - - :Parameters: - - rec: - - :Returns: - - `list` - - **Examples** - - >>> u.header_lz(rec) - - """ - int_hdr = rec.int_hdr - return [int_hdr.item(lblev), int_hdr.item(lbuser5)] - - def header_z(self, rec): - """Return the list [LBLEV, LBUSER5, BLEV, BRLEV, BHLEV, BHRLEV, - BULEV, BHULEV] for the given record. - - These header items are used by the compare_levels function in - compare.c - - :Parameters: - - rec: - - :Returns: - - `list` - - **Examples** - - >>> u.header_z(rec) - - """ - return self.header_lz + self.header_bz - - @_manage_log_level_via_verbose_attr - def create_data(self): - """Sets the data and data axes. - - :Returns: - - `Data` - - """ - import dask.array as da - from dask.array.core import getter, normalize_chunks - from dask.base import tokenize - - if self.info: - logger.info("Creating data:") # pragma: no cover - - LBROW = self.lbrow - LBNPT = self.lbnpt - - yx_shape = (LBROW, LBNPT) - - nz = self.nz - nt = self.nt - recs = self.recs - - um_Units = self.um_Units - attributes = { - "units": getattr(um_Units, "units", None), - "calendar": getattr(um_Units, "calendar", None), - } - - data_type_in_file = self.data_type_in_file - - data_axes = [_axis["y"], _axis["x"]] - - # Initialise a dask graph for the uncompressed array, and some - # dask.array.core.getter arguments - token = tokenize((nt, nz) + yx_shape, uuid4()) - name = (UMArray().__class__.__name__ + "-" + token,) - dsk = {} - full_slice = Ellipsis - klass_name = UMArray().__class__.__name__ - - umarray_kwargs = { - "filename": self.filename, - "fmt": self.fmt, - "word_size": self.word_size, - "byte_ordering": self.byte_ordering, - "attributes": attributes, - "unpack": self.unpack, - } - - if len(recs) == 1: - # -------------------------------------------------------- - # 0-d partition matrix - # -------------------------------------------------------- - pmaxes = [] - file_data_types = set() - - rec = recs[0] - - fill_value = rec.real_hdr.item(bmdi) - if fill_value == _BMDI_no_missing_data_value: - fill_value = None - - data_shape = yx_shape - - subarray = UMArray( - address=rec.hdr_offset, - shape=yx_shape, - dtype=data_type_in_file(rec), - **umarray_kwargs, - ) - - key = f"{klass_name}-{tokenize(subarray)}" - dsk[key] = subarray - dsk[name + (0, 0)] = (getter, key, full_slice, False, False) - - dtype = data_type_in_file(rec) - chunks = normalize_chunks((-1, -1), shape=data_shape, dtype=dtype) - else: - # -------------------------------------------------------- - # 1-d or 2-d partition matrix - # -------------------------------------------------------- - file_data_types = set() - - # Find the partition matrix shape - pmshape = [n for n in (nt, nz) if n > 1] - - if len(pmshape) == 1: - # ---------------------------------------------------- - # 1-d partition matrix - # ---------------------------------------------------- - z_axis = _axis.get(self.z_axis) - if nz > 1: - pmaxes = [z_axis] - data_shape = (nz, LBROW, LBNPT) - else: - pmaxes = [_axis["t"]] - data_shape = (nt, LBROW, LBNPT) - - indices = [(i, rec) for i, rec in enumerate(recs)] - - if nz > 1 and z_axis in self.down_axes: - indices = self._reorder_z_axis(indices, z_axis, pmaxes) - - for i, rec in indices: - # Find the data type of the array in the file - file_data_type = data_type_in_file(rec) - file_data_types.add(file_data_type) - - shape = (1,) + yx_shape - - subarray = UMArray( - address=rec.hdr_offset, - shape=shape, - dtype=file_data_type, - **umarray_kwargs, - ) - - key = f"{klass_name}-{tokenize(subarray)}" - dsk[key] = subarray - dsk[name + (i, 0, 0)] = ( - getter, - key, - full_slice, - False, - False, - ) - - dtype = np.result_type(*file_data_types) - chunks = normalize_chunks( - (1, -1, -1), shape=data_shape, dtype=dtype - ) - else: - # ---------------------------------------------------- - # 2-d partition matrix - # ---------------------------------------------------- - z_axis = _axis[self.z_axis] - pmaxes = [_axis["t"], z_axis] - - data_shape = (nt, nz, LBROW, LBNPT) - - indices = [ - divmod(i, nz) + (rec,) for i, rec in enumerate(recs) - ] - if z_axis in self.down_axes: - indices = self._reorder_z_axis(indices, z_axis, pmaxes) - - for t, z, rec in indices: - # Find the data type of the array in the file - file_data_type = data_type_in_file(rec) - file_data_types.add(file_data_type) - - shape = (1, 1) + yx_shape - - subarray = UMArray( - address=rec.hdr_offset, - shape=shape, - dtype=file_data_type, - **umarray_kwargs, - ) - - key = f"{klass_name}-{tokenize(subarray)}" - dsk[key] = subarray - dsk[name + (t, z, 0, 0)] = ( - getter, - key, - full_slice, - False, - False, - ) - - dtype = np.result_type(*file_data_types) - chunks = normalize_chunks( - (1, 1, -1, -1), shape=data_shape, dtype=dtype - ) - - data_axes = pmaxes + data_axes - - # Set the data array - fill_value = recs[0].real_hdr.item(bmdi) - if fill_value == _BMDI_no_missing_data_value: - fill_value = None - - # Create the dask array - dx = da.Array(dsk, name[0], chunks=chunks, dtype=dtype) - - # Create the Data object - data = Data(dx, units=um_Units, fill_value=fill_value) - data._nc_set_aggregation_write_status(True) - - self.data = data - self.data_axes = data_axes - - return data - - def decode_lbexp(self): - """Decode the integer value of LBEXP in the PP header into a - runid. - - If this value has already been decoded, then it will be returned - from the cache, otherwise the value will be decoded and then added - to the cache. - - :Returns: - - `str` - A string derived from LBEXP. If LBEXP is a negative integer - then that number is returned as a string. - - **Examples** - - >>> self.decode_lbexp() - 'aaa5u' - >>> self.decode_lbexp() - '-34' - - """ - LBEXP = self.int_hdr[lbexp] - - runid = _cached_runid.get(LBEXP, None) - if runid is not None: - # Return a cached decoding of this LBEXP - return runid - - if LBEXP < 0: - runid = str(LBEXP) - else: - # Convert LBEXP to a binary string, filled out to 30 bits with - # zeros - bits = bin(LBEXP) - bits = bits.lstrip("0b").zfill(30) - - # Step through 6 bits at a time, converting each 6 bit chunk into - # a decimal integer, which is used as an index to the characters - # lookup list. - runid = [] - for i in range(0, 30, 6): - index = int(bits[i : i + 6], 2) - if index < _n_characters: - runid.append(_characters[index]) - - runid = "".join(runid) - - # Enter this runid into the cache - _cached_runid[LBEXP] = runid - - # Return the runid - return runid - - def dtime(self, rec): - """Return the elapsed time since the data time of the given - record. - - :Parameters: - - rec: - - :Returns: - - `float` - - **Examples** - - >>> u.dtime(rec) - 31.5 - - """ - units = self.refunits - calendar = self.calendar - - LBDTIME = tuple(self.header_dtime(rec)) - - key = (LBDTIME, units, calendar) - time = _cached_date2num.get(key, None) - if time is None: - from netCDF4 import date2num as netCDF4_date2num - - # It is important to use the same time_units as vtime - try: - if self.calendar == "gregorian": - time = netCDF4_date2num( - datetime(*LBDTIME), units, calendar - ) - else: - import cftime - - time = netCDF4_date2num( - cftime.datetime(*LBDTIME, calendar=self.calendar), - units, - calendar, - ) - - _cached_date2num[key] = time - except ValueError: - time = np.nan # ppp - - return time - - def fdr(self): - """Return a the contents of PP field headers as strings. - - This is a bit like printfdr in the UKMO IDL PP library. - - :Returns: - - `list` - - """ - out2 = [] - for i, rec in enumerate(self.recs): - out = [f"Field {i}:"] - - x = [ - f"{name}::{value}" - for name, value in zip( - _header_names, self.int_hdr + self.real_hdr - ) - ] - - x = textwrap.fill(" ".join(x), width=79) - out.append(x.replace("::", ": ")) - - if self.extra: - out.append("EXTRA DATA:") - for key in sorted(self.extra): - out.append(f"{key}: {str(self.extra[key])}") - - out.append("file: " + self.filename) - out.append( - f"fmt, byte order, word size: {self.fmt}, " - f"{self.byte_ordering}, {self.word_size}" - ) - - out.append("") - - out2.append("\n".join(out)) - - return out2 - - def latitude_longitude_2d_aux_coordinates(self, yc, xc): - """Set the latitude and longitude auxiliary coordinates. - - :Parameters: - - yc: `DimensionCoordinate` - - xc: `DimensionCoordinate` - - :Returns: - - `list` - The keys of the auxiliary coordinates. - - """ - BDX = self.bdx - BDY = self.bdy - LBNPT = self.lbnpt - LBROW = self.lbrow - BPLAT = self.bplat - BPLON = self.bplon - - # Create the unrotated latitude and longitude arrays if we - # couldn't find them in the cache - cache_key = (LBNPT, LBROW, BDX, BDY, BPLAT, BPLON) - lat, lon = _cached_latlon.get(cache_key, (None, None)) - - if lat is None: - lat, lon = self.unrotated_latlon(yc.array, xc.array, BPLAT, BPLON) - - atol = self.atol - if abs(BDX) >= atol and abs(BDY) >= atol: - _cached_latlon[cache_key] = (lat, lon) - - if xc.has_bounds() and yc.has_bounds(): # TODO push to implementation - cache_key = ("bounds",) + cache_key - lat_bounds, lon_bounds = _cached_latlon.get( - cache_key, (None, None) - ) - if lat_bounds is None: - xb = np.empty(xc.size + 1) - xb[:-1] = xc.bounds.subspace[:, 0].squeeze(1).array - xb[-1] = xc.bounds.datum(-1, 1) - - yb = np.empty(yc.size + 1) - yb[:-1] = yc.bounds.subspace[:, 0].squeeze(1).array - yb[-1] = yc.bounds.datum(-1, 1) - - temp_lat_bounds, temp_lon_bounds = self.unrotated_latlon( - yb, xb, BPLAT, BPLON - ) - - lat_bounds = np.empty(lat.shape + (4,)) - lon_bounds = np.empty(lon.shape + (4,)) - - lat_bounds[..., 0] = temp_lat_bounds[0:-1, 0:-1] - lon_bounds[..., 0] = temp_lon_bounds[0:-1, 0:-1] - - lat_bounds[..., 1] = temp_lat_bounds[1:, 0:-1] - lon_bounds[..., 1] = temp_lon_bounds[1:, 0:-1] - - lat_bounds[..., 2] = temp_lat_bounds[1:, 1:] - lon_bounds[..., 2] = temp_lon_bounds[1:, 1:] - - lat_bounds[..., 3] = temp_lat_bounds[0:-1, 1:] - lon_bounds[..., 3] = temp_lon_bounds[0:-1, 1:] - - atol = self.atol - if abs(BDX) >= atol and abs(BDY) >= atol: - _cached_latlon[cache_key] = (lat_bounds, lon_bounds) - else: - lat_bounds = None - lon_bounds = None - - axes = [_axis["y"], _axis["x"]] - - keys = [] - for axiscode, array, bounds in zip( - (10, 11), (lat, lon), (lat_bounds, lon_bounds) - ): - # ac = AuxiliaryCoordinate() - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data( - ac, - array, - bounds=bounds, - units=_axiscode_to_Units.setdefault(axiscode, None), - ) - ac = self.coord_names(ac, axiscode) - - key = self.implementation.set_auxiliary_coordinate( - self.field, ac, axes=axes, copy=False - ) - keys.append(key) - - return keys - - def model_level_number_coordinate(self, aux=False): - """model_level_number dimension or auxiliary coordinate. - - :Parameters: - - aux: `bool` - - :Returns: - - out : `AuxiliaryCoordinate` or `DimensionCoordinate` or `None` - - """ - array = tuple([rec.int_hdr.item(lblev) for rec in self.z_recs]) - - key = array - c = _cached_model_level_number_coordinate.get(key, None) - - if c is not None: - if aux: - self.field.insert_aux(c, axes=[_axis["z"]], copy=True) - self.implementation.set_auxiliary_coordinate( - self.field, - c, - axes=[_axis["z"]], - copy=True, - autocyclic=_autocyclic_false, - ) - else: - self.implementation.set_dimension_coordinate( - self.field, - c, - axes=[_axis["z"]], - copy=True, - autocyclic=_autocyclic_false, - ) - else: - array = np.array(array, dtype=self.int_hdr_dtype) - - if array.min() < 0: - return - - array = np.where(array == 9999, 0, array) - - axiscode = 5 - - if aux: - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, array, units=Units("1")) - ac = self.coord_names(ac, axiscode) - self.implementation.set_auxiliary_coordinate( - self.field, - ac, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - else: - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, units=Units("1")) - dc = self.coord_names(dc, axiscode) - dc = self.coord_axis(dc, axiscode) - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - _cached_model_level_number_coordinate[key] = c - - return c - - def data_type_in_file(self, rec): - """Return the data type of the data array. - - :Parameters: - - rec: `umfile.Rec` - - :Returns: - - `numpy.dtype` - - """ - # Find the data type - if rec.int_hdr.item(lbuser2) == 3: - # Boolean - return np.dtype(bool) - - # Int or float - return rec.get_type_and_num_words()[0] - - def printfdr(self, display=False): - """Print out the contents of PP field headers. - - This is a bit like printfdr in the UKMO IDL PP library. - - **Examples** - - >>> u.printfdr() - - """ - if display: - for header in self.fdr(): - print(header) - else: - for header in self.fdr(): - logger.info(header) - - def pseudolevel_coordinate(self, LBUSER5): - """Create and return the pseudolevel coordinate.""" - if self.nz == 1: - array = np.array((LBUSER5,), dtype=self.int_hdr_dtype) - else: - # 'Z' aggregation has been done along the pseudolevel axis - array = np.array( - [rec.int_hdr.item(lbuser5) for rec in self.z_recs], - dtype=self.int_hdr_dtype, - ) - self.z_axis = "p" - - axiscode = 40 - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data( - dc, array, units=_axiscode_to_Units.setdefault(axiscode, None) - ) - self.implementation.set_properties( - dc, {"long_name": "pseudolevel"}, copy=False - ) - dc.id = "UM_pseudolevel" - - da = self.implementation.initialise_DomainAxis(size=array.size) - axisP = self.implementation.set_domain_axis(self.field, da, copy=False) - _axis["p"] = axisP - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["p"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - return dc - - def radiation_wavelength_coordinate(self, rwl, rwl_units): - """Creata and return the radiation wavelength coordinate.""" - array = np.array((rwl,), dtype=float) - bounds = np.array(((0.0, rwl)), dtype=float) - - units = _Units.get(rwl_units, None) - if units is None: - units = Units(rwl_units) - _Units[rwl_units] = units - - axiscode = -20 - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, bounds, units=units) - dc = self.coords_names(dc, axiscode) - - da = self.implementation.initialise_DomainAxis(size=array.size) - axisR = self.implementation.set_domain_axis(self.field, da) - _axis["r"] = axisR - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["r"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - return dc - - def reference_time_Units(self): - """Return the units of the `reference_time`.""" - LBYR = self.int_hdr[lbyr] - time_units = f"days since {LBYR}-1-1" - calendar = self.calendar - - key = time_units + " calendar=" + calendar - units = _Units.get(key, None) - if units is None: - units = Units(time_units, calendar) - _Units[key] = units - - self.refUnits = units - self.refunits = time_units - - return units - - def size_1_height_coordinate(self, axiscode, height, units): - """Create and return the size-one height coordinate.""" - # Create the height coordinate from the information given in the - # STASH to standard_name conversion table - - key = (axiscode, height, units) - dc = _cached_size_1_height_coordinate.get(key, None) - - da = self.implementation.initialise_DomainAxis(size=1) - axisZ = self.implementation.set_domain_axis(self.field, da, copy=False) - _axis["z"] = axisZ - - if dc is not None: - copy = True - else: - height_units = _Units.get(units, None) - if height_units is None: - height_units = Units(units) - _Units[units] = height_units - - array = np.array((height,), dtype=float) - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, units=height_units) - dc = self.coord_positive(dc, axiscode, _axis["z"]) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - _cached_size_1_height_coordinate[key] = dc - copy = False - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["z"]], - copy=copy, - autocyclic=_autocyclic_false, - ) - return dc - - def test_um_condition(self, um_condition, LBCODE, BPLAT, BPLON): - """Return `True` if a field satisfies the condition specified - for a STASH code to standard name conversion. - - :Parameters: - - um_condition: `str` - - LBCODE: `int` - - BPLAT: `float` - - BPLON: `float` - - :Returns: - - `bool` - `True` if a field satisfies the condition specified, - `False` otherwise. - - **Examples** - - >>> ok = u.test_um_condition('true_latitude_longitude', ...) - - """ - if um_condition == "true_latitude_longitude": - if LBCODE in _true_latitude_longitude_lbcodes: - return True - - # Check pole location in case of incorrect LBCODE - atol = self.atol - if ( - abs(BPLAT - 90.0) <= atol + cf_rtol() * 90.0 - and abs(BPLON) <= atol - ): - return True - - elif um_condition == "rotated_latitude_longitude": - if LBCODE in _rotated_latitude_longitude_lbcodes: - return True - - # Check pole location in case of incorrect LBCODE - atol = self.atol - if not ( - abs(BPLAT - 90.0) <= atol + cf_rtol() * 90.0 - and abs(BPLON) <= atol - ): - return True - - else: - raise ValueError( - "Unknown UM condition in STASH code conversion table: " - f"{um_condition!r}" - ) - - # Still here? Then the condition has not been satisfied. - return - - def test_um_version(self, valid_from, valid_to, um_version): - """Return `True` if the UM version applicable to this field is - within the given range. - - If possible, the UM version is derived from the PP header and - stored in the metadata object. Otherwise it is taken from the - *um_version* parameter. - - :Parameters: - - valid_from: `int`, `float` or `None` - - valid_to: `int`, `float` or `None` - - um_version: `int` or `float` - - :Returns: - - `bool` - `True` if the UM version applicable to this field - construct is within the range, `False` otherwise. - - **Examples** - - >>> ok = u.test_um_version(401, 505, 1001) - >>> ok = u.test_um_version(401, None, 606.3) - >>> ok = u.test_um_version(None, 405, 401) - - """ - if valid_to is None: - if valid_from is None: - return True - - if valid_from <= um_version: - return True - elif valid_from is None: - if um_version <= valid_to: - return True - elif valid_from <= um_version <= valid_to: - return True - - return False - - def time_coordinate(self, axiscode): - """Return the T dimension coordinate. - - :Parameters: - - axiscode: `int` - - :Returns: - - `DimensionCoordinate` - - """ - recs = self.t_recs - - vtimes = np.array([self.vtime(rec) for rec in recs], dtype=float) - dtimes = np.array([self.dtime(rec) for rec in recs], dtype=float) - - if np.isnan(vtimes.sum()) or np.isnan(dtimes.sum()): - return # ppp - - IB = self.lbtim_ib - - if IB <= 1 or vtimes.item(0) >= dtimes.item(0): - array = vtimes - bounds = None - climatology = False - elif IB == 3: - # The field is a time mean from T1 to T2 for each year - # from LBYR to LBYRD - ctimes = np.array([self.ctime(rec) for rec in recs]) - array = 0.5 * (vtimes + ctimes) - bounds = self.create_bounds_array(vtimes, dtimes) - - climatology = True - else: - array = 0.5 * (vtimes + dtimes) - bounds = self.create_bounds_array(vtimes, dtimes) - - climatology = False - - da = self.implementation.initialise_DomainAxis(size=array.size) - axisT = self.implementation.set_domain_axis(self.field, da, copy=False) - _axis["t"] = axisT - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data( - dc, array, bounds, units=self.refUnits, climatology=climatology - ) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["t"]], - copy=False, - autocyclic=_autocyclic_false, - ) - return dc - - def time_coordinate_from_extra_data(self, axiscode, axis): - """Create the time coordinate from extra data and return it. - - :Returns: - - `DimensionCoordinate` - - """ - extra = self.extra - - array = extra[axis] - bounds = extra.get(axis + "_bounds", None) - - calendar = self.calendar - if calendar == "360_day": - units = _Units["360_day 0-1-1"] - elif calendar == "gregorian": - units = _Units["gregorian 1752-09-13"] - elif calendar == "365_day": - units = _Units["365_day 1752-09-13"] - else: - units = None - - # Create time domain axis. - # - # Note that `axis` might not be "t". For instance, it could be - # "y" if the time coordinates are coming from extra data. - da = self.implementation.initialise_DomainAxis(size=array.size) - axisT = self.implementation.set_domain_axis(self.field, da, copy=False) - _axis[axis] = axisT - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, bounds, units=units) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=(axisT,), - copy=False, - autocyclic=_autocyclic_false, - ) - - return dc - - def time_coordinate_from_um_timeseries(self, axiscode, axis): - """Create the time coordinate from a timeseries field.""" - # This PP/FF field is a timeseries. The validity time is - # taken to be the time for the first sample, the data time - # for the last sample, with the others evenly between. - rec = self.recs[0] - vtime = self.vtime(rec) - dtime = self.dtime(rec) - - size = self.lbuser3 - 1.0 - delta = (dtime - vtime) / size - - calendar = self.calendar - if calendar == "360_day": - units = _Units["360_day 0-1-1"] - elif calendar == "gregorian": - units = _Units["gregorian 1752-09-13"] - elif calendar == "365_day": - units = _Units["365_day 1752-09-13"] - else: - units = None - - array = np.arange(vtime, vtime + delta * size, size, dtype=float) - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, units=units) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis[axis]], - copy=False, - autocyclic=_autocyclic_false, - ) - return dc - - def vtime(self, rec): - """Return the elapsed time since the validity time of the given - record. - - :Parameters: - - rec: - - :Returns: - - `float` - - **Examples** - - >>> u.vtime(rec) - 31.5 - - """ - units = self.refunits - calendar = self.calendar - - LBVTIME = tuple(self.header_vtime(rec)) - - key = (LBVTIME, units, calendar) - - time = _cached_date2num.get(key, None) - if time is None: - import cftime - - # It is important to use the same time_units as dtime - try: - time = cftime.date2num( - cftime.datetime(*LBVTIME, calendar=self.calendar), - units, - calendar, - ) - - _cached_date2num[key] = time - except ValueError: - time = np.nan # ppp - - return time - - # def dddd(self): - # """TODO.""" - # for axis_code, extra_type in zip((11, 10), ("x", "y")): - # coord_type = extra_type + "_domain_bounds" - # - # if coord_type in p.extra: - # p.extra[coord_type] - # # Create, from extra data, an auxiliary coordinate - # # with 1) data and bounds, if the upper and lower - # # bounds have no missing values; or 2) data but no - # # bounds, if the upper bound has missing values - # # but the lower bound does not. - # - # # Should be the axis which has axis_code 13 - # file_position = ppfile.tell() - # bounds = p.extra[coord_type][...] - # - # # Reset the file pointer after reading the extra - # # data into a numpy array - # ppfile.seek(file_position, os.SEEK_SET) - # data = None - # # dch also test in bmdi?: - # if np.any(bounds[..., 1] == _pp_rmdi): - # # dch also test in bmdi?: - # if not np.any(bounds[..., 0] == _pp_rmdi): - # data = bounds[..., 0] - # bounds = None - # else: - # data = np.mean(bounds, axis=1) - # - # if (data, bounds) != (None, None): - # aux = "aux%(auxN)d" % locals() - # auxN += 1 # Increment auxiliary number - # - # coord = _create_Coordinate( - # domain, - # aux, - # axis_code, - # p=p, - # array=data, - # aux=True, - # bounds_array=bounds, - # pubattr={"axis": None}, - # # DCH xdim? should be the axis which has axis_code 13: - # dimensions=[xdim], - # ) - # else: - # coord_type = "{0}_domain_lower_bound".format(extra_type) - # if coord_type in p.extra: - # # Create, from extra data, an auxiliary - # # coordinate with data but no bounds, if the - # # data noes not contain any missing values - # file_position = ppfile.tell() - # data = p.extra[coord_type][...] - # # Reset the file pointer after reading the - # # extra data into a numpy array - # ppfile.seek(file_position, os.SEEK_SET) - # if not np.any(data == _pp_rmdi): # dch + test in bmdi - # aux = "aux%(auxN)d" % locals() - # auxN += 1 # Increment auxiliary number - # coord = _create_Coordinate( - # domain, - # aux, - # axis_code, - # p=p, - # aux=True, - # array=np.array(data), - # pubattr={"axis": None}, - # dimensions=[xdim], - # ) # DCH xdim? - - def unrotated_latlon(self, rotated_lat, rotated_lon, pole_lat, pole_lon): - """Create 2-d arrays of unrotated latitudes and longitudes. - - :Parameters: - - rotated_lat: `numpy.ndarray` - - rotated_lon: `numpy.ndarray` - - pole_lat: `float` - - pole_lon: `float` - - :Returns: - - lat, lon: `numpy.ndarray`, `numpy.ndarray` - - """ - # Make sure rotated_lon and pole_lon is in [0, 360) - pole_lon = pole_lon % 360.0 - - # Convert everything to radians - pole_lon *= _pi_over_180 - pole_lat *= _pi_over_180 - - cos_pole_lat = np.cos(pole_lat) - sin_pole_lat = np.sin(pole_lat) - - # Create appropriate copies of the input rotated arrays - rot_lon = rotated_lon.copy() - rot_lat = rotated_lat.view() - - # Make sure rotated longitudes are between -180 and 180 - rot_lon %= 360.0 - rot_lon = np.where(rot_lon < 180.0, rot_lon, rot_lon - 360) - - # Create 2-d arrays of rotated latitudes and longitudes in radians - nlat = rot_lat.size - nlon = rot_lon.size - rot_lon = np.resize(np.deg2rad(rot_lon), (nlat, nlon)) - rot_lat = np.resize(np.deg2rad(rot_lat), (nlon, nlat)) - rot_lat = np.transpose(rot_lat, axes=(1, 0)) - - # Find unrotated latitudes - CPART = np.cos(rot_lon) * np.cos(rot_lat) - sin_rot_lat = np.sin(rot_lat) - x = cos_pole_lat * CPART + sin_pole_lat * sin_rot_lat - x = np.clip(x, -1.0, 1.0) - unrotated_lat = np.arcsin(x) - - # Find unrotated longitudes - x = -cos_pole_lat * sin_rot_lat + sin_pole_lat * CPART - x /= np.cos(unrotated_lat) - # dch /0 or overflow here? surely lat could be ~+-pi/2? if so, - # does x ~ cos(lat)? - x = np.clip(x, -1.0, 1.0) - unrotated_lon = -np.arccos(x) - - unrotated_lon = np.where(rot_lon > 0.0, -unrotated_lon, unrotated_lon) - if pole_lon >= self.atol: - SOCK = pole_lon - np.pi - else: - SOCK = 0 - unrotated_lon += SOCK - - # Convert unrotated latitudes and longitudes to degrees - unrotated_lat = np.rad2deg(unrotated_lat) - unrotated_lon = np.rad2deg(unrotated_lon) - - # Return unrotated latitudes and longitudes - return (unrotated_lat, unrotated_lon) - - def xy_coordinate(self, axiscode, axis): - """Create an X or Y dimension coordinate from header entries or - extra data. - - :Parameters: - - axiscode: `int` - - axis: `str` - Which type of coordinate to create: ``'x'`` or - ``'y'``. - - :Returns: - - (`str`, `DimensionCoordinate`) - - """ - X = axiscode in (11, -11) - - if X: - autocyclic = {"X": True} - else: - autocyclic = _autocyclic_false - - if axis == "x": - delta = self.bdx - origin = self.real_hdr[bzx] - size = self.lbnpt - - da = self.implementation.initialise_DomainAxis(size=size) - axis_key = self.implementation.set_domain_axis( - self.field, da, copy=False - ) - _axis["x"] = axis_key - else: - delta = self.bdy - origin = self.real_hdr[bzy] - size = self.lbrow - - da = self.implementation.initialise_DomainAxis(size=size) - axis_key = self.implementation.set_domain_axis( - self.field, da, copy=False - ) - _axis["y"] = axis_key - - autocyclic = _autocyclic_false - - if abs(delta) > self.atol: - # Create regular coordinates from header items - if axiscode == 11 or axiscode == -11: - origin -= divmod(origin + delta * size, 360.0)[0] * 360 - while origin + delta * size > 360.0: - origin -= 360.0 - while origin + delta * size < -360.0: - origin += 360.0 - - array = _cached_regular_array.get((origin, delta, size)) - if array is None: - array = np.arange( - origin + delta, - origin + delta * (size + 0.5), - delta, - dtype=float, - ) - _cached_regular_array[(origin, delta, size)] = array - - # Create the coordinate bounds - if axiscode in (13, 31, 40, 99): - # The following axiscodes do not have bounds: - # 13 = Site number (set of parallel rows or columns - # e.g.Time series) - # 31 = Logarithm to base 10 of pressure in mb - # 40 = Pseudolevel - # 99 = Other - bounds = None - else: - bounds = _cached_regular_bounds.get((origin, delta, size)) - if bounds is None: - delta_by_2 = 0.5 * delta - bounds = self.create_bounds_array( - array - delta_by_2, array + delta_by_2 - ) - _cached_regular_bounds[(origin, delta, size)] = bounds - else: - # Create coordinate from extra data - array = self.extra.get(axis, None) - lower_bounds = self.extra.get(axis + "_lower_bound", None) - upper_bounds = self.extra.get(axis + "_upper_bound", None) - if lower_bounds is not None and upper_bounds is not None: - bounds = self.create_bounds_array(lower_bounds, upper_bounds) - else: - bounds = None - - units = _axiscode_to_Units.setdefault(axiscode, None) - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data(dc, array, bounds, units=units) - dc = self.coord_positive(dc, axiscode, axis_key) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - if X and bounds is not None: - autocyclic["cyclic"] = abs(bounds[0, 0] - bounds[-1, -1]) == 360.0 - autocyclic["period"] = Data(360.0, units) - autocyclic["axis"] = axis_key - autocyclic["coord"] = dc - - key = self.implementation.set_dimension_coordinate( - self.field, dc, axes=[axis_key], copy=False, autocyclic=autocyclic - ) - - return key, dc, axis_key - - def site_coordinates_from_extra_data(self): - """Create site-related coordinates from extra data. - - :Returns: - - `None` - - """ - # Create coordinate from extra data - for axis, standard_name, units in zip( - ("x", "y"), - ("longitude", "latitude"), - (_Units["degrees_east"], _Units["degrees_north"]), - ): - lower_bounds = self.extra.get(axis + "_domain_lower_bound", None) - upper_bounds = self.extra.get(axis + "_domain_upper_bound", None) - if lower_bounds is None or upper_bounds is None: - continue - - # Still here? - bounds = self.create_bounds_array(lower_bounds, upper_bounds) - array = np.average(bounds, axis=1) - - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, array, bounds, units=units) - - ac.standard_name = standard_name - ac.long_name = "region limit" - self.implementation.set_auxiliary_coordinate( - self.field, - ac, - axes=[_axis["site_axis"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - array = self.extra.get("domain_title", None) - if array is not None: - ac = self.implementation.initialise_AuxiliaryCoordinate() - ac = self.coord_data(ac, array, None, units=None) - - ac.standard_name = "region" - self.implementation.set_auxiliary_coordinate( - self.field, - ac, - axes=[_axis["site_axis"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - @_manage_log_level_via_verbose_attr - def z_coordinate(self, axiscode): - """Create a Z dimension coordinate from BLEV. - - :Parameters: - - axiscode: `int` - - :Returns: - - `DimensionCoordinate` - - """ - if self.info: - logger.info( - "Creating Z coordinates and bounds from BLEV, BRLEV and " - "BRSVD1:" - ) # pragma: no cover - - z_recs = self.z_recs - array = tuple([rec.real_hdr.item(blev) for rec in z_recs]) - bounds0 = tuple( - [rec.real_hdr[brlev] for rec in z_recs] - ) # lower level boundary - bounds1 = tuple([rec.real_hdr[brsvd1] for rec in z_recs]) # bulev - if _coord_positive.get(axiscode, None) == "down": - bounds0, bounds1 = bounds1, bounds0 - - array = np.array(array, dtype=self.real_hdr_dtype) - bounds0 = np.array(bounds0, dtype=self.real_hdr_dtype) - bounds1 = np.array(bounds1, dtype=self.real_hdr_dtype) - bounds = self.create_bounds_array(bounds0, bounds1) - - if (bounds0 == bounds1).all() or np.allclose(bounds.min(), _pp_rmdi): - bounds = None - else: - bounds = self.create_bounds_array(bounds0, bounds1) - - da = self.implementation.initialise_DomainAxis(size=array.size) - axisZ = self.implementation.set_domain_axis(self.field, da, copy=False) - _axis["z"] = axisZ - - dc = self.implementation.initialise_DimensionCoordinate() - dc = self.coord_data( - dc, - array, - bounds=bounds, - units=_axiscode_to_Units.setdefault(axiscode, None), - ) - dc = self.coord_positive(dc, axiscode, _axis["z"]) - dc = self.coord_axis(dc, axiscode) - dc = self.coord_names(dc, axiscode) - - self.implementation.set_dimension_coordinate( - self.field, - dc, - axes=[_axis["z"]], - copy=False, - autocyclic=_autocyclic_false, - ) - - return dc - - -class UMRead(cfdm.read_write.IORead): - """A container for instantiating Fields from a UM fields file.""" - - @_manage_log_level_via_verbosity - def read( - self, - dataset, - um_version=None, - aggregate=True, - endian=None, - word_size=None, - set_standard_name=True, - height_at_top_of_model=None, - fmt=None, - chunk=True, - verbose=None, - select=None, - squeeze=False, - unsqueeze=False, - domain=False, - dataset_type=None, - ignore_unknown_type=False, - unpack=True, - filesystem=None, - storage_options=None, - ): - """Read fields from a PP file or UM fields file. - - The file may be big or little endian, 32 or 64 bit - - :Parameters: - - dataset: `file` or `str` - A string giving the file name, or an open file object, - from which to read fields. - - um_version: number, optional - The Unified Model (UM) version to be used when decoding - the PP header. Valid versions are, for example, ``402`` - (v4.2), ``606.3`` (v6.6.3) and ``1001`` (v10.1). The - default version is ``405`` (v4.5). The version is ignored - if it can be inferred from the PP headers, which will - generally be the case for files created at versions 5.3 - and later. Note that the PP header can not encode tertiary - version elements (such as the ``3`` in ``606.3``), so it - may be necessary to provide a UM version in such cases. - - verbose: `int` or `str` or `None`, optional - If an integer from ``-1`` to ``3``, or an equivalent string - equal ignoring case to one of: - - * ``'DISABLE'`` (``0``) - * ``'WARNING'`` (``1``) - * ``'INFO'`` (``2``) - * ``'DETAIL'`` (``3``) - * ``'DEBUG'`` (``-1``) - - set for the duration of the method call only as the minimum - cut-off for the verboseness level of displayed output (log) - messages, regardless of the globally-configured `cf.log_level`. - Note that increasing numerical value corresponds to increasing - verbosity, with the exception of ``-1`` as a special case of - maximal and extreme verbosity. - - Otherwise, if `None` (the default value), output messages will - be shown according to the value of the `cf.log_level` setting. - - Overall, the higher a non-negative integer or equivalent string - that is set (up to a maximum of ``3``/``'DETAIL'``) for - increasing verbosity, the more description that is printed - about the read process. - - set_standard_name: `bool`, optional - - select: (sequence of) `str` or `Query` or `re.Pattern`, optional - Only return field constructs whose identities match - the given values(s), i.e. those fields ``f`` for which - ``f.match_by_identity(*select)`` is `True`. See - `cf.Field.match_by_identity` for details. - - This is equivalent to, but faster than, not using the - *select* parameter but applying its value to the - returned field list with its - `cf.FieldList.select_by_identity` method. For example, - ``fl = cf.read(file, select='stash_code=3236')`` is - equivalent to ``fl = - cf.read(file).select_by_identity('stash_code=3236')``. - - squeeze: `bool`, optional - If True then remove all size 1 dimensions from field - construct data arrays, regardless of how the data are - stored in the dataset. If False (the default) then the - presence or not of size 1 dimensions is determined by - how the data are stored in its dataset. - - .. versionadded:: 3.17.0 - - unsqueeze: `bool`, optional - If True then ensure that field construct data arrays - span all of the size 1 dimensions, regardless of how - the data are stored in the dataset. If False (the - default) then the presence or not of size 1 dimensions - is determined by how the data are stored in its - dataset. - - .. versionadded:: 3.17.0 - - unpack: `bool`, optional - If True, the default, then unpack arrays by convention - when the data is read from disk. - - Unpacking is determined by netCDF conventions for the - following variable attributes ``add_offset`` and - ``scale_factor``, as applied to lookup header entries - BDATUM and BMKS respectively. - - .. versionadded:: 3.17. - - storage_protocol: `None` or `str`, optional - The `fsspec` file system protocol (e.g, ``'file'``, - ``'s3'``, ``'http'``). If `None` (the default) then a - local file system is assumed. - - .. versionadded:: 3.20.0 - - storage_options: `dict` or `None`, optional - Key/value pairs to be passed on to the creation of - `s3fs.S3FileSystem` file systems to control the - opening of files in S3 object stores. Ignored for - files not in an S3 object store, i.e. those whose - names do not start with ``s3:``. - - By default, or if `None`, then *storage_options* is - taken as ``{}``. - - If the ``'endpoint_url'`` key is not in - *storage_options* or is not in a dictionary defined by - the ``'client_kwargs`` key (which is always the case - when *storage_options* is `None`), then one will be - automatically inserted for accessing an S3 file. For - example, for a file name of - ``'s3://store/data/file.nc'``, an ``'endpoint_url'`` - key with value ``'https://store'`` would be created. - - *Parameter example:* - For a file name of ``'s3://store/data/file.nc'``, - the following are equivalent: ``None``, ``{}``, and - ``{'endpoint_url': 'https://store'}``, - ``{'client_kwargs': {'endpoint_url': - 'https://store'}}`` - - *Parameter example:* - ``{'key': 'scaleway-api-key...', 'secret': - 'scaleway-secretkey...', 'endpoint_url': - 'https://s3.fr-par.scw.cloud', 'client_kwargs': - {'region_name': 'fr-par'}}`` - - .. versionadded:: 3.20.0 - - :Returns: - - `list` - The fields in the file. - - **Examples** - - >>> f = read('file.pp') - >>> f = read('*/file[0-9].pp', um_version=708) - - """ - if domain: - raise ValueError( - "Can't read Domain constructs from UM or PP datasets " - "(only Field constructs)" - ) - - representation = self.dataset_representation(dataset) - if representation != "path": - raise NotImplementedError( - "Can't yet read Field constructs from a UM or PP " - f"{representation!r} dataset: {dataset!r}" - ) - - if not _stash2standard_name: - # -------------------------------------------------------- - # Create the STASH code to standard_name conversion - # dictionary - # -------------------------------------------------------- - load_stash2standard_name() - - if endian: - byte_ordering = endian + "_endian" - else: - byte_ordering = None - - if fmt is not None: - fmt = fmt.upper() - - if um_version is None: - um_version = 405 - else: - um_version = float(str(um_version).replace(".", "0", 1)) - - # ------------------------------------------------------------ - # Parse the 'dataset' keyword parameter - # ------------------------------------------------------------ - if filesystem is None: - try: - dataset = abspath(dataset, uri=False) - except ValueError: - dataset = abspath(dataset) - - filename = dataset - self.read_vars = { - "filename": filename, - "byte_ordering": byte_ordering, - "word_size": word_size, - "fmt": fmt, - } - - history = f"Converted from UM/PP by cf-python v{__version__}" - - if endian: - byte_ordering = endian + "_endian" - else: - byte_ordering = None - - # ------------------------------------------------------------ - # Parse the 'dataset_type' keyword parameter - # ------------------------------------------------------------ - if dataset_type is not None: - if isinstance(dataset_type, str): - dataset_type = (dataset_type,) - - dataset_type = set(dataset_type) - if not dataset_type.intersection(("UM",)): - # Return now if there are valid file types - return [] - - if storage_options is not None: - raise NotImplementedError( - "Can't yet open PP/UM files with file system storage options" - ) - - if filesystem is not None: - raise NotImplementedError( - "Can't yet open PP/UM files from a pre-defined file system" - ) - - storage_protocol = None - - f = self.dataset_open(dataset, parse=True) - - info = is_log_level_info(logger) - - um = [ - UMField( - filename, - var, - f.fmt, - f.byte_ordering, - f.word_size, - um_version, - set_standard_name, - history=history, - height_at_top_of_model=height_at_top_of_model, - verbose=verbose, - implementation=self.implementation, - select=select, - info=info, - unpack=unpack, - storage_protocol=storage_protocol, - storage_options=storage_options, - ) - for var in f.vars - ] - - self.dataset_close() - - return [field for x in um for field in x.fields if field] - - def _open_um_file( - self, - filename, - aggregate=True, - fmt=None, - word_size=None, - byte_ordering=None, - parse=True, - ): - """Open a UM fields file or PP file. - - :Parameters: - - filename: `str` - The file to be opened. - - parse: `bool`, optional - If True, the default, then parse the contents. If - False then the contents are not parsed, which can be - considerably faster in cases when the contents are not - required. - - .. versionadded:: 3.16.2 - - :Returns: - - `umread_lib.umfile.File` - The open PP or FF file object. - - """ - self.dataset_close() - try: - f = File( - filename, - byte_ordering=byte_ordering, - word_size=word_size, - fmt=fmt, - parse=parse, - ) - except Exception: - try: - f.close_fd() - except Exception: - pass - - raise DatasetTypeError( - f"\nCan't interpret {filename} as a PP or UM dataset" - ) - - self._um_file = f - return f - - def is_um_file(self, filename): - """Whether or not a file is a PP file or UM fields file. - - Note that the file type is determined by inspecting the file's - content and any file suffix is not considered. - - :Parameters: - - filename: `str` - The file. - - :Returns: - - `bool` - - **Examples** - - >>> r.is_um_file('ppfile') - True - - """ - try: - # Note: No need to completely parse the file to ascertain - # if it's PP or FF. - self.dataset_open(filename, parse=False) - except Exception: - self.dataset_close() - return False - else: - self.dataset_close() - return True - - def dataset_close(self): - """Close the file that has been read. - - :Returns: - - `None` - - """ - f = getattr(self, "_um_file", None) - if f is not None: - f.close_fd() - - self._um_file = None - - def dataset_open(self, filename, parse=True): - """Open the file for reading. - - :Paramters: - - filename: `str` - The file to be read. - - parse: `bool`, optional - If True, the default, then parse the contents. If - False then the contents are not parsed, which can be - considerably faster in cases when the contents are not - required. - - .. versionadded:: 3.16.2 - - :Returns: - - `umread_lib.umfile.File` - The open PP or FF file object. - - """ - g = getattr(self, "read_vars", {}) - - return self._open_um_file( - filename, - byte_ordering=g.get("byte_ordering"), - word_size=g.get("word_size"), - fmt=g.get("fmt"), - parse=parse, - ) - - @classmethod - def dataset_representation(cls, dataset): - """Return the logical representation type of the input dataset. - - .. versionadded:: 3.20.0 - - :Parameters: - - dataset: - The dataset. May be a string-valued path or a - file-like object. - - :Returns: - - `str` - The dataset representation: - - * ``'path'``: A string-valued path. - - * ``'file_handle'``: An open file handle (such as - returned by `fsspec.filesystem.open`) - - * ``'unknown'``: Anything else. - - """ - # Strings (Paths) - if isinstance(dataset, str): - return "path" - - # Check for a "binary stream" (file handle) - if hasattr(dataset, "read") and hasattr(dataset, "seek"): - return "file_handle" - - return "unknown" - - -""" -Problems: - -Z and P coordinates -/home/david/data/pp/aaaao/aaaaoa.pmh8dec.03328.pp - -/net/jasmin/chestnut/data-24/david/testpp/026000000000c.fc0607.000128.0000.00.04.0260.0020.1491.12.01.00.00.pp -skipping variable stash code=0, 0, 0 because: grid code not supported -umfile: error condition detected in routine list_copy_to_ptr_array -umfile: error condition detected in routine process_vars -umfile: error condition detected in routine file_parse -OK 2015-04-01 - -/net/jasmin/chestnut/data-24/david/testpp/026000000000c.fc0619.000128.0000.00.04.0260.0020.1491.12.01.00.00.pp -skipping variable stash code=0, 0, 0 because: grid code not supported -umfile: error condition detected in routine list_copy_to_ptr_array -umfile: error condition detected in routine process_vars -umfile: error condition detected in routine file_parse -OK 2015-04-01 - -/net/jasmin/chestnut/data-24/david/testpp/lbcode_10423.pp -skipping variable stash code=0, 0, 0 because: grid code not supported -umfile: error condition detected in routine list_copy_to_ptr_array -umfile: error condition detected in routine process_vars -umfile: error condition detected in routine file_parse -OK 2015-04-01 - -/net/jasmin/chestnut/data-24/david/testpp/lbcode_11323.pp -skipping variable stash code=0, 0, 0 because: grid code not supported -umfile: error condition detected in routine list_copy_to_ptr_array -umfile: error condition detected in routine process_vars -umfile: error condition detected in routine file_parse -OK 2015-04-01 - -EXTRA_DATA: -/net/jasmin/chestnut/data-24/david/testpp/ajnjgo.pmm1feb.pp - -SLOW: (Not any more! 2015-04-01) -/net/jasmin/chestnut/data-24/david/testpp/xgdria.pdk949a.pp -/net/jasmin/chestnut/data-24/david/testpp/xhbmaa.pm27sep.pp - -RUN LENGTH ENCODED dump (not fields file) -/home/david/data/um/xhlska.dak69h0 -Field 115 (stash code 9) - -dch@eslogin008:/nerc/n02/n02/dch> ff2pp xgvwko.piw96b0 xgvwko.piw96b0.pp - -file xgvwko.piw96b0 is a byte swapped 64 bit ieee um file - -""" diff --git a/cf/test/test_pp.py b/cf/test/test_pp.py index b538c8aaf0..5d48ccdac8 100644 --- a/cf/test/test_pp.py +++ b/cf/test/test_pp.py @@ -141,8 +141,8 @@ def test_PP_um_version(self): def test_PP_file_object(self): # Can't yet read PP/UM from file-like objects with open(self.ppfile, "rb") as fh: - f = cf.read(fh) - + cf.read(fh) + # Check that the file has been rewound self.assertEqual(fh.tell(), 0) From 21492e6ba52187d4f82e23c4a16c555c1812ca0f Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 17 Jul 2026 11:51:58 +0100 Subject: [PATCH 23/43] dev --- cf/mixin/fielddomain.py | 17 +++++++++-------- cf/mixin/utils/grid_mapping.py | 30 +++++++++++++++++++----------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 36f7ccc183..034b497309 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2358,9 +2358,9 @@ def healpix_to_ugrid(self, cache=True, inplace=False): # If 1-d lat/lon coordinates do not exist, then derive them # from the HEALPix indices. Setting the pole_longitude to - # something other than None - it doesn't matter what - ensures - # that the north (south) polar vertex comes out as a single - # node in the domain topology. + # something other than `None` - it doesn't matter what - + # ensures that the north (south) polar vertex comes out as a + # single node in the domain topology. f.create_latlon_coordinates( two_d=False, longitude_at_pole=0, cache=cache, inplace=True ) @@ -2484,9 +2484,9 @@ def create_latlon_coordinates( is at ``2``/``'INFO'`` or higher (as set by `cf.log_level` or the *verbose* parameter). - If the log level is at ``3``/``'DEBUG'``/``-1`` then a - description of the `pyproj.CRS` instances used to create 2-d - latitude and longitude coordinates will also be shown. + If the log level is at ``3``/``'DEBUG'``/``-1`` then + information on how the latitude and longitude coordinates were + created is also reported. .. versionadded:: 3.20.0 @@ -2694,11 +2694,12 @@ def create_latlon_coordinates( lat_key, lon_key = _healpix_create_latlon_coordinates( f, longitude_at_pole, cache ) + coords_created = lat_key is not None if two_d and not coords_created: # -------------------------------------------------------- - # 2-d lat/lon coordinates + # 2-d lat/lon coordinates from 1-d projection coordinates # -------------------------------------------------------- from .utils import create_2d_latlon_coordinates @@ -2706,7 +2707,7 @@ def create_latlon_coordinates( f, cr, cr_latlon, - longitude_at_pole=88, # longitude_at_pole, + longitude_at_pole=longitude_at_pole, cache=cache, ) coords_created = lat_key is not None diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 9cd27eef37..bb0581cfc4 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -3,8 +3,11 @@ :Glossary: Definitions of `pyproj.CRS` parameters that map to CF grid mapping -parameters. See https://proj.org/en/stable/operations/projections for -details. +parameters. + +For further details see +https://proj.org/en/stable/operations/projections and +https://github.com/cf-convention/cf-conventions/wiki/Mapping-from-CF-Grid-Mapping-Attributes-to-CRS-WKT-Elements * a: Semi-major axis of the ellipsoid. @@ -249,16 +252,21 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): def _cc_parameter(p, parameter, default=None): """Get a coordinate reference construct parameter. - If there is a ``crs_wkt`` parameter then *default* will be - returned if the *parameter* does not exist. + If there is a ``crs_wkt`` parameter then: + + - `None` will be returned if the *parameter* does not exist. + + If there is not a ``crs_wkt`` parameter then: - If there is not a ``crs_wkt`` parameter and *default* is not - `None`, then *default* will be returned if the *parameter* does - not exist. + - if *default* is not `None`, then *default* will be returned if + the *parameter* does not exist. - If there is not a ``crs_wkt`` parameter and *default* is `None`, - then a `KeyError` will be raised if the *parameter* does not - exist. + - if *default* is `None`, then a `KeyError` will be raised if the + *parameter* does not exist. + + This behaviour allows a ``crs_wkt`` parameter to provide a value + for a missing CF grid mapping parameter (see `_create_pyproj_CRS` + for details. :Parameters: @@ -277,7 +285,7 @@ def _cc_parameter(p, parameter, default=None): """ if "crs_wkt" in p: - return p.get(parameter, default) + return p.get(parameter) if default is not None: return p.get(parameter, default) From 6a2151b7f3e9b2504c1f68449e1e89b44b3654b1 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 30 Jul 2026 16:45:42 +0100 Subject: [PATCH 24/43] dev --- MANIFEST.in | 2 +- cf/data/array/umarray.py | 735 +----------------- cf/data/collapse/collapse_active.py | 17 +- cf/data/fragment/fragmentumarray.py | 18 +- cf/functions.py | 10 +- cf/mixin/fielddomain.py | 9 +- cf/mixin/utils/grid_mapping.py | 15 +- cf/mixin/utils/latlon_utils.py | 61 +- cf/umread_lib/__init__.py | 0 cf/umread_lib/c-lib/Makefile | 53 -- cf/umread_lib/c-lib/README | 25 - cf/umread_lib/c-lib/bits/constants.h | 9 - cf/umread_lib/c-lib/bits/datatype.h | 15 - cf/umread_lib/c-lib/bits/err_macros.h | 36 - cf/umread_lib/c-lib/bits/ordering.h | 16 - cf/umread_lib/c-lib/bits/pp_header.h | 76 -- .../c-lib/bits/type_dep_entry_protos.h | 42 - cf/umread_lib/c-lib/bits/type_dep_protos.h | 105 --- cf/umread_lib/c-lib/bits/type_dep_redefs.h | 137 ---- cf/umread_lib/c-lib/bits/type_indep_protos.h | 61 -- cf/umread_lib/c-lib/bits/typedefs.h | 208 ----- cf/umread_lib/c-lib/error.c | 68 -- cf/umread_lib/c-lib/filetype.c | 200 ----- cf/umread_lib/c-lib/linklist.c | 261 ------- cf/umread_lib/c-lib/malloc.c | 118 --- cf/umread_lib/c-lib/new_structs.c | 106 --- cf/umread_lib/c-lib/swap.c | 37 - cf/umread_lib/c-lib/type-dep/Makefile | 28 - cf/umread_lib/c-lib/type-dep/axes.c | 75 -- cf/umread_lib/c-lib/type-dep/compare.c | 345 -------- cf/umread_lib/c-lib/type-dep/date_and_time.c | 169 ---- cf/umread_lib/c-lib/type-dep/debug_dump.c | 88 --- .../c-lib/type-dep/interpret_header.c | 141 ---- cf/umread_lib/c-lib/type-dep/levels.c | 97 --- cf/umread_lib/c-lib/type-dep/process_vars.c | 350 --------- cf/umread_lib/c-lib/type-dep/read.c | 501 ------------ cf/umread_lib/c-lib/type-dep/redefs_dbl | 62 -- cf/umread_lib/c-lib/type-dep/redefs_sgl | 62 -- .../c-lib/type-dep/umfile_test_typedep.c | 154 ---- cf/umread_lib/c-lib/type-dep/umfile_typedep.a | Bin 124124 -> 0 bytes cf/umread_lib/c-lib/type-dep/unwgdos.c | 508 ------------ cf/umread_lib/c-lib/umfile.c | 165 ---- cf/umread_lib/c-lib/umfile.h | 204 ----- cf/umread_lib/c-lib/umfileint.h | 25 - cf/umread_lib/cInterface.py | 656 ---------------- cf/umread_lib/extraData.py | 171 ---- cf/umread_lib/umfile.py | 517 ------------ setup.py | 61 +- 48 files changed, 68 insertions(+), 6751 deletions(-) delete mode 100644 cf/umread_lib/__init__.py delete mode 100644 cf/umread_lib/c-lib/Makefile delete mode 100644 cf/umread_lib/c-lib/README delete mode 100644 cf/umread_lib/c-lib/bits/constants.h delete mode 100644 cf/umread_lib/c-lib/bits/datatype.h delete mode 100644 cf/umread_lib/c-lib/bits/err_macros.h delete mode 100644 cf/umread_lib/c-lib/bits/ordering.h delete mode 100644 cf/umread_lib/c-lib/bits/pp_header.h delete mode 100644 cf/umread_lib/c-lib/bits/type_dep_entry_protos.h delete mode 100644 cf/umread_lib/c-lib/bits/type_dep_protos.h delete mode 100644 cf/umread_lib/c-lib/bits/type_dep_redefs.h delete mode 100644 cf/umread_lib/c-lib/bits/type_indep_protos.h delete mode 100644 cf/umread_lib/c-lib/bits/typedefs.h delete mode 100644 cf/umread_lib/c-lib/error.c delete mode 100644 cf/umread_lib/c-lib/filetype.c delete mode 100644 cf/umread_lib/c-lib/linklist.c delete mode 100644 cf/umread_lib/c-lib/malloc.c delete mode 100644 cf/umread_lib/c-lib/new_structs.c delete mode 100644 cf/umread_lib/c-lib/swap.c delete mode 100644 cf/umread_lib/c-lib/type-dep/Makefile delete mode 100644 cf/umread_lib/c-lib/type-dep/axes.c delete mode 100644 cf/umread_lib/c-lib/type-dep/compare.c delete mode 100644 cf/umread_lib/c-lib/type-dep/date_and_time.c delete mode 100644 cf/umread_lib/c-lib/type-dep/debug_dump.c delete mode 100644 cf/umread_lib/c-lib/type-dep/interpret_header.c delete mode 100644 cf/umread_lib/c-lib/type-dep/levels.c delete mode 100644 cf/umread_lib/c-lib/type-dep/process_vars.c delete mode 100644 cf/umread_lib/c-lib/type-dep/read.c delete mode 100644 cf/umread_lib/c-lib/type-dep/redefs_dbl delete mode 100644 cf/umread_lib/c-lib/type-dep/redefs_sgl delete mode 100644 cf/umread_lib/c-lib/type-dep/umfile_test_typedep.c delete mode 100644 cf/umread_lib/c-lib/type-dep/umfile_typedep.a delete mode 100644 cf/umread_lib/c-lib/type-dep/unwgdos.c delete mode 100644 cf/umread_lib/c-lib/umfile.c delete mode 100644 cf/umread_lib/c-lib/umfile.h delete mode 100644 cf/umread_lib/c-lib/umfileint.h delete mode 100644 cf/umread_lib/cInterface.py delete mode 100644 cf/umread_lib/extraData.py delete mode 100644 cf/umread_lib/umfile.py diff --git a/MANIFEST.in b/MANIFEST.in index 6b545ff796..675685f6a0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,7 +9,7 @@ recursive-exclude cf/data *.rst prune cf/test recursive-include cf/test __init__.py test_*.py recursive-include cf/test cfa_test.sh run_tests.py setup_create_field.py create_test_files*.py individual_tests.sh -recursive-include cf/test test_file.nc test_file[2-4].nc file.nc file[1-9].nc ugrid_global_1.nc ugrid_global_2.nc create_test_files.npz create_test_files_2.npz wgdos_packed.pp extra_data.pp file1.pp *.cdl create_test_files.npz create_test_files_2.npz example_field_0.nc cell_measures.nc +recursive-include cf/test test_file.nc test_file[2-4].nc file.nc file[1-9].nc ugrid_global_1.nc ugrid_global_2.nc create_test_files.npz create_test_files_2.npz wgdos_packed.pp extra_data.pp file1.pp rotated_pole.pp *.cdl create_test_files.npz create_test_files_2.npz example_field_0.nc cell_measures.nc recursive-include cf/test/example_field_0.zarr[23] * include cf/test/example_field_0.kerchunk prune cf/test/dir diff --git a/cf/data/array/umarray.py b/cf/data/array/umarray.py index 5c236783e4..d363e9ac18 100644 --- a/cf/data/array/umarray.py +++ b/cf/data/array/umarray.py @@ -1,730 +1,11 @@ -import cfdm +class UMArray: + """A UM array.""" -from cf.constants import _stash2standard_name -from cf.functions import _DEPRECATION_ERROR_ATTRIBUTE, load_stash2standard_name -from cf.umread_lib.umfile import File, Rec + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" -from .abstract import Array - - -class UMArray( - cfdm.data.mixin.IndexMixin, - cfdm.data.abstract.FileArray, - Array, -): - """A sub-array stored in a PP or UM fields file.""" - - def __init__( - self, - filename=None, - address=None, - dtype=None, - shape=None, - fmt=None, - word_size=None, - byte_ordering=None, - mask=True, - unpack=True, - attributes=None, - filesystem=None, - source=None, - copy=True, - ): - """**Initialisation** - - :Parameters: - - filename: (sequence of) `str`, optional - The file name(s). - - address: (sequence of) `int`, optional - The start position in the file(s) of the header(s). - - .. versionadded:: 3.15.0 - - dtype: `numpy.dtype` - The data type of the data array on disk. - - shape: `tuple` - The shape of the unpacked data array. Note that this - is the shape as required by the object containing the - `UMArray` object, and so may contain extra size one - dimensions. When read, the data on disk is reshaped to - *shape*. - - fmt: `str`, optional - ``'PP'`` or ``'FF'`` - - word_size: `int`, optional - ``4`` or ``8`` - - byte_ordering: `str`, optional - ``'little_endian'`` or ``'big_endian'`` - - {{init attributes: `dict` or `None`, optional}} - - During the first `__getitem__` call, any of the - ``_FillValue``, ``add_offset``, ``scale_factor``, - ``units``, and ``calendar`` attributes which haven't - already been set will be inferred from the lookup - header and cached for future use. - - .. versionadded:: 3.16.3 - - {{init source: optional}} - - {{init copy: `bool`, optional}} - - size: `int` - Deprecated at version 3.14.0. - - ndim: `int` - Deprecated at version 3.14.0. - - header_offset: `int` - Deprecated at version 3.15.0. Use the *address* - parameter instead. - - data_offset: `int`, optional - Deprecated at version 3.15.0. - - disk_length: `int`, optional - Deprecated at version 3.15.0. - - units: `str` or `None`, optional - Deprecated at version 3.16.3. Use the - *attributes* parameter instead. - - calendar: `str` or `None`, optional - Deprecated at version 3.16.3. Use the - *attributes* parameter instead. - - storage_protocol: Deprecated at version NEXTVERSION - Use *filesystem* instead. - - storage_protocol: Deprecated at version NEXTVERSION - Use *filesystem* instead. - - """ - super().__init__( - filename=filename, - address=address, - dtype=dtype, - shape=shape, - mask=mask, - unpack=unpack, - attributes=attributes, - filesystem=filesystem, - source=source, - copy=copy, - ) - - if source is not None: - try: - fmt = source._get_component("fmt", None) - except AttributeError: - fmt = None - - try: - word_size = source._get_component("word_size", None) - except AttributeError: - word_size = None - - try: - byte_ordering = source._get_component("byte_ordering", None) - except AttributeError: - byte_ordering = None - - if fmt is not None: - self._set_component("fmt", fmt, copy=False) - - if byte_ordering is not None: - self._set_component("byte_ordering", byte_ordering, copy=False) - - if word_size is not None: - self._set_component("word_size", word_size, copy=False) - - # By default, close the UM file after data array access - self._set_component("close", True, copy=False) - - def _get_array(self, index=None): - """Returns a subspace of the dataset variable. - - .. versionadded:: 3.16.3 - - .. seealso:: `__array__`, `index` - - :Parameters: - - {{index: `tuple` or `None`, optional}} - - :Returns: - - `numpy.ndarray` - The subspace. - - """ - # Note: No need to lock the UM file - concurrent reads are OK. - - if index is None: - index = self.index() - - f, header_offset = self.open() - rec = self._get_rec(f, header_offset) - - int_hdr = rec.int_hdr - real_hdr = rec.real_hdr - array = rec.get_data().reshape(self.original_shape) - - self.close(f) - del f, rec - - # Set the netCDF attributes for the data - attributes = self.get_attributes({}) - self._set_units(int_hdr, attributes) - self._set_FillValue(int_hdr, real_hdr, attributes) - self._set_unpack(int_hdr, real_hdr, attributes) - self._set_component("attributes", attributes, copy=False) - - # Get the data subspace, applying any masking and unpacking - array = cfdm.netcdf_indexer( - array, - mask=self.get_mask(), - unpack=self.get_unpack(), - always_masked_array=False, - orthogonal_indexing=True, - attributes=attributes, - copy=False, - ) - array = array[index] - - if int_hdr.item(38) == 3: - # Convert the data to a boolean array - array = array.astype(bool) - - # Set the data type - self._set_component("dtype", array.dtype, copy=False) - - # Return the numpy array - return array - - def _get_rec(self, f, header_offset): - """Get a container for a record. - - This includes the lookup header and file offsets. - - .. versionadded:: 3.14.0 - - .. seealso:: `close`, `open` - - :Parameters: - - f: `umread_lib.umfile.File` - The open PP or FF file. - - header_offset: `int` - - :Returns: - - `umread_lib.umfile.Rec` - The record container. - - """ - return Rec.from_file_and_offsets(f, header_offset) - - # ------------------------------------------------------------ - # Leave the following commented code here for debugging - # purposes. Replacing the above line with this code moves the - # calculation of the data offset and disk length from pure - # Python to the C library, at the expense of completely - # parsing the file. Note: If you do replace the above line - # with the commented code, then you *must* also set - # 'parse=True' in the `open` method. - # ------------------------------------------------------------ - - # for v in f.vars: - # for r in v.recs: - # if r.hdr_offset == header_offset: - # return r - - def _set_FillValue(self, int_hdr, real_hdr, attributes): - """Set the ``_FillValue`` attribute. - - .. versionadded:: 3.16.3 - - :Parameters: - - int_hdr: `numpy.ndarray` - The integer header of the data. - - real_header: `numpy.ndarray` - The real header of the data. - - attributes: `dict` - The dictionary in which to store the new - attributes. If a new attribute exists then - *attributes* is updated in-place. - - :Returns: - - `None - - """ - if "FillValue" in attributes: - return - - # Set the fill_value from BMDI - _FillValue = real_hdr.item(17) - if _FillValue != -1.0e30: - # -1.0e30 is the flag for no missing data - if int_hdr.item(38) == 2: - # Must have an integer _FillValue for integer data - _FillValue = int(_FillValue) - - attributes["_FillValue"] = _FillValue - - def _set_units(self, int_hdr, attributes): - """Set the ``units`` attribute. - - .. versionadded:: 3.14.0 - - :Parameters: - - int_hdr: `numpy.ndarray` - The integer header of the data. - - real_header: `numpy.ndarray` - The real header of the data. - - attributes: `dict` - The dictionary in which to store the new - attributes. If a new attribute exists then - *attributes* is updated in-place. - - :Returns: - - `None` - - """ - if "units" in attributes: - return - - units = None - if not _stash2standard_name: - load_stash2standard_name() - - submodel = int_hdr.item(44) - stash = int_hdr.item(41) - records = _stash2standard_name.get((submodel, stash)) - if records: - LBSRCE = int_hdr.item(37) - version, source = divmod(LBSRCE, 10000) - if version <= 0: - version = 405.0 - - for ( - long_name, - units0, - valid_from, - valid_to, - standard_name, - cf_info, - condition, - ) in records: - if not self._test_version( - valid_from, valid_to, version - ) or not self._test_condition(condition, int_hdr): - continue - - units = units0 - break - - attributes["units"] = units - - def _set_unpack(self, int_hdr, real_hdr, attributes): - """Set the ``add_offset`` and ``scale_factor`` attributes. - - .. versionadded:: 3.16.3 - - :Parameters: - - int_hdr: `numpy.ndarray` - The integer header of the data. - - real_header: `numpy.ndarray` - The real header of the data. - - attributes: `dict` - The dictionary in which to store the new - attributes. If any new attributes exist then - *attributes* is updated in-place. - - :Returns: - - `None - - """ - if "scale_factor" not in attributes: - # Treat BMKS as a scale_factor if it is neither 0 nor 1 - scale_factor = real_hdr.item(18) - if scale_factor != 1.0 and scale_factor != 0.0: - if int_hdr.item(38) == 2: - # Must have an integer scale_factor for integer data - scale_factor = int(scale_factor) - - attributes["scale_factor"] = scale_factor - - if "add_offset" not in attributes: - # Treat BDATUM as an add_offset if it is not 0 - add_offset = real_hdr.item(4) - if add_offset != 0.0: - if int_hdr.item(38) == 2: - # Must have an integer add_offset for integer data - add_offset = int(add_offset) - - attributes["add_offset"] = add_offset - - def _test_condition(self, condition, int_hdr): - """Return `True` if a field satisfies a condition for a STASH - code to standard name conversion. - - .. versionadded:: 3.14.0 - - :Parameters: - - condition: `str` - The condition. If False then the condition is always - passed, otherwise the condition is specified as - ``'true_latitude_longitude'`` or - ``'rotated_latitude_longitude'``. - - int_hdr: `numpy.ndarray` - The integer lookup header used to evaluate the - condition. - - :Returns: - - `bool` - `True` if the data satisfies the condition specified, - `False` otherwise. - - """ - if not condition: - return True - - if condition == "true_latitude_longitude": - LBCODE = int_hdr.item(15) - # LBCODE 1: Unrotated regular lat/long grid - # LBCODE 2 = Regular lat/lon grid boxes (grid points are - # box centres) - if LBCODE in (1, 2): - return True - elif condition == "rotated_latitude_longitude": - LBCODE = int_hdr.item(15) - # LBCODE 101: Rotated regular lat/long grid - # LBCODE 102: Rotated regular lat/lon grid boxes (grid - # points are box centres) - # LBCODE 111: ? - if LBCODE in (101, 102, 111): - return True - else: - return False - - def _test_version(self, valid_from, valid_to, version): - """Return `True` if the UM version applicable to this field is - within the given range. - - If possible, the UM version is derived from the PP header and - stored in the metadata object. Otherwise it is taken from the - *version* parameter. - - .. versionadded:: 3.14.0 - - :Parameters: - - valid_from: number or `None` - The lower bound of the version range, e.g. ``4.5``, - ``606.1``, etc. - - valid_to: number or `None` - The upper bound of the version range, e.g. ``4.5``, - ``606.1``, etc. - - version: number - The version of field, e.g. ``4.5``, ``606.1``, etc. - - :Returns: - - `bool` - `True` if the UM version applicable to this data is - within the given range, `False` otherwise. - - """ - if valid_to is None: - if valid_from is None: - return True - - if valid_from <= version: - return True - elif valid_from is None: - if version <= valid_to: - return True - elif valid_from <= version <= valid_to: - return True - - return False - - @property - def file_address(self): - """The file name and address. - - Deprecated at version 3.14.0. Use methods `get_filename` - and `get_address` instead. - - :Returns: - - `tuple` - The file name and file address. - - **Examples** - - >>> a.file_address() - ('file.pp', 234835) - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "file_address", - "Use methods 'get_filename' and 'get_address' instead.", - version="3.14.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def header_offset(self): - """The start position in the file of the header. - - :Returns: - - `int` or `None` - The address, or `None` if there isn't one. - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "header_offset", - "Use method 'get_address' instead.", - version="3.15.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def data_offset(self): - """The start position in the file of the data array. - - :Returns: - - `int` - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "data_offset", - version="3.15.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def disk_length(self): - """The number of words on disk for the data array. - - :Returns: - - `int` - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "disk_length", - version="3.15.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def fmt(self): - """The file format of the UM file containing the array. - - Deprecated at version 3.14.0. Use method `get_fmt` - instead. - - :Returns: - - `str` - 'FF' or 'PP' - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "fmt", - "Use method 'get_fmt' instead.", - version="3.14.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def byte_ordering(self): - """The endianness of the data. - - Deprecated at version 3.14.0. Use method - `get_byte_ordering` instead. - - :Returns: - - `str` - 'little_endian' or 'big_endian' - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "byte_ordering", - "Use method 'get_byte_ordering' instead.", - version="3.14.0", - removed_at="5.0.0", - ) # pragma: no cover - - @property - def word_size(self): - """Word size in bytes. - - Deprecated at version 3.14.0. Use method `get_word_size` - instead. - - :Returns: - - `int` - 4 or 8 - - """ - _DEPRECATION_ERROR_ATTRIBUTE( - self, - "word_size", - "Use method 'get_word_size' instead.", - version="3.14.0", - removed_at="5.0.0", - ) # pragma: no cover - - def close(self, f): - """Close the dataset containing the data. - - :Parameters: - - f: `umfile_lib.File` - The UM or PP dataset to be be closed. - - .. versionadded:: 3.14.0 - - :Returns: - - `None` - - """ - if self._get_component("close"): - f.close_fd() - - def get_byte_ordering(self): - """The endianness of the data. - - .. versionadded:: 3.14.0 - - .. seealso:: `open` - - :Returns: - - `str` or `None` - ``'little_endian'`` or ``'big_endian'``. If the byte - ordering has not been set then `None` is returned, in - which case byte ordering will be detected - automatically (if possible) when the file is opened - with `open`. - - """ - return self._get_component("byte_ordering", None) - - def get_fmt(self): - """The file format of the UM file containing the array. - - .. versionadded:: 3.14.0 - - .. seealso:: `open` - - :Returns: - - `str` or `None` - ``'FF'`` or ``'PP'``. If the word size has not been - set then `None` is returned, in which case file format - will be detected automatically (if possible) when the - file is opened with `open`. - - """ - return self._get_component("fmt", None) - - def get_format(self): - """The format of the files. - - .. versionadded:: 3.15.0 - - .. seealso:: `get_address`, `get_filename`, `get_formats` - - :Returns: - - `str` - The file format. Always ``'um'``, signifying PP/UM. - - **Examples** - - >>> a.get_format() - 'um' - - """ - return "um" - - def get_word_size(self): - """Word size in bytes. - - .. versionadded:: 3.14.0 - - .. seealso:: `open` - - :Returns: - - `int` or `None` - ``4`` or ``8``. If the word size has not been set then - `None` is returned, in which case word size will be - detected automatically (if possible) when the file is - opened with `open`. - - """ - return self._get_component("word_size", None) - - def open(self): - """Returns an open dataset and the address of the data. - - :Returns: - - `umfile_lib.umfile.File`, `int` - The open file object, and the start address in bytes - of the lookup header. - - **Examples** - - >>> f.open() - (, 4) - - """ - return super().open( - File, - byte_ordering=self.get_byte_ordering(), - word_size=self.get_word_size(), - fmt=self.get_fmt(), - parse=False, + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use XnetcdfArray instead." ) diff --git a/cf/data/collapse/collapse_active.py b/cf/data/collapse/collapse_active.py index ff8763c108..8cd09b135d 100644 --- a/cf/data/collapse/collapse_active.py +++ b/cf/data/collapse/collapse_active.py @@ -163,14 +163,8 @@ def active_chunk_function(method, *args, **kwargs): return axis = kwargs.get("axis") - if axis is not None: - if isinstance(axis, Integral): - axis = (axis,) - - if len(axis) < x.ndim: - # Active storage is not (yet) allowed for reductions over - # a subset of the axes - return + if isinstance(axis, Integral): + axis = (axis,) # ---------------------------------------------------------------- # Still here? Set up an Active instance that will carry out the @@ -198,9 +192,12 @@ def active_chunk_function(method, *args, **kwargs): address = x.get_address() dataset = x.get_filename() else: - if dataset.backend_api not in "pyfive": + # For datasets that follow the pyfive API (e.g. `pyfive`, + # `umfive`), we can pass the dataset variable directly to + # `Active`. + if dataset.backend_api not in ("pyfive",): return - x + dataset = dataset.backend_accessor active_kwargs = { diff --git a/cf/data/fragment/fragmentumarray.py b/cf/data/fragment/fragmentumarray.py index 6cfa8bbde2..ec1190a117 100644 --- a/cf/data/fragment/fragmentumarray.py +++ b/cf/data/fragment/fragmentumarray.py @@ -1,13 +1,15 @@ -import cfdm - -from ..array.umarray import UMArray - - -class FragmentUMArray( - cfdm.data.fragment.mixin.FragmentFileArrayMixin, UMArray -): +class FragmentUMArray: """A fragment of aggregated data in a PP or UM file. .. versionadded:: 3.14.0 """ + + def __init__(self, *args, **kwargs): + class DeprecationError(Exception): + """Deprecation error.""" + + raise DeprecationError( + f"{self.__class__.__name__} was deprecated at version NEXTVERSION " + "and is no longer available. Use FragmentFileArray instead." + ) diff --git a/cf/functions.py b/cf/functions.py index ff74e3af5e..04d7555438 100644 --- a/cf/functions.py +++ b/cf/functions.py @@ -2505,11 +2505,7 @@ def load_stash2standard_name( table: `str` or `None`, optional Use the conversion table at this file location. By default - the table will be looked for at - ``os.path.join(os.path.dirname(cf.__file__),'etc/STASH_to_CF.txt')`` - - Setting *table* to `None` will reset the table, removing - any modifications that have previously been made. + the default table of the `umfive` library will be loaded. delimiter: `str`, optional The delimiter of the table columns. By default, ``!`` is @@ -2522,7 +2518,9 @@ def load_stash2standard_name( into the existing table, overwriting any entries which already exist. - + reset: `bool`, optional + If True then clear all entries and re-load the default + table. :Returns: diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 6fc6b0acbc..8ea6e63c0f 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -2510,10 +2510,10 @@ def create_latlon_coordinates( south pole. If `None` (the default) then the longitudes of such points are determined by whichever algorithm was used to create the coordinates, which - could result in different points on a pole having + could result in different grid points on a pole having different longitudes. If set to a number, then the - longitudes of all points on the north or south pole - will be given that value. + longitudes of all grid points on the north or south + pole will be given that value. overwrite: `bool`, optional If True then remove any existing latitude and @@ -2697,6 +2697,8 @@ def create_latlon_coordinates( coords_created = lat_key is not None + # Paving the way for reduced_gaussian ... + if two_d and not coords_created: # -------------------------------------------------------- # 2-d lat/lon coordinates from 1-d projection coordinates @@ -2708,7 +2710,6 @@ def create_latlon_coordinates( cr, cr_latlon, longitude_at_pole=longitude_at_pole, - cache=cache, ) coords_created = lat_key is not None diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index bb0581cfc4..ccd2d94a47 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -250,23 +250,23 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): def _cc_parameter(p, parameter, default=None): - """Get a coordinate reference construct parameter. + """Get a parameter from a coordinate reference construct. If there is a ``crs_wkt`` parameter then: - - `None` will be returned if the *parameter* does not exist. + * `None` will be returned if the *parameter* does not exist. If there is not a ``crs_wkt`` parameter then: - - if *default* is not `None`, then *default* will be returned if + * If *default* is not `None`, then *default* will be returned if the *parameter* does not exist. - - if *default* is `None`, then a `KeyError` will be raised if the + * If *default* is `None`, then a `KeyError` will be raised if the *parameter* does not exist. This behaviour allows a ``crs_wkt`` parameter to provide a value - for a missing CF grid mapping parameter (see `_create_pyproj_CRS` - for details. + for a missing CF grid mapping parameter (which happens later on in + `_create_pyproj_CRS`). :Parameters: @@ -859,7 +859,7 @@ def stereographic(cr): def transverse_mercator(cr): - """Create a tranverse_mercator CRS. + """Create a transverse_mercator CRS. https://proj.org/en/stable/operations/projections/tmerc.html @@ -976,7 +976,6 @@ def create_projection_CRS(cr, grid_mapping_name): proj = transverse_mercator(cr) case "vertical_perspective": proj = vertical_perspective(cr) - except KeyError as error: # Trap a KeyError arising from a missing mandatory coordinate # reference parameter diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 983e552f3d..c1a01322c0 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -12,9 +12,7 @@ logger = logging.getLogger(__name__) -def create_2d_latlon_coordinates( - f, cr, cr_latlon, longitude_at_pole=None, cache=True -): +def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): """Create 2-d latitude and longitude coordinates and bounds. Creates the 2-d latitude and longitude coordinate constructs that @@ -55,19 +53,9 @@ def create_2d_latlon_coordinates( pole. If `None` (the default) then the longitudes of such points are determined by whichever algorithm was used to create the coordinates, which could result in different - points on a pole having different longitudes. If set to a - number, then the longitudes of all points on the north or - south pole will be given that value. - - cache: `bool`, optional - If True (the default) then cache in memory the first and - last of any newly-created coordinates and bounds. This may - slightly slow down the coordinate creation process, but - may greatly speed up, and reduce the memory requirement - of, a future inspection of the coordinates and - bounds. Even when *cache* is True, new cached coordinate - values can only be created if the existing 1-d coordinates - themselves have cached first and last values. + grid points on a pole having different longitudes. If set + to a number, then the longitudes of all grid points on the + north or south pole will be given that value. :Returns: @@ -115,7 +103,7 @@ def create_2d_latlon_coordinates( return (None, None) # ---------------------------------------------------------------- - # Create the source prjection CRS + # Create the source projection CRS # ---------------------------------------------------------------- proj_src = create_projection_CRS(cr, grid_mapping_name) if proj_src is None: @@ -131,9 +119,9 @@ def create_2d_latlon_coordinates( # Create the destination latitude_longitude CRS # ---------------------------------------------------------------- if cr_latlon is None: - # When specific latitude_longitude coordinate refernce has not - # been provided, then get the shape of the ellipsoid from the - # projection coordinate reference. + # When a specific latitude_longitude coordinate reference has + # not been provided, get the shape of the ellipsoid from the + # source projection coordinate reference. cr_latlon = cr proj_latlon = create_projection_CRS(cr_latlon, "latitude_longitude") @@ -142,14 +130,15 @@ def create_2d_latlon_coordinates( if is_log_level_info(logger): logger.info( f"Can't create 2-d lat/lon coordinates for {cr!r}: " - "Unable to create a latitude_longitude pyproj.CRS object" + "Unable to create a latitude_longitude pyproj.CRS object " + f"from {cr_latlon!r}" ) # pragma: no cover return (None, None) # ---------------------------------------------------------------- - # Create the transform function from source to destination - # coordinates + # Create the transform function that converts source coordinates + # to destination coordinates # ---------------------------------------------------------------- try: transformer = pyproj.Transformer.from_crs( @@ -166,11 +155,13 @@ def create_2d_latlon_coordinates( return (None, None) # ---------------------------------------------------------------- - # Create 2-d lat/lon coordinate from 1-d grid coordinate centres + # Create 2-d lat/lon coordinates from 1-d grid coordinate centres # ---------------------------------------------------------------- x = one_d["x"] y = one_d["y"] + # The transform function requires that the projection coordinates + # have units of metres metres = Units("m") if x.Units.equivalent(metres): x = x.to_units(metres) @@ -178,7 +169,7 @@ def create_2d_latlon_coordinates( if y.Units.equivalent(metres): y = y.to_units(metres) - # Create x and y meshes of cell centres + # Create meshes of x and y cell centres x_mesh, y_mesh = np.meshgrid(x.array, y.array) try: @@ -186,7 +177,6 @@ def create_2d_latlon_coordinates( x_mesh, y_mesh, errcheck=True, radians=False ) except Exception as error: - # Invalid latitude_longitude coordinate reference if is_log_level_info(logger): logger.info( f"Can't create 2-d lat/lon coordinates for {cr!r}: " @@ -194,8 +184,8 @@ def create_2d_latlon_coordinates( ) # pragma: no cover return (None, None) - else: - del x_mesh, y_mesh + + del x_mesh, y_mesh if longitude_at_pole is not None: # Set the longitude at the poles @@ -216,7 +206,7 @@ def create_2d_latlon_coordinates( xb = xb.array yb = yb.array - # Create x and y meshes of vertices. + # Create meshes of and y vertices. shape = (y.size, x.size) xb = np.broadcast_to(xb[np.newaxis, :, :], shape + (2,)) yb = np.broadcast_to(yb[:, np.newaxis, :], shape + (2,)) @@ -240,7 +230,6 @@ def create_2d_latlon_coordinates( try: lon_bounds, lat_bounds = transformer.transform(x_mesh, y_mesh) except Exception as error: - # Invalid latitude_longitude coordinate reference if is_log_level_info(logger): logger.info( f"Can't create 2-d lat/lon coordinate bounds for {cr!r}: " @@ -248,8 +237,8 @@ def create_2d_latlon_coordinates( ) # pragma: no cover return (None, None) - else: - del x_mesh, y_mesh + + del x_mesh, y_mesh if longitude_at_pole is not None: # Set the longitude at the poles @@ -308,10 +297,10 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): The 1-d coordinates and axes in the following dictionary keys: - * ``'x'``: The X coordinate construct - * ``'y'``: The Y coordinate construct - * ``'axis_x'``: The X domain axis construct key - * ``'axis_y'``: The Y domain axis construct key + * ``'x'``: The X coordinate construct. + * ``'y'``: The Y coordinate construct. + * ``'axis_x'``: The X domain axis construct key. + * ``'axis_y'``: The Y domain axis construct key. If both 1-d dimension coordinates could not be found then `None` is returned. diff --git a/cf/umread_lib/__init__.py b/cf/umread_lib/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/cf/umread_lib/c-lib/Makefile b/cf/umread_lib/c-lib/Makefile deleted file mode 100644 index 8111177976..0000000000 --- a/cf/umread_lib/c-lib/Makefile +++ /dev/null @@ -1,53 +0,0 @@ -HEADERS=umfile.h umfileint.h bits/*.h - -LIBRARY=umfile.so -CFLAGS=-Wall -fPIC - -UNAME_S := $(shell uname -s) -ifeq ($(UNAME_S),Linux) - CC=gcc - LD=ld - LDFLAGS=-shared --build-id - CFLAGS += -DLINUX - LD_ARCHIVE_FLAGS=--whole-archive -endif -ifeq ($(UNAME_S),Darwin) - CC=clang - LD=clang - LDFLAGS=-dynamiclib - CFLAGS += -DOSX - LD_ARCHIVE_FLAGS=-force_load -endif - -CPP=gcc -E -P -OBJS = umfile.o error.o filetype.o \ - malloc.o linklist.o new_structs.o swap.o - -TYPE_DEP_LIBRARY = umfile_typedep.a -TYPE_DEP_DIR = type-dep -TYPE_DEP_LIBRARY_PATH = $(TYPE_DEP_DIR)/$(TYPE_DEP_LIBRARY) - -export CC CFLAGS CPP TYPE_DEP_LIBRARY - -.PHONY: clean all type-dep - -all: $(LIBRARY) - -clean: - rm -f $(OBJS) - $(MAKE) -C $(TYPE_DEP_DIR) clean - -type-dep: - $(MAKE) -C $(TYPE_DEP_DIR) - -$(LIBRARY): $(OBJS) type-dep - $(LD) $(LDFLAGS) -o $@ $(OBJS) $(LD_ARCHIVE_FLAGS) $(TYPE_DEP_LIBRARY_PATH) - -%.o: %.c $(HEADERS) - $(CC) $(CFLAGS) -c $< - -#bits/protos_sgl.h: bits/type_dep_protos.h -# $(CPP) -DBUILD_HDR -DSINGLE $< > $@ -# -#bits/protos_dbl.h: bits/type_dep_protos.h -# $(CPP) -DBUILD_HDR -DDOUBLE $< > $@ diff --git a/cf/umread_lib/c-lib/README b/cf/umread_lib/c-lib/README deleted file mode 100644 index 898bdc7e89..0000000000 --- a/cf/umread_lib/c-lib/README +++ /dev/null @@ -1,25 +0,0 @@ - -For source files in the subdirectory type-dep, two versions of each object -file will be built, one compiled with -DSINGLE, the other compiled with --DDOUBLE, and this will cause INTEGER and REAL and other dependent data types -to be typedef-ed accordingly, meaning that they are suitable for data read in -from 32- or 64-bit UM files without doing any casting. Functions should be -prototyped in bits/type_dep_protos.h. The symbol names in these -type-dependent functions will be renamed from 'foo' to 'foo_sgl' and 'foo_dbl' -as appropriate for the two compiled versions. This happens automatically by -the build process. This also includes renaming of external symbols where the -functions called are provided by other source files also in the type-dep -directory. - -Files in this (the parent) directory will be built without either -DSINGLE or --DDOUBLE, and no renaming of objects will take place, and the symbol names are -unmodified. They should be protyped in bits/type_indep_protos.h. - -In the few places where a type-independent function calls a type-dependent -function, the "_sgl" or "_dbl" part of the symbol name must be included -explicitly (this will usually be in a 'switch' statement in a despatch -function). These entry points into the type dependent code should be -prototyped in bits/type_dep_entry_protos.h, using the processor macro -WITH_LEN(foo), which expands to foo_sgl or foo_dbl; the header file is -included twice with different defines, so a single prototype statement using -this macro will suffice to prototype both versions of the function. diff --git a/cf/umread_lib/c-lib/bits/constants.h b/cf/umread_lib/c-lib/bits/constants.h deleted file mode 100644 index 608765c9c4..0000000000 --- a/cf/umread_lib/c-lib/bits/constants.h +++ /dev/null @@ -1,9 +0,0 @@ -/* int_missing_data is convention in input file */ -#define INT_MISSING_DATA -32768 - -/* for float comparisons */ -#if defined(SINGLE) -#define REAL_TOLERANCE 1e-5 -#elif defined(DOUBLE) -#define REAL_TOLERANCE 1e-13 -#endif diff --git a/cf/umread_lib/c-lib/bits/datatype.h b/cf/umread_lib/c-lib/bits/datatype.h deleted file mode 100644 index f51437f903..0000000000 --- a/cf/umread_lib/c-lib/bits/datatype.h +++ /dev/null @@ -1,15 +0,0 @@ -#if defined(DOUBLE) - -#define REAL float64_t -#define INTEGER int64_t -#define COMPILED_TYPE (double_precision) -#define WORD_SIZE 8 - -#elif defined(SINGLE) - -#define REAL float32_t -#define INTEGER int32_t -#define COMPILED_TYPE (single_precision) -#define WORD_SIZE 4 - -#endif diff --git a/cf/umread_lib/c-lib/bits/err_macros.h b/cf/umread_lib/c-lib/bits/err_macros.h deleted file mode 100644 index 1e84a37bee..0000000000 --- a/cf/umread_lib/c-lib/bits/err_macros.h +++ /dev/null @@ -1,36 +0,0 @@ - -/* error-checking macros */ - -/* these are to allow a compact way of incorporating error-checking of - * the return value of a function call, without obfuscating the basic purpose - * of the line of code, which is executing the function call. - * - * CKI used for integer functions which return negative value on failure - * CKP used for pointer functions which return NULL on failure - * CKF for floats for good measure (probably not used) - * - * put the ERRBLK (or ERRBLKI or ERRBLKP) at the end of the subroutine - */ - -#define FLT_ERR -1e38 - -#ifdef DEBUG -#define ERR abort(); -#else -/* ERR: unconditional branch */ -#define ERR goto err; -#endif - -#define CKI(i) if ((i) < 0){ ERR } -#define CKP(p) if ((p) == NULL){ ERR } -#define CKF(f) if ((f) == FLT_ERR){ ERR } - -/* ERRIF: conditional branch */ -#define ERRIF(i) if (i){ ERR } - -#define GRIPE gripe(__func__); -#define SWITCH_BUG switch_bug(__func__); ERR; -#define ERRBLK(rtn) err: GRIPE; return (rtn); -#define ERRBLKI ERRBLK(-1); -#define ERRBLKP ERRBLK(NULL); -#define ERRBLKF ERRBLK(FLT_ERR); diff --git a/cf/umread_lib/c-lib/bits/ordering.h b/cf/umread_lib/c-lib/bits/ordering.h deleted file mode 100644 index 1fb013f876..0000000000 --- a/cf/umread_lib/c-lib/bits/ordering.h +++ /dev/null @@ -1,16 +0,0 @@ -#if defined(LINUX) -#include -#elif defined(OSX) -#include -#else -#error build type not specified -#endif -#if BYTE_ORDER == LITTLE_ENDIAN -#define NATIVE_ORDERING little_endian -#define REVERSE_ORDERING big_endian -#elif BYTE_ORDER == BIG_ENDIAN -#define NATIVE_ORDERING big_endian -#define REVERSE_ORDERING little_endian -#else -#error BYTE_ORDER not defined properly -#endif diff --git a/cf/umread_lib/c-lib/bits/pp_header.h b/cf/umread_lib/c-lib/bits/pp_header.h deleted file mode 100644 index a442be6267..0000000000 --- a/cf/umread_lib/c-lib/bits/pp_header.h +++ /dev/null @@ -1,76 +0,0 @@ -/* ----------------------------------------------------------- */ -/* PP header interpretation */ - -#define N_INT_HDR 45 -#define N_REAL_HDR 19 -#define N_HDR (N_INT_HDR + N_REAL_HDR) - -#define INDEX_LBYR 0 -#define INDEX_LBMON 1 -#define INDEX_LBDAT 2 -#define INDEX_LBHR 3 -#define INDEX_LBMIN 4 -#define INDEX_LBDAY 5 -#define INDEX_LBYRD 6 -#define INDEX_LBMOND 7 -#define INDEX_LBDATD 8 -#define INDEX_LBHRD 9 -#define INDEX_LBMIND 10 -#define INDEX_LBDAYD 11 -#define INDEX_LBTIM 12 -#define INDEX_LBFT 13 -#define INDEX_LBLREC 14 -#define INDEX_LBCODE 15 -#define INDEX_LBHEM 16 -#define INDEX_LBROW 17 -#define INDEX_LBNPT 18 -#define INDEX_LBEXT 19 -#define INDEX_LBPACK 20 -#define INDEX_LBREL 21 -#define INDEX_LBFC 22 -#define INDEX_LBCFC 23 -#define INDEX_LBPROC 24 -#define INDEX_LBVC 25 -#define INDEX_LBRVC 26 -#define INDEX_LBEXP 27 -#define INDEX_LBBEGIN 28 -#define INDEX_LBNREC 29 -#define INDEX_LBPROJ 30 -#define INDEX_LBTYP 31 -#define INDEX_LBLEV 32 -#define INDEX_LBRSVD1 33 -#define INDEX_LBRSVD2 34 -#define INDEX_LBRSVD3 35 -#define INDEX_LBRSVD4 36 -#define INDEX_LBSRCE 37 -#define INDEX_LBUSER1 38 -#define INDEX_LBUSER2 39 -#define INDEX_LBUSER3 40 -#define INDEX_LBUSER4 41 -#define INDEX_LBUSER5 42 -#define INDEX_LBUSER6 43 -#define INDEX_LBUSER7 44 -#define INDEX_BULEV 0 -#define INDEX_BHULEV 1 -#define INDEX_BRSVD3 2 -#define INDEX_BRSVD4 3 -#define INDEX_BDATUM 4 -#define INDEX_BACC 5 -#define INDEX_BLEV 6 -#define INDEX_BRLEV 7 -#define INDEX_BHLEV 8 -#define INDEX_BHRLEV 9 -#define INDEX_BPLAT 10 -#define INDEX_BPLON 11 -#define INDEX_BGOR 12 -#define INDEX_BZY 13 -#define INDEX_BDY 14 -#define INDEX_BZX 15 -#define INDEX_BDX 16 -#define INDEX_BMDI 17 -#define INDEX_BMKS 18 - -#define LOOKUP(rec, index) (((INTEGER *)((rec)->int_hdr))[index]) -#define RLOOKUP(rec, index) (((REAL *)((rec)->real_hdr))[index]) - -/* ----------------------------------------------------------- */ diff --git a/cf/umread_lib/c-lib/bits/type_dep_entry_protos.h b/cf/umread_lib/c-lib/bits/type_dep_entry_protos.h deleted file mode 100644 index 9f10ef0756..0000000000 --- a/cf/umread_lib/c-lib/bits/type_dep_entry_protos.h +++ /dev/null @@ -1,42 +0,0 @@ -/* prototypes for entry points to the type-dependent code; - * need to be usable without INTEGER, etc, being defined, - * so void* used for some pointer types that have specific - * pointer types defined in the function declarations themselves - */ - -void WITH_LEN(swap_bytes)(void *ptr, size_t num_words); - -int WITH_LEN(get_type_and_num_words_core)(const void *int_hdr, - Data_type *type_rtn, - size_t *num_words_rtn); - -int WITH_LEN(read_hdr_at_offset)(int fd, - size_t header_offset, - Byte_ordering byte_ordering, - void *int_hdr_rtn, - void *real_hdr_rtn); - -int WITH_LEN(read_record_data_core)(int fd, - size_t data_offset, - size_t disk_length, - Byte_ordering byte_ordering, - const void *int_hdr, - const void *real_hdr, - size_t nwords, - void *data_return); - -File *WITH_LEN(file_parse_core)(int fd, - File_type file_type); - -size_t WITH_LEN(get_extra_data_offset_and_length_core)(const void *int_hdr, - size_t data_offset, - size_t disk_length, - size_t *extra_data_offset_rtn, - size_t *extra_data_length_rtn); - -int WITH_LEN(read_extra_data_core)(int fd, - size_t extra_data_offset, - size_t extra_data_length, - Byte_ordering byte_ordering, - void *extra_data_rtn); - diff --git a/cf/umread_lib/c-lib/bits/type_dep_protos.h b/cf/umread_lib/c-lib/bits/type_dep_protos.h deleted file mode 100644 index 00e9a76c9e..0000000000 --- a/cf/umread_lib/c-lib/bits/type_dep_protos.h +++ /dev/null @@ -1,105 +0,0 @@ -/* interpret_header.c */ -Data_type get_type(const INTEGER *int_hdr); -size_t get_num_data_words (const INTEGER *int_hdr); -size_t get_extra_data_length(const INTEGER *int_hdr); - -int var_is_missing(const INTEGER *int_hdr); -int get_var_stash_model(const INTEGER *int_hdr); -int get_var_stash_section(const INTEGER *int_hdr); -int get_var_stash_item(const INTEGER *int_hdr); -int get_var_compression(const INTEGER *int_hdr); -int get_var_gridcode(const INTEGER *int_hdr); -int get_var_packing(const INTEGER *int_hdr); -REAL get_var_real_fill_value(const REAL *int_hdr); - -/* read.c */ - -size_t read_words(int fd, - void *ptr, - size_t num_words, - Byte_ordering byte_ordering); - -int read_extra_data_at_offset(int fd, - size_t extra_data_offset, - size_t extra_data_length, - Byte_ordering byte_ordering, - void *extra_data_rtn); - -int read_hdr(int fd, - Byte_ordering byte_ordering, - INTEGER *int_hdr_rtn, - REAL *real_hdr_rtn); - -Rec *get_record(File *file, List *heaplist); -int read_all_headers(File *file, List *heaplist); -size_t skip_fortran_record(File *file); -int skip_word(File *file); -int read_all_headers_pp(File *file, List *heaplist); -int read_all_headers_ff(File *file, List *heaplist); -size_t get_ff_disk_length(INTEGER *ihdr); -int get_valid_records_ff(int fd, - Byte_ordering byte_ordering, - size_t hdr_start, size_t hdr_size, int nrec, - int valid[], int *n_valid_rec_return); -int unpack_run_length_encoded(REAL *datain, INTEGER nin, REAL *dataout, INTEGER nout, REAL mdi); - -/* process_vars.c */ -int process_vars(File *file, List *heaplist); -int test_skip_var(const Rec *rec); -int initialise_records(Rec **recs, int nrec, List *heaplist); -int get_vars(int nrec, Rec **recs, - List *vars, - List *heaplist); -int set_disambig_index(Z_axis *z_axis, T_axis *t_axis, - Rec **recs, int nvrec, int svindex); -int add_axes_to_var(Var *var, - Z_axis *z_axis, T_axis *t_axis, - List *z_axes, List *t_axes, - List *heaplist); -int grid_supported(INTEGER *int_hdr); -int var_has_regular_z_t(Z_axis *z_axis, T_axis *t_axis, Rec **recs, int nvrec); - -/* level.c */ -int lev_set(Level *lev, const Rec *rec); -Lev_type level_type(const Rec *rec); - -/* date_and_time.c */ -REAL get_mean_period(const Time *time); -int is_time_mean(INTEGER LBTIM); -REAL time_diff(INTEGER lbtim, const Date *date, const Date *orig_date); -REAL sec_to_day(int64_t seconds); -Calendar_type calendar_type(INTEGER type); -int64_t gregorian_to_secs(const Date *date); -int time_set(Time *time, const Rec *rec); - -/* axes.c */ -Z_axis *new_z_axis(List *heaplist); -int free_z_axis(Z_axis *z_axis, List *heaplist); -T_axis *new_t_axis(List *heaplist); -int free_t_axis(T_axis *t_axis, List *heaplist); -int t_axis_add(T_axis *t_axis, const Time *time, - int *index_return, List *heaplist); -int z_axis_add(Z_axis *z_axis, const Level *lev, - int *index_return, List *heaplist); - -/* compare.c */ -int compare_records_between_vars(const Rec *a, const Rec *b); -int compare_mean_periods(const Rec *a, const Rec *b); -int compare_records_within_var(const Rec *a, const Rec *b); -int compare_records(const void *p1, const void *p2); -int records_from_different_vars(const Rec *a, const Rec *b); -int compare_lists(const List *l1, const List *l2, int (*compfunc)(const void*, const void*)); -int compare_levels(const void *p1, const void *p2); -int compare_times(const void *p1, const void *p2); -int compare_dates(const Date *a, const Date *b); - -/* unwgdos.c */ -int unwgdos(void *datain, int nbytes, REAL *dataout, int nout, REAL mdi); - - -/* Debug_dump.c */ -void debug_dump_all_headers(File *file); - -#ifdef MAIN -int main(); -#endif diff --git a/cf/umread_lib/c-lib/bits/type_dep_redefs.h b/cf/umread_lib/c-lib/bits/type_dep_redefs.h deleted file mode 100644 index 93c81a24d0..0000000000 --- a/cf/umread_lib/c-lib/bits/type_dep_redefs.h +++ /dev/null @@ -1,137 +0,0 @@ -#if defined(SINGLE) - -#define swap_bytes swap_bytes_sgl - -#define calendar_type calendar_type_sgl -#define compare_dates compare_dates_sgl -#define compare_levels compare_levels_sgl -#define compare_lists compare_lists_sgl -#define compare_mean_periods compare_mean_periods_sgl -#define compare_records compare_records_sgl -#define compare_records_between_vars compare_records_between_vars_sgl -#define compare_records_within_var compare_records_within_var_sgl -#define compare_times compare_times_sgl -#define debug_dump_all_headers debug_dump_all_headers_sgl -#define file_parse_core file_parse_core_sgl -#define free_t_axis free_t_axis_sgl -#define free_z_axis free_z_axis_sgl -#define get_extra_data_length get_extra_data_length_sgl -#define get_extra_data_offset_and_length_core get_extra_data_offset_and_length_core_sgl -#define get_ff_disk_length get_ff_disk_length_sgl -#define get_mean_period get_mean_period_sgl -#define get_num_data_words get_num_data_words_sgl -#define get_record get_record_sgl -#define get_type get_type_sgl -#define get_type_and_num_words_core get_type_and_num_words_core_sgl -#define get_valid_records_ff get_valid_records_ff_sgl -#define get_var_compression get_var_compression_sgl -#define get_var_gridcode get_var_gridcode_sgl -#define get_var_packing get_var_packing_sgl -#define get_var_real_fill_value get_var_real_fill_value_sgl -#define get_var_stash_item get_var_stash_item_sgl -#define get_var_stash_model get_var_stash_model_sgl -#define get_var_stash_section get_var_stash_section_sgl -#define get_vars get_vars_sgl -#define gregorian_to_secs gregorian_to_secs_sgl -#define grid_supported grid_supported_sgl -#define initialise_records initialise_records_sgl -#define is_time_mean is_time_mean_sgl -#define lev_set lev_set_sgl -#define level_type level_type_sgl -#define new_t_axis new_t_axis_sgl -#define new_z_axis new_z_axis_sgl -#define process_vars process_vars_sgl -#define read_all_headers read_all_headers_sgl -#define read_all_headers_ff read_all_headers_ff_sgl -#define read_all_headers_pp read_all_headers_pp_sgl -#define read_extra_data_core read_extra_data_core_sgl -#define read_hdr read_hdr_sgl -#define read_hdr_at_offset read_hdr_at_offset_sgl -#define read_record_data_core read_record_data_core_sgl -#define read_record_data_dummy read_record_data_dummy_sgl -#define read_words read_words_sgl -#define records_from_different_vars records_from_different_vars_sgl -#define sec_to_day sec_to_day_sgl -#define set_disambig_index set_disambig_index_sgl -#define skip_fortran_record skip_fortran_record_sgl -#define skip_word skip_word_sgl -#define t_axis_add t_axis_add_sgl -#define test_skip_var test_skip_var_sgl -#define time_diff time_diff_sgl -#define time_set time_set_sgl -#define unpack_run_length_encoded unpack_run_length_encoded_sgl -#define unwgdos unwgdos_sgl -#define var_has_regular_z_t var_has_regular_z_t_sgl -#define var_is_missing var_is_missing_sgl -#define z_axis_add z_axis_add_sgl - -#elif defined(DOUBLE) - -#define swap_bytes swap_bytes_dbl - -#define calendar_type calendar_type_dbl -#define compare_dates compare_dates_dbl -#define compare_levels compare_levels_dbl -#define compare_lists compare_lists_dbl -#define compare_mean_periods compare_mean_periods_dbl -#define compare_records compare_records_dbl -#define compare_records_between_vars compare_records_between_vars_dbl -#define compare_records_within_var compare_records_within_var_dbl -#define compare_times compare_times_dbl -#define debug_dump_all_headers debug_dump_all_headers_dbl -#define file_parse_core file_parse_core_dbl -#define free_t_axis free_t_axis_dbl -#define free_z_axis free_z_axis_dbl -#define get_extra_data_length get_extra_data_length_dbl -#define get_extra_data_offset_and_length_core get_extra_data_offset_and_length_core_dbl -#define get_ff_disk_length get_ff_disk_length_dbl -#define get_mean_period get_mean_period_dbl -#define get_num_data_words get_num_data_words_dbl -#define get_record get_record_dbl -#define get_type get_type_dbl -#define get_type_and_num_words_core get_type_and_num_words_core_dbl -#define get_valid_records_ff get_valid_records_ff_dbl -#define get_var_compression get_var_compression_dbl -#define get_var_gridcode get_var_gridcode_dbl -#define get_var_packing get_var_packing_dbl -#define get_var_real_fill_value get_var_real_fill_value_dbl -#define get_var_stash_item get_var_stash_item_dbl -#define get_var_stash_model get_var_stash_model_dbl -#define get_var_stash_section get_var_stash_section_dbl -#define get_vars get_vars_dbl -#define gregorian_to_secs gregorian_to_secs_dbl -#define grid_supported grid_supported_dbl -#define initialise_records initialise_records_dbl -#define is_time_mean is_time_mean_dbl -#define lev_set lev_set_dbl -#define level_type level_type_dbl -#define new_t_axis new_t_axis_dbl -#define new_z_axis new_z_axis_dbl -#define process_vars process_vars_dbl -#define read_all_headers read_all_headers_dbl -#define read_all_headers_ff read_all_headers_ff_dbl -#define read_all_headers_pp read_all_headers_pp_dbl -#define read_extra_data_core read_extra_data_core_dbl -#define read_hdr read_hdr_dbl -#define read_hdr_at_offset read_hdr_at_offset_dbl -#define read_record_data_core read_record_data_core_dbl -#define read_record_data_dummy read_record_data_dummy_dbl -#define read_words read_words_dbl -#define records_from_different_vars records_from_different_vars_dbl -#define sec_to_day sec_to_day_dbl -#define set_disambig_index set_disambig_index_dbl -#define skip_fortran_record skip_fortran_record_dbl -#define skip_word skip_word_dbl -#define t_axis_add t_axis_add_dbl -#define test_skip_var test_skip_var_dbl -#define time_diff time_diff_dbl -#define time_set time_set_dbl -#define unpack_run_length_encoded unpack_run_length_encoded_dbl -#define unwgdos unwgdos_dbl -#define var_has_regular_z_t var_has_regular_z_t_dbl -#define var_is_missing var_is_missing_dbl -#define z_axis_add z_axis_add_dbl - -#else -#error Need to compile this file with -DSINGLE or -DDOUBLE -#endif diff --git a/cf/umread_lib/c-lib/bits/type_indep_protos.h b/cf/umread_lib/c-lib/bits/type_indep_protos.h deleted file mode 100644 index 2bd249fc2f..0000000000 --- a/cf/umread_lib/c-lib/bits/type_indep_protos.h +++ /dev/null @@ -1,61 +0,0 @@ -/* PROTOTYPES */ - -/* error.c */ -void switch_bug(const char *routine); -void gripe(const char *routine); -void error_mesg(const char *fmt, ...); -void debug(const char *fmt, ...); -void errorhandle_init(); - -/* malloc.c */ -void *malloc_(size_t size, List *heaplist); -void *dup_(const void *inptr, size_t size, List *heaplist); -int free_(void *ptr, List *heaplist); -int free_all(List *heaplist); - -/* swap.c */ -/* NB the _sgl and _dbl functions are explicitly coded, hence not handled by type_dep - * (reason: even in the DOUBLE case, we need swap_bytes_sgl available for 32-bit packed data) - */ -void swap_bytes_sgl(void *ptr, size_t num_words); -void swap_bytes_dbl(void *ptr, size_t num_words); - -/* linklist.c */ -typedef int(*free_func) (void *, List *); - -void *list_new(List *heaplist); -int list_free(List *list, int free_ptrs, List *heaplist); -int list_size(const List *list); -int list_add(List *list, void *ptr, List *heaplist); -int list_add_or_find(List *list, - void *item_in, - int (*compar)(const void *, const void *), - int matchval, - free_func free_function, - int *index_return, - List *heaplist); -int list_del(List *list, void *ptr, List *heaplist); -int list_del_by_listel(List *list, List_element *p, List *heaplist); -int list_startwalk(const List *list, List_handle *handle); -void *list_walk(List_handle *handle, int return_listel); -void *list_find(List *list, - const void *item, - int (*compar)(const void *, const void *), - int matchval, - int *index_return); -int list_copy_to_ptr_array(const List *list, - int *n_return, - void *ptr_array_return, - List *heaplist); - -/* filetype.c */ -int detect_file_type_(int fd, File_type *file_type); - -/* new_structs.c */ -Rec *new_rec(int word_size, List *heaplist); -int free_rec(Rec *rec, List *heaplist); -Var *new_var(List *heaplist); -int free_var(Var *var, List *heaplist); -File *new_file(); -int free_file(File *file); - diff --git a/cf/umread_lib/c-lib/bits/typedefs.h b/cf/umread_lib/c-lib/bits/typedefs.h deleted file mode 100644 index 04db2a204c..0000000000 --- a/cf/umread_lib/c-lib/bits/typedefs.h +++ /dev/null @@ -1,208 +0,0 @@ -#include - -typedef float float32_t; -typedef double float64_t; - -enum { single_precision, double_precision }; - -/*---------------------------*/ -/* for linked list */ - -struct _list_element -{ - void *ptr; - struct _list_element *prev; - struct _list_element *next; -}; -typedef struct _list_element List_element; - -typedef struct -{ - int n; - List_element *first; - List_element *last; -} - List; - -typedef struct -{ - /* This is a little structure which stores the information needed for - * pp_list_walk. Its main purpose is to store the position outside the list - * structure itself, so that for read-only scanning of the list, the PPlist* - * can be declared as const. - */ - List_element *current; - const List *list; -} - List_handle; - -/*---------------------------*/ - -typedef enum -{ - pseudo_lev_type, - height_lev_type, - depth_lev_type, - hybrid_sigmap_lev_type, - hybrid_height_lev_type, - pressure_lev_type, - soil_lev_type, - boundary_layer_top_lev_type, - top_of_atmos_lev_type, - mean_sea_lev_type, - surface_lev_type, - tropopause_lev_type, - other_lev_type -} - Lev_type; - -typedef enum -{ - gregorian, - cal360day, - model -} - Calendar_type; - -typedef enum -{ - lev_type, - hybrid_sigmap_a_type, - hybrid_sigmap_b_type, - hybrid_height_a_type, - hybrid_height_b_type -} - Lev_val_type; - - -#if defined(INTEGER) - -typedef struct -{ - INTEGER year; - INTEGER month; - INTEGER day; - INTEGER hour; - INTEGER minute; - INTEGER second; -} - Date; - -typedef struct -{ - /* this is a value on time axis */ - INTEGER type; - Date time1; - Date time2; -} - Time; - -typedef struct -{ - List *values; -} - T_axis; - -typedef struct -{ - Lev_type type; - - union - { - struct - { - REAL level; -#ifdef BDY_LEVS - REAL ubdy_level; - REAL lbdy_level; -#endif - } - misc; - - struct - { - REAL a; - REAL b; -#ifdef BDY_LEVS - REAL ubdy_a; - REAL ubdy_b; - REAL lbdy_a; - REAL lbdy_b; -#endif - } - hybrid_sigmap; - - struct - { - REAL a; - REAL b; -#ifdef BDY_LEVS - REAL ubdy_a; - REAL ubdy_b; - REAL lbdy_a; - REAL lbdy_b; -#endif - } - hybrid_height; - - struct - { - INTEGER index; - } - pseudo; - } - values; -} - Level; - -typedef struct -{ - List *values; -} - Z_axis; - -#else - -typedef void Z_axis; -typedef void T_axis; -typedef void Time; -typedef void Level; - -#endif - -/*---------------------------*/ - -struct _File -{ - List *heaplist; - int nrec; - Rec **recs; -}; - -struct _Var -{ - Z_axis *z_axis; - T_axis *t_axis; - Rec *first_rec; - int first_rec_no; - int last_rec_no; -}; - -struct _Rec -{ - Level *lev; - Time *time; - int zindex; /* index on z axis within a variable - used for detecting vars with irreg z,t */ - int tindex; /* index on t axis within a variable - used for detecting vars with irreg z,t */ - int disambig_index; /* index used for splitting variables with irreg z,t into - * sets of variables with regular z,t */ - int supervar_index; /* when a variable is split, this is set to an index which is common - * across the set, but different from sets generated from other - * super-variables - */ - float64_t mean_period; /* period (in days) of time mean - (store here so as to calculate once only) */ -}; - -/*---------------------------*/ - diff --git a/cf/umread_lib/c-lib/error.c b/cf/umread_lib/c-lib/error.c deleted file mode 100644 index 5734318bbc..0000000000 --- a/cf/umread_lib/c-lib/error.c +++ /dev/null @@ -1,68 +0,0 @@ -#include -#include - -#include "umfileint.h" - -static FILE *output = NULL; - -static int verbose; - -static const int do_debug = 1; - -void switch_bug(const char *routine) -{ - gripe("no match in switch statement in routine; " - "may indicate coding bug in umfile or unexpected header value"); -} - -void gripe(const char *routine) -{ - if (verbose || do_debug) - { - fprintf(output, "umfile: error condition detected in routine %s\n", - routine); - fflush(output); - } - verbose = 0; -} - -void error_mesg(const char *fmt, ...) -{ - va_list args; - - if (verbose || do_debug) - { - va_start(args, fmt); - vfprintf(output, fmt, args); - fprintf(output, "\n"); - va_end(args); - fflush(output); - } - verbose = 0; -} - -void debug(const char *fmt, ...) -{ - va_list args; - - if (do_debug) - { - va_start(args, fmt); - fprintf(output, "DEBUG: "); - vfprintf(output, fmt, args); - fprintf(output, "\n"); - va_end(args); - fflush(output); - } -} - -void errorhandle_init() -{ - /* init sets verbose -- called at start of each of the interface routines -- - * then first call to error will cause a diagnostic to be printed, - * but then unsets verbose to avoid series of knock-on messages - */ - verbose = 1; - if (output == NULL) - output = stderr; -} diff --git a/cf/umread_lib/c-lib/filetype.c b/cf/umread_lib/c-lib/filetype.c deleted file mode 100644 index 4ea24a4f2a..0000000000 --- a/cf/umread_lib/c-lib/filetype.c +++ /dev/null @@ -1,200 +0,0 @@ - /* Routines for auto-determining file type from the start of the file - * contents. - * - * ================= - * Fields file tests - * ================= - * - * These are done first. We test the second word, which should be the - * submodel ID - is this 1, 2 or 4? - * - * ==> Could the fields file test give a false +ve with PP file? - * - * Test for fields file only true if the first 16 bytes, when viewed - * as 4 32-bit integers, are one of the following: - * - * bytes 1-4 bytes 5-8 bytes 9-12 bytes 13-16 - * --------------------------------------------------- - * any any 0 1/2/4(BE) <-- 64-bit BE FF - * any any 1/2/4(LE) 0 <-- 64-bit LE FF - * any 1/2/4(BE) any any <-- 32-bit BE FF - * any 1/2/4(LE) any any <-- 32-bit LE FF - * - * For PP files, we in fact have: - * - * 0 512/1024(BE) 0 lbyr(BE) <--- 64-bit BE PP - * 512/1024(LE) 0 lbyr(LE) 0 <--- 64-bit LE PP - * 256/512(BE) lbyr(BE) lbmon(BE) lbdat(BE) <--- 32-bit BE PP - * 256/512(LE) lbyr(LE) lbmon(LE) lbdat(LE) <--- 32-bit LE PP - * - * Possible false positives: - * - * - any PP with lbyr=1/2/4 looks like FF of same length and endianness - * - 32-bit BE PP with lbmon=0, lbdat=1/2/4 looks like 64-bit BE FF - * - 32-bit LE PP with lbmon=1/2/4, lbdat=0 looks like 64-bit LE FF - * - * Do we care about these cases? - * lbyr=1/2/4: probably NO - * lbmon=0, lbdat non-zero: probably NO - * lbmon=1/2/4, lbdat=0 - possible monthly climatology? <== YES - * - * Always option for user to force file type, but: - * **FIXME**: additional test could help. - * - * ======== - * PP tests - * ======== - * - * If the fields-file test is false, then test for types of plain PP file. - * Here we test the first word, which should be record length (put there by - * fortran). - * - * Check first for a 64-bit PP file, but in addition to the first word being - * a valid possibility, this must also pass the stringent test of every - * other 32-bit value being zero throughout the first 14 64-bit words, - * although because of endianness issues we accept the sequence of - * alternating zeros to start either at the first or second 32-bit value. - * The point is that for a true 64-bit file, these should all be small - * integers, so the most significant bytes will be 0. (The first possibly - * large integer is the 15th: LBLREC.) However, for a 32-bit file this test - * will span the first 28 elements. Even if the date elements (first 12 - * words) are all 0, LBROW (18) and LBNPT (19) should both be non-zero, so - * both the set of even-positioned integers and the set of odd-positioned - * integers will each contain at least one non-zero value, and the test will - * fail. - * - * If the 64-bit tests fail, try 32-bit. - */ - -#include -#include -#include - -#include "umfileint.h" - -/* values passed to valid_um_word2 and valid_pp_word1 could be 32 or - * 64-bit. Declare as longer of these two (int64_t), and shorter will be - * accommodated also. - */ - -static int valid_um_word2(int64_t val) -{ - /* second word should be 1,2 or 4, reflecting model ID in fixed length - header */ - return (val == 1 || val == 2 || val == 4); -} - -static int valid_pp_word1(int64_t val, int wsize) -{ - /* first word should be integer from Fortan representing length of header - record */ - return (val == 64 * wsize || val == 128 * wsize); -} - -/* tests whether sequence of integers has every other value = 0, but - * only when starting at first value - */ -static int is_alternating_zeros_without_offset(int32_t *vals, int num_pairs) -{ - int i; - int32_t *p; - p = vals; - for (i = 0; i < num_pairs; i++) - { - if (*p != 0) return 0; - p += 2; - } - return 1; -} - -/* tests whether sequence of integers has every other value = 0, but - * can either be when starting at first or second value - */ -static int is_alternating_zeros(int32_t *vals, int num_pairs) -{ - return (is_alternating_zeros_without_offset(vals, num_pairs) || - is_alternating_zeros_without_offset(vals + 1, num_pairs)); -} - -#define N_PAIRS 14 -int detect_file_type_(int fd, File_type *file_type) -{ - int32_t data4[2 * N_PAIRS], data4s[2]; - int64_t data8[2], data8s[2]; - - /* read and store first 24 4-byte words - * and store first two integers of this according to possible suppositions - * of 4- or 8- byte, and of native or swapped byte ordering - */ - lseek(fd, 0, SEEK_SET); - if(read(fd, data4, 8 * N_PAIRS) != 8 * N_PAIRS) return 1; - - memcpy(data8, data4, 16); - - memcpy(data4s, data4, 8); - swap_bytes_sgl(data4s, 2); - - memcpy(data8s, data4, 16); - swap_bytes_dbl(data8s, 2); - - - /* --- Fields file cases -- */ - - if (valid_um_word2(data4[1])) - { - file_type->fmt = fields_file; - file_type->byte_ordering = NATIVE_ORDERING; - file_type->word_size = 4; - } - else if (valid_um_word2(data8[1])) - { - file_type->fmt = fields_file; - file_type->byte_ordering = NATIVE_ORDERING; - file_type->word_size = 8; - } - else if (valid_um_word2(data4s[1])) - { - file_type->fmt = fields_file; - file_type->byte_ordering = REVERSE_ORDERING; - file_type->word_size = 4; - } - else if (valid_um_word2(data8s[1])) - { - file_type->fmt = fields_file; - file_type->byte_ordering = REVERSE_ORDERING; - file_type->word_size = 8; - } - - /* --- Plain PP cases -- */ - - else if (valid_pp_word1(data8[0], 8) && is_alternating_zeros(data4, N_PAIRS)) - { - file_type->fmt = plain_pp; - file_type->byte_ordering = NATIVE_ORDERING; - file_type->word_size = 8; - } - else if (valid_pp_word1(data8s[0], 8) && is_alternating_zeros(data4, N_PAIRS)) - { - file_type->fmt = plain_pp; - file_type->byte_ordering = REVERSE_ORDERING; - file_type->word_size = 8; - } - else if (valid_pp_word1(data4[0], 4)) - { - file_type->fmt = plain_pp; - file_type->byte_ordering = NATIVE_ORDERING; - file_type->word_size = 4; - } - else if (valid_pp_word1(data4s[0], 4)) - { - file_type->fmt = plain_pp; - file_type->byte_ordering = REVERSE_ORDERING; - file_type->word_size = 4; - } - else - { - /* type not identified */ - return 1; - } - return 0; -} diff --git a/cf/umread_lib/c-lib/linklist.c b/cf/umread_lib/c-lib/linklist.c deleted file mode 100644 index 536ff828ad..0000000000 --- a/cf/umread_lib/c-lib/linklist.c +++ /dev/null @@ -1,261 +0,0 @@ -#include "umfileint.h" - -/* LINKED LIST FUNCTIONS */ - -void *list_new(List *heaplist) -{ - List *list; - CKP( list = malloc_(sizeof(List), heaplist) ); - list->first = NULL; - list->last = NULL; - list->n = 0; - return list; - ERRBLKP; -} - -/* This function frees a list; - * Set free_ptrs if the pointers which have been explicitly stored on the - * list (2nd argument to list_add) are to be freed, not just the pointers - * which are implicit in the linked list structure. NB there is no further - * recursion, in the sense that if the stored pointers are to datatypes which - * contain further pointers then these may have to be freed explicitly. - */ -int list_free(List *list, int free_ptrs, List *heaplist) -{ - List_element *p, *next; - CKP(list); - for (p = list->first ; p != NULL ; p = next) - { - next = p->next; - if (free_ptrs) - CKI( free_(p->ptr, heaplist) ); - CKI( free_(p,heaplist) ); - } - CKI( free_(list, heaplist) ); - return 0; - ERRBLKI; -} - - -int list_size(const List *list) -{ - CKP(list); - return list->n; - ERRBLKI; -} - - -int list_add(List *list, void *ptr, List *heaplist) -{ - List_element *el; - CKP(list); - CKP( el = malloc_(sizeof(List_element), heaplist) ); - list->n ++; - el->ptr = ptr; - el->next = NULL; - if (list->first == NULL) - { - el->prev = NULL; - list->first = list->last = el; - } - else - { - list->last->next = el; - el->prev = list->last; - list->last = el; - } - return 0; - ERRBLKI; -} - -/* list_add_or_find takes a pointer to an item and tries to find it on the - * list, using the comparision function as in list_find. - * - * If it already exists, it changes the item to point to the old value, and - * calls the supplied function (if non-null) to free the item. If it does - * not exist, it adds the item to the list. - * - * Return values: - * 0 time already existed in axis - * 1 time has been added to axis - * -1 an error occurred (probably in memory allocation) - * - * NOTE: the return value of this function may be tested with the CKI() macro. - * Do not add non-error cases with negative return values. - * - * NOTE 2: The item is formally declared as a void* but the - * thing pointed to should itself be a pointer (to heap memory), - * so you should pass in a foo** of some sort. The only reason for - * not declaring as void** is that void* has the special property of - * being treated by the compiler as a generic pointer hence no - * warnings about incompatible pointer type - */ -int list_add_or_find(List *list, - void *item_in, - int (*compar)(const void *, const void *), - int matchval, - free_func free_function, - int *index_return, - List *heaplist) -{ - void *oldptr; - void **item = (void**) item_in; - - if ((oldptr = list_find(list, *item, compar, - matchval, index_return)) != NULL) - { - if (free_function != NULL) - CKI( free_function(*item, heaplist) ); - *item = oldptr; - return 0; - } - else - { - CKI( list_add(list, *item, heaplist) ); - if (index_return != NULL) - *index_return = list_size(list) - 1; - return 1; - } - ERRBLKI; -} - - -/* call list_del to find a pointer ("ptr" element contained within the - * listel structure) on the list, and then delete that element from the list, - * or call list_del_by_listel directly (more efficient) if you already - * have the listel structure pointer for what you want to delete. - */ - -int list_del(List *list, void *ptr, List *heaplist) -{ - List_element *p; - CKP(list); - for (p = list->first; p != NULL; p = p->next) - if (p->ptr == ptr) - return list_del_by_listel(list, p, heaplist); - - /* if what we're trying to remove is not found, fall through - * to error exit - */ - ERRBLKI; -} - - -int list_del_by_listel(List *list, List_element *p, List *heaplist) -{ - List_element *prev, *next; - next = p->next; - prev = p->prev; - if (next != NULL) next->prev = prev; - if (prev != NULL) prev->next = next; - if (p == list->first) list->first = next; - if (p==list->last) list->last = prev; - CKI( free_(p, heaplist) ); - list->n --; - return 0; - ERRBLKI; -} - -/* call list_startwalk before a sequence of calls to list_walk */ -int list_startwalk(const List *list, List_handle *handle) -{ - CKP(list); - CKP(handle); - handle->current = list->first; - handle->list = list; - return 0; - ERRBLKI; -} - - -/* list_walk: - * designed to be called repeatedly, and returns the next element of the - * list each time (but must not call either add or del between calls) - * - * (Set return_listel to nonzero to return the list element structure rather - * than the pointer it contains. This is just so that if you put null - * pointers on the list you can tell the difference from end of list.) - */ -void *list_walk(List_handle *handle, int return_listel) -{ - void *ptr; - CKP(handle); - if (handle->current == NULL) - return NULL; - else - { - ptr = (return_listel) ? (void *) handle->current : handle->current->ptr; - handle->current = handle->current->next; - return ptr; - } - ERRBLKP; -} - -/*------------------------------------------------------------------------------*/ -/* list_find: find first item on the list matching specified item, where - * "compar" is the matching function, and "matchval" is return value from - * compar in the event of a match - * - * The pointer index_return, if non-NULL, is used to return the index number - * on the list (set to -1 if not found). - */ -void *list_find(List *list, - const void *item, - int (*compar)(const void *, const void *), - int matchval, - int *index_return) -{ - int index; - List_element *listel; - List_handle handle; - void *ptr; - - list_startwalk(list, &handle); - index = 0; - while ((listel = list_walk(&handle, 1)) != NULL) - { - ptr = listel->ptr; - if (compar(&item, &ptr) == matchval) - { - if (index_return != NULL) - *index_return = index; - return ptr; - } - index++; - } - if (index_return != NULL) - *index_return = -1; - return NULL; -} - - -int list_copy_to_ptr_array(const List *list, - int *n_return, - void *ptr_array_return, - List *heaplist) -{ - int n; - List_handle handle; - List_element *listel; - void **ptr_array, **p; - - n = list_size(list); - if (n == 0) - ptr_array = NULL; - else - { - CKP( ptr_array = malloc_(n * sizeof(void *), heaplist) ); - p = ptr_array; - - list_startwalk(list, &handle); - while ((listel = list_walk(&handle, 1)) != NULL) - { - *p = listel->ptr; - p++; - } - } - *n_return = n; - * (void ***) ptr_array_return = ptr_array; - return 0; - ERRBLKI; -} diff --git a/cf/umread_lib/c-lib/malloc.c b/cf/umread_lib/c-lib/malloc.c deleted file mode 100644 index 2023d54b8a..0000000000 --- a/cf/umread_lib/c-lib/malloc.c +++ /dev/null @@ -1,118 +0,0 @@ -#include -#include - -#include "umfileint.h" - -/* Malloc functions - * - * These routines are closely integrated with the link list functions; they - * are called with a linked list "heaplist"; the malloc_ function adds the - * newly allocated pointer to this list, and the free_ function removes it - * from the list. (They can also be called with NULL in which case they - * ignore the heaplist; this is necessary when allocating or freeing memory - * for the heaplist itself.) - * - * The idea is that all the dynamically memory allocation associated with - * a given file should be through these functions. Then whenever the file - * is closed properly or because an error condition gave an abort, the - * memory can be freed without needing complicated tests to work out what - * has been allocated: just go through the linked list freeing pointers. - * - * NOTE: this routine now allocates a little more memory than requested, - * and saves the pointer to the list element on the heaplist at the start, - * before returning to the calling routine the pointer to the actual block - * of memory that the caller is interested in. This ensures that when freeing - * the memory, list_del_by_listel can be used instead of list_del, giving - * efficiency gains. - */ - -static const int extrasize = sizeof(List_element*); - -void *malloc_(size_t size, List *heaplist){ - - void *ptr; - List_element* *elp; - - if (size == 0) - return NULL; - - /* The only call to malloc in umfile c-lib (except in unwgdos.c and packed_data in read.c) */ - ptr = malloc(size + extrasize); - - if (ptr == NULL) - { - error_mesg("unable to allocate of %d bytes of memory", - size); - } - else - { - /* copy the pointer so we can use the start of the address to store - * the List_element* - */ - elp = (List_element**) ptr; - - /* Now increment the pointer (to after our stored List_element*) to give - * what the calling routine calling routine sees the start of memory - * (cast to char* for ptr arithmetic. Do this *before* storing it - * on the heaplist, because pointers on will be freed with free - */ - ptr = (void*) ((char*)ptr + extrasize); - - if (heaplist != NULL) - { - CKI( list_add(heaplist, ptr, NULL) ); - - /* we just added to the list, so that heaplist->last will - * contain pointer to the relevant List_element* - */ - *elp = heaplist->last; - } - else - *elp = NULL; - } - - return ptr; - ERRBLKP; -} - - -void *dup_(const void *inptr, size_t size, List *heaplist) -{ - void *outptr; - - CKP( outptr = malloc_(size, heaplist) ); - memcpy(outptr, inptr, size); - return outptr; - ERRBLKP; -} - - -int free_(void *ptr, List *heaplist) -{ - List_element *el; - - CKP(ptr); - /* first subtract off the extra size we added (see malloc_) */ - ptr = (void*) ((char*) ptr - extrasize); - - /* this is our list element */ - el = * (List_element**) ptr; - - /* The only call to free in umfile c-lib - * (except in unwgdos.c and packed_data in read.c) - */ - free(ptr); - - /* printf ("free: %p\n",ptr); */ - if (heaplist != NULL) - CKI( list_del_by_listel(heaplist, el, NULL) ); - - return 0; - ERRBLKI; -} - - -int free_all(List *heaplist) -{ - return list_free(heaplist, 1, NULL); -} diff --git a/cf/umread_lib/c-lib/new_structs.c b/cf/umread_lib/c-lib/new_structs.c deleted file mode 100644 index 15d70f2f37..0000000000 --- a/cf/umread_lib/c-lib/new_structs.c +++ /dev/null @@ -1,106 +0,0 @@ -#include "umfileint.h" - -/* functions to create pointers to new structures (such as Rec, Var and File) - * from heap memory - * - * Also for good measure functions to free these, although most are not - * crucial if they are not called, because of the garbage collection procedure - * employed in free_file() - */ - -Rec *new_rec(int word_size, List *heaplist) -{ - Rec *rec; - - CKP( rec = malloc_(sizeof(Rec), heaplist) ); - CKP( rec->internp = malloc_(sizeof(struct _Rec), heaplist) ); - CKP( rec->int_hdr = malloc_(N_INT_HDR * word_size, heaplist) ); - CKP( rec->real_hdr = malloc_(N_REAL_HDR * word_size, heaplist) ); - - rec->header_offset = -1; - rec->data_offset = -1; - rec->disk_length = -1; - return rec; - ERRBLKP; -} - -int free_rec(Rec *rec, List *heaplist) -{ - CKI( free_(rec->internp, heaplist) ); - CKI( free_(rec->int_hdr, heaplist) ); - CKI( free_(rec->real_hdr, heaplist) ); - CKI( free_(rec, heaplist) ); - return 0; - ERRBLKI; -} - -Var *new_var(List *heaplist) -{ - Var *var; - CKP( - var = malloc_(sizeof(Var), heaplist) - ); - CKP( - var->internp = malloc_(sizeof(struct _Var), heaplist) - ); - var->nz = 0; - var->nt = 0; - var->supervar_index = -1; - var->recs = NULL; - return var; - ERRBLKP; -} - -int free_var(Var *var, List *heaplist) -{ - CKI( free_(var->internp, heaplist) ); - if (var->recs) - CKI( free_(var->recs, heaplist) ); - CKI( free_(var, heaplist) ); - return 0; - ERRBLKI; -} - - -/* new_file is a rather special case, because the heaplist is initialised as - * part of the structure rather than supplied externally. Also free_file will - * free everything on the heaplist. - */ - -File *new_file() -{ - File *file; - - if ( (file = malloc_(sizeof(File), NULL)) == NULL) goto err1; - if ( (file->internp = malloc_(sizeof(struct _File), NULL)) == NULL) goto err2; - if ( (file->internp->heaplist = list_new(NULL)) == NULL) goto err3; - - file->nvars = 0; - file->vars = NULL; - file->fd = -1; - file->file_type.fmt = -1; - file->file_type.byte_ordering = -1; - file->file_type.word_size = -1; - file->internp->nrec = 0; - file->internp->recs = NULL; - - return file; - - err3: - free_(file->internp, NULL); - err2: - free_(file, NULL); - err1: - GRIPE; - return NULL; -} - - -int free_file(File *file) -{ - CKI( free_all(file->internp->heaplist) ); - CKI( free_(file->internp, NULL) ); - CKI( free_(file, NULL) ); - return 0; - ERRBLKI; -} diff --git a/cf/umread_lib/c-lib/swap.c b/cf/umread_lib/c-lib/swap.c deleted file mode 100644 index 5e68197457..0000000000 --- a/cf/umread_lib/c-lib/swap.c +++ /dev/null @@ -1,37 +0,0 @@ -#include "umfileint.h" - -#define DO_SWAP(x, y) {t = p[x]; p[x] = p[y]; p[y] = t;} - -void swap_bytes_sgl(void *ptr, size_t num_words) -{ - int i; - char *p; - char t; - - p = (char*) ptr; - for (i = 0; i < num_words; i++) - { - DO_SWAP(3, 0); - DO_SWAP(2, 1); - p += 4; - } -} - -void swap_bytes_dbl(void *ptr, size_t num_words) -{ - int i; - char *p; - char t; - - p = (char*) ptr; - for (i = 0; i < num_words; i++) - { - DO_SWAP(7, 0); - DO_SWAP(6, 1); - DO_SWAP(5, 2); - DO_SWAP(4, 3); - p += 8; - } -} - - diff --git a/cf/umread_lib/c-lib/type-dep/Makefile b/cf/umread_lib/c-lib/type-dep/Makefile deleted file mode 100644 index a3bda54740..0000000000 --- a/cf/umread_lib/c-lib/type-dep/Makefile +++ /dev/null @@ -1,28 +0,0 @@ -STEMS = umfile_test_typedep interpret_header read process_vars \ - debug_dump date_and_time compare levels axes unwgdos - -CFLAGS += -I.. - -SGL_OBJS=$(foreach stem, $(STEMS), $(stem)_sgl.o) -DBL_OBJS=$(foreach stem, $(STEMS), $(stem)_dbl.o) - -OBJS = $(SGL_OBJS) $(DBL_OBJS) - -LIB = $(TYPE_DEP_LIBRARY) - -.PHONY: all clean - -all: $(LIB) - -clean: - rm -f $(OBJS) $(LIB) - -%_dbl.o: %.c $(HEADERS) - $(CC) $(CFLAGS) -c -DDOUBLE -o $@ $< - -%_sgl.o: %.c $(HEADERS) - $(CC) $(CFLAGS) -c -DSINGLE -o $@ $< - -$(LIB): $(OBJS) - rm -f $@ - ar r $@ $(OBJS) diff --git a/cf/umread_lib/c-lib/type-dep/axes.c b/cf/umread_lib/c-lib/type-dep/axes.c deleted file mode 100644 index 1dd90f8a6e..0000000000 --- a/cf/umread_lib/c-lib/type-dep/axes.c +++ /dev/null @@ -1,75 +0,0 @@ -#include - -#include "umfileint.h" - -/* - * Functions relating to time and z axes. - * - * These functions have very similar content for Z and T axes, because in fact - * the Z_axis and T_axis struct each only contains a list of values. In cdunifpp, - * there are other elements (e.g. a time axis origin) that are used when comparing - * axes (maybe unnecessarily). In umread, we do not even bother to compare axes - * because axes are not returned to the caller: they are just used to evaluate the - * shape of the variable (nz, nt) and ensure that it is regular. - */ - -T_axis *new_t_axis(List *heaplist) -{ - T_axis *t_axis; - CKP( t_axis = malloc_(sizeof(T_axis), heaplist) ); - t_axis->values = list_new(heaplist); - return t_axis; - ERRBLKP; -} - -int free_t_axis(T_axis *t_axis, List *heaplist) -{ - CKI( list_free(t_axis->values, 1, heaplist) ); - CKI( free_(t_axis, heaplist) ); - return 0; - ERRBLKI; -} - - -Z_axis *new_z_axis(List *heaplist) -{ - Z_axis *z_axis; - CKP( z_axis = malloc_(sizeof(Z_axis), heaplist) ); - z_axis->values = list_new(heaplist); - return z_axis; - ERRBLKP; -} - -int free_z_axis(Z_axis *z_axis, List *heaplist) -{ - CKI( list_free(z_axis->values, 1, heaplist) ); - CKI( free_(z_axis, heaplist) ); - return 0; - ERRBLKI; -} - - -int t_axis_add(T_axis *t_axis, const Time *time, - int *index_return, List *heaplist) -{ - Time *timecopy; - - CKP( timecopy = dup_(time, sizeof(Time), heaplist) ); - return list_add_or_find(t_axis->values, &timecopy, compare_times, 0, - free_, index_return, heaplist); - ERRBLKI; -} - - - -int z_axis_add(Z_axis *z_axis, const Level *lev, - int *index_return, List *heaplist) -{ - Level *levcopy; - - CKP( levcopy = dup_(lev, sizeof(Level), heaplist) ); - return list_add_or_find(z_axis->values, &levcopy, compare_levels, 0, - free_, index_return, heaplist); - ERRBLKI; -} - diff --git a/cf/umread_lib/c-lib/type-dep/compare.c b/cf/umread_lib/c-lib/type-dep/compare.c deleted file mode 100644 index 71e010f3aa..0000000000 --- a/cf/umread_lib/c-lib/type-dep/compare.c +++ /dev/null @@ -1,345 +0,0 @@ -/* - * COMPARISON FUNCTIONS. - * - * NOTE: functions which take arguments of type void* (except for - * compare_ptrs) are designed to be used with generic routines: - * compare_records is envisaged for use with qsort; several other functions - * are envisaged for use with compare_lists (below). - * - * In these cases if supplying pointers directly to the relevant structures, - * need to generate an extra level of pointer with "&" syntax. - * - * But not all functions below are like that. Don't assume functions can be - * used analogously without first examining the argument lists. - */ - -/* The code profiler suggests that compare_ints and compare_reals are - * candidates for inlining; however, unfortunately this sometimes gets - * compiled with c89 which doesn't support inline functions. Use a #define - * for compare_ints. compare_reals, which is more awkward to #define, is just - * going to have to stay as it is for now (it's called less often). - */ - -#include - -#include "umfileint.h" - -#define compare_ints(a, b) ((a) < (b) ? (-1) : (a) > (b) ? 1 : 0) - -/* - * static int compare_ints(INTEGER a, INTEGER b) - * { - * if (ab) return 1; - * return 0; - * } - */ - -static int compare_reals(REAL a, REAL b) -{ - REAL delta; - - /* first test for special case (unnecessary, but code profiler shows - * slightly more efficient) - */ - if (a == b) - return 0; - - delta = fabs(b * REAL_TOLERANCE); - if (a < b - delta) return -1; - if (a > b + delta) return 1; - - return 0; -} - -#define COMPARE_INTS(tag) {int cmp = compare_ints(LOOKUP(a, tag), LOOKUP(b, tag)); if (cmp != 0) return cmp;} -#define COMPARE_REALS(tag) {int cmp = compare_reals(RLOOKUP(a, tag), RLOOKUP(b, tag)); if (cmp != 0) return cmp;} - - -/* routine to compare two PP records, to see if they are in the same - * variable - * - * returns: - * - * -1 or 1 headers are from different variables; - * sign of return value gives consistent ordering - * - * 0 headers are from same variable - */ -int compare_records_between_vars(const Rec *a, const Rec *b) -{ - int cmp; - COMPARE_INTS(INDEX_LBUSER4); - COMPARE_INTS(INDEX_LBUSER7); - COMPARE_INTS(INDEX_LBCODE); - COMPARE_INTS(INDEX_LBVC); - COMPARE_INTS(INDEX_LBTIM); - COMPARE_INTS(INDEX_LBPROC); - COMPARE_REALS(INDEX_BPLAT); - COMPARE_REALS(INDEX_BPLON); - COMPARE_INTS(INDEX_LBHEM); - COMPARE_INTS(INDEX_LBROW); - COMPARE_INTS(INDEX_LBNPT); - - COMPARE_REALS(INDEX_BGOR); - COMPARE_REALS(INDEX_BZY); - COMPARE_REALS(INDEX_BDY); - COMPARE_REALS(INDEX_BZX); - COMPARE_REALS(INDEX_BDX); - - cmp = compare_mean_periods(a, b); - if (cmp != 0) return cmp; - - /* Disambig index is used to force distinction between variables for records - * whose headers are the same. It is initialised to the same value for all - * records (in fact -1), but may later be set to different values according - * to some heuristic. - */ - - cmp = compare_ints(a->internp->disambig_index, b->internp->disambig_index); - if (cmp != 0) return cmp; - - return 0; -} - - -/* helper routine for compare_mean_periods - test if both periods are in specified range - * note - assumes that low, high are positive - */ -static int both_values_in_range(REAL low, REAL high, REAL a, REAL b) -{ - REAL low1 = low * (1. - REAL_TOLERANCE); - REAL high1 = high * (1. + REAL_TOLERANCE); - return (a >= low1) && (a <= high1) && (b >= low1) & (b <= high1); -} - - -/* Routine to compare if two PP records have different meaning periods, such - * that they should be considered to be part of different variables. Normally - * this will be true if the mean periods differ by more than "delta", but in - * the case of Gregorian calendar, we allow some tolerance relating to - * climatology data. - * - * This should only get called if both records have already been checked for - * having the same LBTIM and LBPROC. - */ - -int compare_mean_periods(const Rec *a, const Rec *b) -{ - int cmp; - - cmp = compare_reals(a->internp->mean_period, b->internp->mean_period); - if (cmp == 0) return 0; - - /* if we get here, times differ - but for gregorian cut some slack */ - if (calendar_type(LOOKUP(a, INDEX_LBTIM)) == gregorian) - { - if (both_values_in_range(28., 31., - a->internp->mean_period, b->internp->mean_period) /* monthly */ - || both_values_in_range(90., 92., - a->internp->mean_period, b->internp->mean_period) /* seasonal */ - || both_values_in_range(365., 366., - a->internp->mean_period, b->internp->mean_period)) /* annual */ - return 0; - } - return cmp; -} - - -/* routine to compare two PP records that the calling routine - * has already established are in the same variable. - * - * returns: - * - * -1 or 1 times or levels differ; - * sign of return value gives consistent ordering - * of times and levels - * - * 0 records do not differ within values tested - */ -int compare_records_within_var(const Rec *a, const Rec *b) -{ - int a_surface, b_surface; - - COMPARE_INTS(INDEX_LBFT); - - COMPARE_INTS(INDEX_LBYR); - COMPARE_INTS(INDEX_LBMON); - COMPARE_INTS(INDEX_LBDAT); - COMPARE_INTS(INDEX_LBDAY); - COMPARE_INTS(INDEX_LBHR); - COMPARE_INTS(INDEX_LBMIN); - - COMPARE_INTS(INDEX_LBYRD); - COMPARE_INTS(INDEX_LBMOND); - COMPARE_INTS(INDEX_LBDATD); - COMPARE_INTS(INDEX_LBDAYD); - COMPARE_INTS(INDEX_LBHRD); - COMPARE_INTS(INDEX_LBMIND); - - /* - * Ordering of levels: - * - * Generally we want to sort on LBLEV before sorting on BLEV. - * - * This is because in the case of hybrid levels, BLEV contains the B values - * (in p = A + B p_s), which won't do for sorting, and fortunately in this - * case LBLEV contains the model level index which is fine. - * - * But there is a nasty special case: surface and boundary layer heat flux - * has LBLEV = 9999, 2, 3, 4, ... where 9999 is the surface layer. In this - * case we *could* in fact sort on BLEV, but then we need to know when it's - * okay to do this (STASH code?). - * - * Maybe safer, treat 9999 lower than any level if comparing it with - * another level. (9999 should always be a special value and it is rare - * for it to be mixed with non-special values in the same variable.) - */ - a_surface = (LOOKUP(a, INDEX_LBLEV) == 9999); - b_surface = (LOOKUP(b, INDEX_LBLEV) == 9999); - if (a_surface && !b_surface) - return -1; - else if (b_surface && !a_surface) - return 1; - - COMPARE_INTS(INDEX_LBLEV); - COMPARE_REALS(INDEX_BLEV); - COMPARE_REALS(INDEX_BHLEV); - - return 0; -} - - -/* routine to compare two PP records. - * returns: - * -2 or 2 headers are from different variable - * -1 or 1 headers are from same variable - * 0 difference not found in elements inspected - * - */ -int compare_records(const void *p1, const void *p2) -{ - const Rec *a = * (Rec **) p1; - const Rec *b = * (Rec **) p2; - - int cmp; - - cmp = compare_records_between_vars(a, b); - if (cmp != 0) { - // debug("compare_records - variables differ %d %d", LOOKUP(a,INDEX_LBUSER4), - // LOOKUP(b,INDEX_LBUSER4)); - - return cmp * 2; - } - cmp = compare_records_within_var(a, b); - if (cmp != 0){ - // debug("compare_records - variables same %d %d", LOOKUP(a,INDEX_LBUSER4), - //LOOKUP(b,INDEX_LBUSER4)); - - return cmp; - } -//debug("compare_records - records same"); - return 0; -} - - -int records_from_different_vars(const Rec *a, const Rec *b) -{ - return (compare_records_between_vars(a, b) != 0); -} - - -int compare_lists(const List *l1, const List *l2, int (*compfunc)(const void*, const void*)) -{ - int i, n, cmp; - const void *item1, *item2; - List_handle handle1, handle2; - - /* differ if number of items differs */ - n = list_size(l1); - if ((cmp = compare_ints(n, list_size(l2))) != 0) return cmp; - - /* differ if any individual item differs */ - list_startwalk(l1, &handle1); - list_startwalk(l2, &handle2); - for (i = 0; i < n; i++) { - item1 = list_walk(&handle1, 0); - item2 = list_walk(&handle2, 0); - if ((cmp = compfunc(&item1, &item2)) != 0) return cmp; - } - return 0; -} - -int compare_levels(const void *p1, const void *p2) -{ - const Level *a = *(Level **)p1; - const Level *b = *(Level **)p2; - - /* macros called LCOMPARE_INTS and LCOMPARE_REALS to emphasise difference from those in compare_records */ - -#define LCOMPARE_INTS(tag) {int cmp = compare_ints(a->tag, b->tag); if (cmp != 0) return cmp;} -#define LCOMPARE_REALS(tag) {int cmp = compare_reals(a->tag, b->tag); if (cmp != 0) return cmp;} - - LCOMPARE_INTS(type); - - switch (a->type) { - case hybrid_height_lev_type: - LCOMPARE_REALS(values.hybrid_height.a); - LCOMPARE_REALS(values.hybrid_height.b); -#ifdef BDY_LEVS - LCOMPARE_REALS(values.hybrid_height.ubdy_a); - LCOMPARE_REALS(values.hybrid_height.ubdy_b); - LCOMPARE_REALS(values.hybrid_height.lbdy_a); - LCOMPARE_REALS(values.hybrid_height.lbdy_b); -#endif - break; - case hybrid_sigmap_lev_type: - LCOMPARE_REALS(values.hybrid_sigmap.a); - LCOMPARE_REALS(values.hybrid_sigmap.b); -#ifdef BDY_LEVS - LCOMPARE_REALS(values.hybrid_sigmap.ubdy_a); - LCOMPARE_REALS(values.hybrid_sigmap.ubdy_b); - LCOMPARE_REALS(values.hybrid_sigmap.lbdy_a); - LCOMPARE_REALS(values.hybrid_sigmap.lbdy_b); -#endif - break; - case pseudo_lev_type: - LCOMPARE_INTS(values.pseudo.index); - break; - default: - LCOMPARE_REALS(values.misc.level); -#ifdef BDY_LEVS - LCOMPARE_REALS(values.misc.ubdy_level); - LCOMPARE_REALS(values.misc.lbdy_level); -#endif - break; - } - return 0; -} - -int compare_times(const void *p1, const void *p2) -{ - const Time *a = * (Time **) p1; - const Time *b = * (Time **) p2; - int cmp; - - /* LBTYP: ignore 100s digit = sampling frequency, as we don't use it for anything */ - if ((cmp = compare_ints(a->type % 100, b->type % 100)) != 0) return cmp; - - if ((cmp = compare_dates(&a->time1, &b->time1)) != 0) return cmp; - if ((cmp = compare_dates(&a->time2, &b->time2)) != 0) return cmp; - return 0; -} - -int compare_dates(const Date *a, const Date *b) -{ - int cmp; - if ((cmp = compare_ints(a->year ,b->year )) != 0) return cmp; - if ((cmp = compare_ints(a->month ,b->month )) != 0) return cmp; - if ((cmp = compare_ints(a->day ,b->day )) != 0) return cmp; - if ((cmp = compare_ints(a->hour ,b->hour )) != 0) return cmp; - if ((cmp = compare_ints(a->minute,b->minute)) != 0) return cmp; - if ((cmp = compare_ints(a->second,b->second)) != 0) return cmp; - return 0; -} - diff --git a/cf/umread_lib/c-lib/type-dep/date_and_time.c b/cf/umread_lib/c-lib/type-dep/date_and_time.c deleted file mode 100644 index 2e1c87ce96..0000000000 --- a/cf/umread_lib/c-lib/type-dep/date_and_time.c +++ /dev/null @@ -1,169 +0,0 @@ -#include "umfileint.h" - -/* - * Aside from the time_set() function, most of the infrastructure in this file - * is for the purpose of calculating the length of the time mean period, just so - * that e.g. monthly and daily means in the same file can be separated out into - * different variables. - */ - -REAL get_mean_period(const Time *time) -{ - /* returns the averaging period in days, or 0. if it is not a mean field */ - INTEGER lbtim = time->type; - if (!is_time_mean(lbtim)) - return 0.; - return time_diff(lbtim, &time->time2, &time->time1); -} - -int is_time_mean(INTEGER LBTIM) -{ - int ib; - ib = (LBTIM / 10) % 10; - return (ib == 2) || (ib == 3); -} - -REAL time_diff(INTEGER lbtim, const Date *date, const Date *orig_date) -{ - int64_t secs; - - switch(calendar_type(lbtim)) - { - case gregorian: - return sec_to_day(gregorian_to_secs(date) - gregorian_to_secs(orig_date)); - break; /* notreached */ - case cal360day: - secs = - date->second - orig_date->second + - 60 * (date->minute - orig_date->minute + - 60 * (date->hour - orig_date->hour + - 24 * (date->day - orig_date->day + - 30 * (date->month - orig_date->month + - 12 * (int64_t) (date->year - orig_date->year) )))); - - return sec_to_day(secs); - break; /* notreached */ - case model: - secs = - date->second - orig_date->second + - 60 * (date->minute - orig_date->minute + - 60 * (date->hour - orig_date->hour + - 24 * (int64_t) (date->day - orig_date->day))); - - return sec_to_day(secs); - break; /* notreached */ - default: - SWITCH_BUG; - } - ERRBLKF; -} - -REAL sec_to_day(int64_t seconds) -{ - /* convert seconds to days, avoiding rounding where possible - * by using integer arithmetic for the whole days - */ - const int secs_per_day = 86400; - - int64_t days, remainder; - days = seconds / secs_per_day; - remainder = seconds % secs_per_day; - - return days + remainder / (REAL) secs_per_day; -} - -Calendar_type calendar_type(INTEGER type) -{ - switch(type % 10) - { - case 0: - /* fallthrough */ - case 3: - return model; - break; /* notreached */ - case 1: - return gregorian; - break; /* notreached */ - case 2: - return cal360day; - break; /* notreached */ - default: - SWITCH_BUG; - } - - /* on error return -1 (though only useful to calling routine if stored in an int - * not a Calendar_type) - */ - ERRBLKI; -} - -int64_t gregorian_to_secs(const Date *date) -{ - /* Convert from Gregorian calendar to seconds since a fixed origin - * - * Can be with respect to any arbitary origin, because return values from this are - * differenced. - * - * Arbitrary origin is what would be 1st Jan in the year 0 if hypothetically the - * system was completely consistent going back this far. This simplifies the - * calculation. - * - * Strictly, this is proleptic_gregorian rather than gregorian (see CF docs) - * as this is more likely to match the code actually in the model. The UM - * docs call it "gregorian" but I'm speculating (without checking model code) - * that the model really doesn't have all that jazz with Julian calendar - * before fifteen-something. - */ - const int sid = 86400; /* seconds in day */ - const int sih = 3600; - const int sim = 60; - - int64_t nsec; - int year,nleap,nday,isleap; - - /* offsets from 1st Jan to 1st of each month in non-leap year */ - int dayno[12] = { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334 }; - - year = date->year; - - /* is the year leap? */ - if (year % 400 == 0) isleap = 1; - else if (year % 100 == 0) isleap = 0; - else if (year % 4 == 0) isleap = 1; - else isleap=0; - - /* nleap is number of 29th Febs passed between origin date and supplied date. */ - nleap = year / 4 - year / 100 + year / 400; - if (isleap && date->month <= 2) - nleap--; - - nday = (year * 365) + dayno[date->month - 1] + (date->day - 1) + nleap; - - nsec = (int64_t) nday * sid + date->hour * sih + date->minute * sim + date->second; - - return nsec; -} - - -int time_set(Time *time, const Rec *rec) -{ - time->type = LOOKUP(rec, INDEX_LBTIM); - - time->time1.year = LOOKUP(rec, INDEX_LBYR); - time->time1.month = LOOKUP(rec, INDEX_LBMON); - time->time1.day = LOOKUP(rec, INDEX_LBDAT); - time->time1.hour = LOOKUP(rec, INDEX_LBHR); - time->time1.minute = LOOKUP(rec, INDEX_LBMIN); - time->time1.second = 0; - - time->time2.year = LOOKUP(rec, INDEX_LBYRD); - time->time2.month = LOOKUP(rec, INDEX_LBMOND); - time->time2.day = LOOKUP(rec, INDEX_LBDATD); - time->time2.hour = LOOKUP(rec, INDEX_LBHRD); - time->time2.minute = LOOKUP(rec, INDEX_LBMIND); - time->time2.second = 0; - - return 0; -} - - diff --git a/cf/umread_lib/c-lib/type-dep/debug_dump.c b/cf/umread_lib/c-lib/type-dep/debug_dump.c deleted file mode 100644 index 996fcb852d..0000000000 --- a/cf/umread_lib/c-lib/type-dep/debug_dump.c +++ /dev/null @@ -1,88 +0,0 @@ -#include "umfileint.h" - -void debug_dump_all_headers(File *file) -{ - int irec; - Rec *rec; - - debug("fd = %d", file->fd); - debug("fmt = %d", file->file_type.fmt); - debug("byte_ordering = %d", file->file_type.byte_ordering); - debug("word_size = %d", file->file_type.word_size); - debug("nrec = %d", file->internp->nrec); - debug(""); - - for (irec = 0; irec < file->internp->nrec; irec++) - { - rec = file->internp->recs[irec]; - debug("rec %d", irec); - debug("header_offset = %d", rec->header_offset); - debug("data_offset = %d", rec->data_offset); - debug("disk_length = %d", rec->disk_length); - - debug("LBYR = %d", LOOKUP(rec, INDEX_LBYR)); - debug("LBMON = %d", LOOKUP(rec, INDEX_LBMON)); - debug("LBDAT = %d", LOOKUP(rec, INDEX_LBDAT)); - debug("LBHR = %d", LOOKUP(rec, INDEX_LBHR)); - debug("LBMIN = %d", LOOKUP(rec, INDEX_LBMIN)); - debug("LBDAY = %d", LOOKUP(rec, INDEX_LBDAY)); - debug("LBYRD = %d", LOOKUP(rec, INDEX_LBYRD)); - debug("LBMOND = %d", LOOKUP(rec, INDEX_LBMOND)); - debug("LBDATD = %d", LOOKUP(rec, INDEX_LBDATD)); - debug("LBHRD = %d", LOOKUP(rec, INDEX_LBHRD)); - debug("LBMIND = %d", LOOKUP(rec, INDEX_LBMIND)); - debug("LBDAYD = %d", LOOKUP(rec, INDEX_LBDAYD)); - debug("LBTIM = %d", LOOKUP(rec, INDEX_LBTIM)); - debug("LBFT = %d", LOOKUP(rec, INDEX_LBFT)); - debug("LBLREC = %d", LOOKUP(rec, INDEX_LBLREC)); - debug("LBCODE = %d", LOOKUP(rec, INDEX_LBCODE)); - debug("LBHEM = %d", LOOKUP(rec, INDEX_LBHEM)); - debug("LBROW = %d", LOOKUP(rec, INDEX_LBROW)); - debug("LBNPT = %d", LOOKUP(rec, INDEX_LBNPT)); - debug("LBEXT = %d", LOOKUP(rec, INDEX_LBEXT)); - debug("LBPACK = %d", LOOKUP(rec, INDEX_LBPACK)); - debug("LBREL = %d", LOOKUP(rec, INDEX_LBREL)); - debug("LBFC = %d", LOOKUP(rec, INDEX_LBFC)); - debug("LBCFC = %d", LOOKUP(rec, INDEX_LBCFC)); - debug("LBPROC = %d", LOOKUP(rec, INDEX_LBPROC)); - debug("LBVC = %d", LOOKUP(rec, INDEX_LBVC)); - debug("LBRVC = %d", LOOKUP(rec, INDEX_LBRVC)); - debug("LBEXP = %d", LOOKUP(rec, INDEX_LBEXP)); - debug("LBBEGIN = %d", LOOKUP(rec, INDEX_LBBEGIN)); - debug("LBNREC = %d", LOOKUP(rec, INDEX_LBNREC)); - debug("LBPROJ = %d", LOOKUP(rec, INDEX_LBPROJ)); - debug("LBTYP = %d", LOOKUP(rec, INDEX_LBTYP)); - debug("LBLEV = %d", LOOKUP(rec, INDEX_LBLEV)); - debug("LBRSVD1 = %d", LOOKUP(rec, INDEX_LBRSVD1)); - debug("LBRSVD2 = %d", LOOKUP(rec, INDEX_LBRSVD2)); - debug("LBRSVD3 = %d", LOOKUP(rec, INDEX_LBRSVD3)); - debug("LBRSVD4 = %d", LOOKUP(rec, INDEX_LBRSVD4)); - debug("LBSRCE = %d", LOOKUP(rec, INDEX_LBSRCE)); - debug("LBUSER1 = %d", LOOKUP(rec, INDEX_LBUSER1)); - debug("LBUSER2 = %d", LOOKUP(rec, INDEX_LBUSER2)); - debug("LBUSER3 = %d", LOOKUP(rec, INDEX_LBUSER3)); - debug("LBUSER4 = %d", LOOKUP(rec, INDEX_LBUSER4)); - debug("LBUSER5 = %d", LOOKUP(rec, INDEX_LBUSER5)); - debug("LBUSER6 = %d", LOOKUP(rec, INDEX_LBUSER6)); - debug("LBUSER7 = %d", LOOKUP(rec, INDEX_LBUSER7)); - debug("BULEV = %f", RLOOKUP(rec, INDEX_BULEV)); - debug("BHULEV = %f", RLOOKUP(rec, INDEX_BHULEV)); - debug("BRSVD3 = %f", RLOOKUP(rec, INDEX_BRSVD3)); - debug("BRSVD4 = %f", RLOOKUP(rec, INDEX_BRSVD4)); - debug("BDATUM = %f", RLOOKUP(rec, INDEX_BDATUM)); - debug("BACC = %f", RLOOKUP(rec, INDEX_BACC)); - debug("BLEV = %f", RLOOKUP(rec, INDEX_BLEV)); - debug("BRLEV = %f", RLOOKUP(rec, INDEX_BRLEV)); - debug("BHLEV = %f", RLOOKUP(rec, INDEX_BHLEV)); - debug("BHRLEV = %f", RLOOKUP(rec, INDEX_BHRLEV)); - debug("BPLAT = %f", RLOOKUP(rec, INDEX_BPLAT)); - debug("BPLON = %f", RLOOKUP(rec, INDEX_BPLON)); - debug("BGOR = %f", RLOOKUP(rec, INDEX_BGOR)); - debug("BZY = %f", RLOOKUP(rec, INDEX_BZY)); - debug("BDY = %f", RLOOKUP(rec, INDEX_BDY)); - debug("BZX = %f", RLOOKUP(rec, INDEX_BZX)); - debug("BDX = %f", RLOOKUP(rec, INDEX_BDX)); - debug("BMDI = %f", RLOOKUP(rec, INDEX_BMDI)); - debug("BMKS = %f", RLOOKUP(rec, INDEX_BMKS)); - } -} diff --git a/cf/umread_lib/c-lib/type-dep/interpret_header.c b/cf/umread_lib/c-lib/type-dep/interpret_header.c deleted file mode 100644 index 0d5bd9a090..0000000000 --- a/cf/umread_lib/c-lib/type-dep/interpret_header.c +++ /dev/null @@ -1,141 +0,0 @@ -#include "umfileint.h" - -Data_type get_type(const INTEGER *int_hdr) -{ - switch (int_hdr[INDEX_LBUSER1]) - { - case(2): - case(-2): - case(3): - case(-3): - return int_type; - /* break; */ - case(1): - case(-1): - return real_type; - /* break; */ - default: - error_mesg("Warning: datatype %d not recognised, assuming real", - int_hdr[INDEX_LBUSER1]); - return real_type; - } -} - -/* Get number of data words. Does not include extra data. */ -size_t get_num_data_words(const INTEGER *int_hdr) -{ - if (int_hdr[INDEX_LBPACK] != 0 - && int_hdr[INDEX_LBROW] > 0 - && int_hdr[INDEX_LBNPT] > 0) - /* if packed and horizontal grid sizes set, use them */ - return int_hdr[INDEX_LBROW] * int_hdr[INDEX_LBNPT]; - else - /* otherwise use LBLREC */ - return int_hdr[INDEX_LBLREC] - get_extra_data_length(int_hdr) / WORD_SIZE; -} - -/* get length of (any) extra data in bytes */ -size_t get_extra_data_length(const INTEGER *int_hdr) -{ - if (int_hdr[INDEX_LBEXT] > 0) - return int_hdr[INDEX_LBEXT] * WORD_SIZE; - return 0; -} - -size_t get_extra_data_offset_and_length_core(const INTEGER *int_hdr, - size_t data_offset, - size_t disk_length, - size_t *extra_data_offset_rtn, - size_t *extra_data_length_rtn) -{ - size_t extra_data_length; - - extra_data_length = get_extra_data_length(int_hdr); - *extra_data_length_rtn = extra_data_length; - - /* If data is packed, the only way of telling where the extra data is - * is to assume that it is at the very end of data_length. - * - * If data is not packed, then can use data_length to work out where the - * extra data starts, allowing resilience against the possibility that - * disk_length might include some possible padding - */ - if (int_hdr[INDEX_LBPACK] != 0) - *extra_data_offset_rtn = data_offset + disk_length - extra_data_length; - else - *extra_data_offset_rtn = data_offset + get_num_data_words(int_hdr) * WORD_SIZE; - - return 0; -} - -int get_type_and_num_words_core(const INTEGER *int_hdr, - Data_type *type_rtn, - size_t *num_words_rtn) -{ - *type_rtn = get_type(int_hdr); - *num_words_rtn = get_num_data_words(int_hdr); - return 0; -} - - -/* sometimes a variable is included but which has some - * really essential header elements to missing data flag, - * so the variable is essentially missing in that any - * attempt to process the variable is only going to - * lead to errors - * - * pp_var_missing() tests for this. - * - * FIXME: expand to test other header elements - */ -int var_is_missing(const INTEGER *int_hdr) -{ - if (int_hdr[INDEX_LBNPT] == INT_MISSING_DATA) - return 1; - - if (int_hdr[INDEX_LBROW] == INT_MISSING_DATA) - return 1; - - return 0; -} - -int get_var_stash_model(const INTEGER *int_hdr) -{ - return int_hdr[INDEX_LBUSER7]; -} - -int get_var_stash_section(const INTEGER *int_hdr) -{ - return int_hdr[INDEX_LBUSER4] / 1000; -} - -int get_var_stash_item(const INTEGER *int_hdr) -{ - return int_hdr[INDEX_LBUSER4] % 1000; -} - -int get_var_compression(const INTEGER *int_hdr) -{ - return (int_hdr[INDEX_LBPACK] / 10) % 10; -} - - -int get_var_gridcode(const INTEGER *int_hdr) -{ - return int_hdr[INDEX_LBCODE]; -} - - -int get_var_packing(const INTEGER *int_hdr) -{ - return int_hdr[INDEX_LBPACK] % 10; -} - - -/* get the fill value from the floating point header. - * caller needs to check that it is actually floating point data! - */ -REAL get_var_real_fill_value(const REAL *real_hdr) -{ - return real_hdr[INDEX_BMDI]; -} diff --git a/cf/umread_lib/c-lib/type-dep/levels.c b/cf/umread_lib/c-lib/type-dep/levels.c deleted file mode 100644 index 251fcdf800..0000000000 --- a/cf/umread_lib/c-lib/type-dep/levels.c +++ /dev/null @@ -1,97 +0,0 @@ -#include "umfileint.h" - - -int lev_set(Level *lev, const Rec *rec) -{ - lev->type = level_type(rec); - - switch (lev->type) - { - case hybrid_height_lev_type: - lev->values.hybrid_height.a = RLOOKUP(rec, INDEX_BLEV); - lev->values.hybrid_height.b = RLOOKUP(rec, INDEX_BHLEV); -#ifdef BDY_LEVS - lev->values.hybrid_height.ubdy_a = RLOOKUP(rec, INDEX_BULEV); - lev->values.hybrid_height.ubdy_b = RLOOKUP(rec, INDEX_BHULEV); - lev->values.hybrid_height.lbdy_a = RLOOKUP(rec, INDEX_BRLEV); - lev->values.hybrid_height.lbdy_b = RLOOKUP(rec, INDEX_BHRLEV); -#endif - break; - - case hybrid_sigmap_lev_type: - lev->values.hybrid_sigmap.a = RLOOKUP(rec, INDEX_BHLEV); - lev->values.hybrid_sigmap.b = RLOOKUP(rec, INDEX_BLEV); -#ifdef BDY_LEVS - lev->values.hybrid_sigmap.ubdy_a = RLOOKUP(rec, INDEX_BHULEV); - lev->values.hybrid_sigmap.ubdy_b = RLOOKUP(rec, INDEX_BULEV); - lev->values.hybrid_sigmap.lbdy_a = RLOOKUP(rec, INDEX_BHRLEV); - lev->values.hybrid_sigmap.lbdy_b = RLOOKUP(rec, INDEX_BRLEV); -#endif - break; - - case pseudo_lev_type: - lev->values.pseudo.index = LOOKUP(rec, INDEX_LBUSER5); - break; - - default: - if (RLOOKUP(rec, INDEX_BLEV) == 0 - && LOOKUP(rec, INDEX_LBLEV) != 9999 - && LOOKUP(rec, INDEX_LBLEV) != 8888) - lev->values.misc.level = LOOKUP(rec, INDEX_LBLEV); - else - lev->values.misc.level = RLOOKUP(rec, INDEX_BLEV); -#ifdef BDY_LEVS - lev->values.misc.ubdy_level = RLOOKUP(rec, INDEX_BULEV); - lev->values.misc.lbdy_level = RLOOKUP(rec, INDEX_BRLEV); -#endif - break; - } - return 0; -} - - -Lev_type level_type(const Rec *rec) -{ - if (LOOKUP(rec, INDEX_LBUSER5) != 0 - && LOOKUP(rec, INDEX_LBUSER5) != INT_MISSING_DATA) - return pseudo_lev_type; - - switch (LOOKUP(rec, INDEX_LBVC)) - { - /* - * 1 Height (m) 8 Pressure (mb) - * 9 Hybrid co-ordinates 10 Sigma (=p/p*) - * 128 Mean sea level 129 Surface - * 130 Tropopause level 131 Maximum wind level - * 132 Freezing level 142 Upper hybrid level - * 143 Lower hybrid level 176 Latitude (deg) - * 177 Longitude (deg) - */ - /* also new dynamics: 65 hybrid height */ - - case 1: - return height_lev_type; - case 2: - return depth_lev_type; - case 5: - return boundary_layer_top_lev_type; - case 6: - return soil_lev_type; - case 8: - return pressure_lev_type; - case 9: - return hybrid_sigmap_lev_type; - case 65: - return hybrid_height_lev_type; - case 128: - return mean_sea_lev_type; - case 129: - return surface_lev_type; - case 130: - return tropopause_lev_type; - case 133: - return top_of_atmos_lev_type; - default: - return other_lev_type; - } -} diff --git a/cf/umread_lib/c-lib/type-dep/process_vars.c b/cf/umread_lib/c-lib/type-dep/process_vars.c deleted file mode 100644 index d41f9c60b1..0000000000 --- a/cf/umread_lib/c-lib/type-dep/process_vars.c +++ /dev/null @@ -1,350 +0,0 @@ -#include - -#include "umfileint.h" - - -File *file_parse_core(int fd, - File_type file_type) -{ - File *file; - List *heaplist; - - CKP( file = new_file() ); - file->fd = fd; - file->file_type = file_type; - - heaplist = file->internp->heaplist; - - CKI( read_all_headers(file, heaplist) ); - CKI( process_vars(file, heaplist) ); - - return file; - - err: - if (file) - free_file(file); - return NULL; -} - - -int process_vars(File *file, List *heaplist) -{ - int nrec; - Rec **recs; - List *vars; - - nrec = file->internp->nrec; - recs = file->internp->recs; - - /* initialise elements in the records before sorting */ - CKI( initialise_records(recs, nrec, heaplist) ); - - /* sort the records */ - qsort(recs, nrec, sizeof(Rec*), compare_records); - - /* now sort out the list of variables and dimensions */ - CKP( vars = list_new(heaplist) ); - CKI( get_vars(nrec, recs, vars, heaplist) ); - /* move the variables from the linked list to the array */ - CKI( list_copy_to_ptr_array(vars, &file->nvars, &file->vars, heaplist) ); - CKI( list_free(vars, 0, heaplist) ); - return 0; - ERRBLKI; -} - - -/* - * scan records for vars and add all to list - */ - -int get_vars(int nrec, Rec **recs, - List *vars, - List *heaplist) -{ - int recno; - int at_start_rec, at_end_rec = 0; - Rec *rec, **vrecs; - Z_axis *z_axis; - T_axis *t_axis; - Var *var; - int zindex, tindex; - int nvrec; - int svindex = 1; - - var = NULL; - z_axis = NULL; - t_axis = NULL; - - for (recno=0; recno < nrec ; recno++) - { - rec = recs[recno]; - - /* Some fieldsfiles have header fields with missing values */ - if (var_is_missing(rec->int_hdr)) - { - error_mesg("skipping variable stash code=%d, %d, %d " - "because of missing header data", - get_var_stash_model(rec->int_hdr), - get_var_stash_section(rec->int_hdr), - get_var_stash_item(rec->int_hdr)); - continue; - } - - /* we are at start record of a variable at the very start, or if at we - * were at the end record last time - */ - at_start_rec = ( recno == 0 || at_end_rec ); - - /* we are at end record of a variable at the very end, or if the header - * shows a difference from the next record which constitutes a different - * variable - * - * We also force end record of a variable if the grid type is not - * supported; any such records are passed back as single-record - * variables for the caller to deal with. - */ - at_end_rec = ( recno == nrec - 1 || - records_from_different_vars(recs[recno + 1], rec) || - !grid_supported(rec->int_hdr)); - - /* allow for variables which are unsupported for some reason */ - if (at_start_rec && test_skip_var(rec)) - continue; - - /* initialise new variable and axes if at first record of a variable */ - if (at_start_rec) - { - CKP( var = new_var(heaplist) ); - CKP( z_axis = new_z_axis(heaplist) ); - CKP( t_axis = new_t_axis(heaplist) ); - - var->internp->first_rec_no = recno; - var->internp->last_rec_no = -1; - var->internp->first_rec = rec; - } - - /* for every record, add the z, t values to the axes */ - - CKI( z_axis_add(z_axis, rec->internp->lev, &zindex, heaplist) ); - rec->internp->zindex = zindex; - - CKI( t_axis_add(t_axis, rec->internp->time, &tindex, heaplist) ); - rec->internp->tindex = tindex; - - if (at_end_rec) - { - var->internp->last_rec_no = recno; - nvrec = var->internp->last_rec_no - var->internp->first_rec_no + 1; - vrecs = recs + var->internp->first_rec_no; - - /* now if the axes are not regular, free the axes, split the variable - * into a number of variables and try again... - */ - if (set_disambig_index(z_axis, t_axis, vrecs, nvrec, svindex)) - { - /* increment the supervar index, used later to show the connection - * between the separate variables into which this one will be split - */ - svindex++; - - /* now re-sort this part of the record list, - * now that we have set the disambig index */ - qsort(vrecs, nvrec, sizeof(Rec *), compare_records); - - /* now go back to the start record of the variable; set to one less - * because it will get incremented in the "for" loop reinitialisation - */ - recno = var->internp->first_rec_no - 1; - - /* and free the stuff associated with the var we won't be using */ - CKI( free_z_axis(z_axis, heaplist) ); - CKI( free_t_axis(t_axis, heaplist) ); - CKI( free_var(var, heaplist) ); - - continue; - } - - /* add the metadata the caller needs */ - - var->nz = list_size(z_axis->values); - var->nt = list_size(t_axis->values); - var->recs = &recs[var->internp->first_rec_no]; - svindex = var->internp->first_rec->internp->supervar_index; - if (svindex >= 0) - var->supervar_index = svindex; - - /* add the variable */ - CKI( list_add(vars, var, heaplist) ); - - /* don't need the axes any more */ - CKI( free_z_axis(z_axis, heaplist) ); - CKI( free_t_axis(t_axis, heaplist) ); - } - } - return 0; - ERRBLKI; -} - - -int test_skip_var(const Rec *rec) -{ - char *skip_reason; - INTEGER *int_hdr; - - int_hdr = rec->int_hdr; - skip_reason = NULL; - - if (var_is_missing(int_hdr)) - skip_reason = "PP record has essential header data set to missing data value"; - - /* Compressed field index */ - if (get_var_compression(int_hdr) == 1) - skip_reason = "compressed field index not supported"; - - /* remove grid_supported test - now used to split up variables, not to - * exclude them. - * - * if (grid_supported(int_hdr) == 0) - * skip_reason = "grid code not supported"; - */ - - /* ADD ANY MORE VARIABLE SKIPPING CASES HERE. */ - - if (skip_reason != NULL) - { - error_mesg("skipping variable stash code=%d, %d, %d because: %s", - get_var_stash_model(int_hdr), - get_var_stash_section(int_hdr), - get_var_stash_item(int_hdr), - skip_reason); - return 1; - } - return 0; -} - - -int initialise_records(Rec **recs, int nrec, List *heaplist) -{ - int recno; - Rec *rec; - - for (recno = 0; recno < nrec ; recno++) - { - rec = recs[recno]; - rec->internp = rec->internp; - - rec->internp->disambig_index = -1; - rec->internp->supervar_index = -1; - - /* store level info */ - CKP( rec->internp->lev = malloc_(sizeof(Level), heaplist) ); - CKI( lev_set(rec->internp->lev, rec) ); - - /* store time info */ - CKP( rec->internp->time = malloc_(sizeof(Time), heaplist) ); - CKI( time_set(rec->internp->time, rec) ); - rec->internp->mean_period = get_mean_period(rec->internp->time); - } - return 0; - ERRBLKI; -} - - -/* - * set the disambig index on all records within a super-variable - */ -int set_disambig_index(Z_axis *z_axis, T_axis *t_axis, - Rec **recs, int nvrec, int svindex) -{ - int var_rec_no; - Rec *vrec; - int zindex, tindex, dindex; - int prev_zindex, prev_tindex, prev_dindex; - - prev_zindex = prev_tindex = prev_dindex = 0; - - /* do nothing if axes are regular */ - if (var_has_regular_z_t(z_axis, t_axis, recs, nvrec)) - return 0; - - for (var_rec_no=0; var_rec_no < nvrec; var_rec_no++) - { - vrec = recs[var_rec_no]; - - zindex = vrec->internp->zindex; - tindex = vrec->internp->tindex; - - /* check for dups coord pairs */ - /* the exact expressions for dindex are fairly arbitrary -- just need to - * ensure that indices for dup coordinate pairs will be different from - * indices for non-dups on other levels - */ - if (var_rec_no > 0 - && zindex == prev_zindex - && tindex == prev_tindex) - dindex = prev_dindex + 1; - else - dindex = zindex * nvrec; - - vrec->internp->disambig_index = dindex; - - if (vrec->internp->supervar_index < 0) - vrec->internp->supervar_index = svindex; - - /* save vals for next iter */ - prev_zindex = zindex; - prev_tindex = tindex; - prev_dindex = dindex; - } - return 1; -} - - -int grid_supported(INTEGER *int_hdr) -{ - int gridcode; - gridcode = get_var_gridcode(int_hdr); - - switch(gridcode) { - - case 1: - case 101: - case 11110: - return 1; - - default: - return 0; - } -} - - -/* routine to test t and z indices to check whether the variable is on regular - * array of times and levels (NB "regular" here refers to the ordering, not to - * whether the spacing is uniform) - */ - -int var_has_regular_z_t(Z_axis *z_axis, T_axis *t_axis, Rec **recs, int nvrec) -{ - int var_rec_no, nz, nt; /* needed for check on variables */ - Rec *rec; - - nz = list_size(z_axis->values); - nt = list_size(t_axis->values); - - /*------------------------------------------------------------*/ - - /* first test the most obvious case of irregular (for possible speed) */ - if (nvrec != nz * nt) - return 0; - - /* z indices (faster varying) should loop be vrec % nz */ - /* t indices (slower varying) should loop be vrec / nz */ - for (var_rec_no=0; var_rec_no < nvrec; var_rec_no++) - { - rec = recs[var_rec_no]; - if (rec->internp->zindex != var_rec_no % nz - || rec->internp->tindex != var_rec_no / nz) - return 0; - } - return 1; -} diff --git a/cf/umread_lib/c-lib/type-dep/read.c b/cf/umread_lib/c-lib/type-dep/read.c deleted file mode 100644 index 59b79d899f..0000000000 --- a/cf/umread_lib/c-lib/type-dep/read.c +++ /dev/null @@ -1,501 +0,0 @@ -#include -#include - -#include "umfileint.h" - -#define file_pos(f) (lseek(f, 0, SEEK_CUR)) - - -/* - * reads n words from file, storing them at ptr, with byte swapping as required - * returns number of words read (i.e. n, unless there's a short read) - */ -size_t read_words(int fd, - void *ptr, - size_t num_words, - Byte_ordering byte_ordering) -{ - size_t nread; - - CKP(ptr); - nread = read(fd, ptr, num_words * WORD_SIZE) / WORD_SIZE; - if (byte_ordering == REVERSE_ORDERING) - swap_bytes(ptr, nread); - return nread; - ERRBLKI; -} - - -int read_extra_data_core(int fd, - size_t extra_data_offset, - size_t extra_data_length, - Byte_ordering byte_ordering, - void *extra_data_rtn) -{ - /* reads extra data into storage provided by the caller - * The caller must provide the offset and length, obtained - * by a previous call to get_extra_data_offset_and_length() - */ - size_t extra_data_words; - - extra_data_words = extra_data_length / WORD_SIZE; - - CKI( lseek(fd, extra_data_offset, SEEK_SET) ); - ERRIF( extra_data_length % WORD_SIZE != 0 ); - ERRIF( read_words(fd, extra_data_rtn, - extra_data_words, byte_ordering) != extra_data_words); - return 0; - ERRBLKI; -} - - -int read_hdr_at_offset(int fd, - size_t header_offset, - Byte_ordering byte_ordering, - INTEGER *int_hdr_rtn, - REAL *real_hdr_rtn) -{ - /* as read_hdr below, but also specifying the file offset in bytes */ - - CKI( lseek(fd, header_offset, SEEK_SET) ); - return read_hdr(fd, byte_ordering, int_hdr_rtn, real_hdr_rtn); - ERRBLKI; -} - - -int read_hdr(int fd, - Byte_ordering byte_ordering, - INTEGER *int_hdr_rtn, - REAL *real_hdr_rtn) -{ - /* reads a PP header at specified word offset into storage - provided by the caller */ - - ERRIF( read_words(fd, int_hdr_rtn, - N_INT_HDR, byte_ordering) != N_INT_HDR); - - ERRIF( read_words(fd, real_hdr_rtn, - N_REAL_HDR, byte_ordering) != N_REAL_HDR); - - return 0; - ERRBLKI; -} - - -Rec *get_record(File *file, List *heaplist) -{ - /* reads PP headers and returns a Rec structure -- - * - * file must be positioned at start of header (after any fortran record length integer) on entry, - * and will be positioned at end of header on return - * - * the Rec structure will contain the headers in elements int_hdr and real_hdr, - * but other elements will be left as initialised by new_rec() - */ - - Rec *rec; - - CKP( rec = new_rec(WORD_SIZE, heaplist) ); - - CKI( - read_hdr(file->fd, - file->file_type.byte_ordering, - rec->int_hdr, - rec->real_hdr) - ); - - return rec; /* success */ - ERRBLKP; -} - -int read_all_headers(File *file, List *heaplist) -{ - switch (file->file_type.fmt) - { - case plain_pp: - return read_all_headers_pp(file, heaplist); - case fields_file: - return read_all_headers_ff(file, heaplist); - default: - switch_bug("read_all_headers"); - ERR; - } - return 0; - ERRBLKI; -} - -/* skip_fortran_record: skips a fortran record, and returns how big it was (in bytes), - * or -1 for end of file, or -2 for any error which may imply corrupt file - * (return value of 0 is a legitimate empty record). - */ -size_t skip_fortran_record(File *file) -{ - INTEGER rec_bytes, rec_bytes_2; - - if( read_words(file->fd, &rec_bytes, 1, - file->file_type.byte_ordering) != 1) return -1; - CKI( lseek(file->fd, rec_bytes, SEEK_CUR) ); - ERRIF( read_words(file->fd, &rec_bytes_2, 1, - file->file_type.byte_ordering) != 1); - ERRIF(rec_bytes != rec_bytes_2); - return rec_bytes; - ERRBLK(-2); -} - -int skip_word(File *file) -{ - CKI( lseek(file->fd, WORD_SIZE, SEEK_CUR) ); - return 0; - - ERRBLKI; -} - -int read_all_headers_pp(File *file, List *heaplist) -{ - int fd; - size_t nrec, rec_bytes, recno, header_offset; - Rec **recs, *rec; - - fd = file->fd; - - /* count the PP records in the file */ - lseek(fd, 0, SEEK_SET); - for (nrec = 0; (rec_bytes = skip_fortran_record(file)) != -1; nrec++) - { - ERRIF(rec_bytes == -2); - if (rec_bytes != N_HDR * WORD_SIZE) { - error_mesg("unsupported header length in PP file: %d words", - rec_bytes / WORD_SIZE); - ERR; - } - ERRIF( skip_fortran_record(file) < 0); /* skip the data record */ - } - - /* now rewind, and read in all the PP header data */ - CKP( recs = malloc_(nrec * sizeof(Rec *), heaplist) ); - file->internp->nrec = nrec; - file->internp->recs = recs; - - lseek(fd, 0, SEEK_SET); - for (recno = 0; recno < nrec; recno++) - { - CKI( skip_word(file) ); - header_offset = file_pos(fd); - CKP( rec = get_record(file, heaplist) ); - CKI( skip_word(file) ); - recs[recno] = rec; - - /* skip data record but store length */ - rec->header_offset = header_offset; - rec->data_offset = file_pos(fd) + WORD_SIZE; - rec->disk_length = skip_fortran_record(file); - } - return 0; - ERRBLKI; -} - - -#define READ_ITEM(x) \ - ERRIF( read_words(fd, &x, 1, byte_ordering) != 1); - -int read_all_headers_ff(File *file, List *heaplist) -{ - int fd; - size_t hdr_start, hdr_size, header_offset, data_offset_calculated, data_offset_specified; - int *valid, n_valid_rec, n_raw_rec, i_valid_rec, i_raw_rec; - Byte_ordering byte_ordering; - Rec *rec, **recs; - INTEGER start_lookup, nlookup1, nlookup2, dataset_type, start_data; - - fd = file->fd; - byte_ordering = file->file_type.byte_ordering; - - /* pick out certain information from the fixed length header */ - CKI( lseek(fd, 4 * WORD_SIZE, SEEK_SET) ); - READ_ITEM(dataset_type); - - ERRIF( read_words(fd, &dataset_type, 1, byte_ordering) != 1); - - CKI( lseek(fd, 149 * WORD_SIZE, SEEK_SET) ); - READ_ITEM(start_lookup); - READ_ITEM(nlookup1); - READ_ITEM(nlookup2); - - CKI( lseek(fd, 159 * WORD_SIZE, SEEK_SET) ); - READ_ITEM(start_data); - - /* (first dim of lookup documented as being 64 or 128, so - * allow header longer than n_hdr (64) -- discarding excess -- but not shorter) - */ - - if (nlookup1 < N_HDR) - { - error_mesg("unsupported header length: %d words", nlookup1); - ERR; - } - - CKP( valid = malloc_(nlookup2 * sizeof(int), heaplist) ); - - hdr_start = (start_lookup - 1) * WORD_SIZE; - hdr_size = nlookup1 * WORD_SIZE; - n_raw_rec = nlookup2; - CKI( get_valid_records_ff(fd, byte_ordering, hdr_start, hdr_size, n_raw_rec, - valid, &n_valid_rec) ); - - /* now read in all the PP header data */ - - CKP( recs = malloc_(n_valid_rec * sizeof(Rec *), heaplist) ); - /* debug("n_raw_rec=%d n_valid_rec=%d", n_raw_rec, n_valid_rec); */ - file->internp->nrec = n_valid_rec; - file->internp->recs = recs; - - i_valid_rec = 0; - data_offset_calculated = (start_data - 1) * WORD_SIZE; - for (i_raw_rec = 0; i_raw_rec < n_raw_rec; i_raw_rec++) - { - if (valid[i_raw_rec]) - { - header_offset = hdr_start + i_raw_rec * hdr_size; - CKI( lseek(fd, header_offset, SEEK_SET) ); - CKP( rec = get_record(file, heaplist) ); - recs[i_valid_rec] = rec; - - rec->header_offset = header_offset; - rec->disk_length = get_ff_disk_length(rec->int_hdr); - - data_offset_specified = (size_t) LOOKUP(rec, INDEX_LBBEGIN) * WORD_SIZE; - /* use LBBEGIN if available */ - rec->data_offset = - (data_offset_specified != 0) ? data_offset_specified : data_offset_calculated; - - data_offset_calculated += rec->disk_length; - - i_valid_rec++; - } - } - - CKI( free_(valid, heaplist) ); - return 0; - ERRBLKI; -} - - -size_t get_ff_disk_length(INTEGER *ihdr) -{ - /* work out disk length in bytes */ - /* Input array size (packed field): - * First try LBNREC - * then if Cray 32-bit packing, know ratio of packed to unpacked lengths; - * else use LBLREC - */ - if (ihdr[INDEX_LBPACK] != 0 && ihdr[INDEX_LBNREC] != 0) - return ihdr[INDEX_LBNREC] * WORD_SIZE; - if (ihdr[INDEX_LBPACK] % 10 == 2) - return get_num_data_words(ihdr) * 4; - return ihdr[INDEX_LBLREC] * WORD_SIZE; -} - - -/* - * check which PP records are valid; populate an array provided by the caller with 1s and 0s - * and also provide the total count - */ -int get_valid_records_ff(int fd, - Byte_ordering byte_ordering, - size_t hdr_start, size_t hdr_size, int n_raw_rec, - int valid[], int *n_valid_rec_return) -{ - int n_valid_rec, irec; - INTEGER lbbegin; - n_valid_rec = 0; - - for (irec = 0; irec < n_raw_rec; irec++) - { - valid[irec] = 0; - CKI( lseek(fd, hdr_start + irec * hdr_size + INDEX_LBBEGIN * WORD_SIZE, SEEK_SET) ); - READ_ITEM(lbbegin); - if (lbbegin != -99) - { - /* valid record */ - valid[irec] = 1; - n_valid_rec++; - } - else - valid[irec] = 0; - } - *n_valid_rec_return = n_valid_rec; - return 0; - ERRBLKI; -} - - -int read_record_data_core(int fd, - size_t data_offset, - size_t disk_length, - Byte_ordering byte_ordering, - const void *int_hdr, - const void *real_hdr, - size_t nwords, - void *data_return) -{ - int pack; - size_t packed_bytes, ipt, packed_words; - void *packed_data; - REAL mdi; - - packed_data = NULL; - - CKI( lseek(fd, data_offset, SEEK_SET) ); - pack = get_var_packing(int_hdr); - - if (pack == 0) - { - /* unpacked data -- read, and byte swap if necessary */ - ERRIF( read_words(fd, data_return, nwords, byte_ordering) != nwords); - } - else - { - /* PACKING IN USE */ - - /* Complain if not REAL data. In cdunifpp, this test was applied only to Cray 32-bit packing, - * but in fact also unwgdos assumes real, so apply to both packing types. - */ - if (get_type(int_hdr) != real_type) - { - error_mesg("Unpacking supported only for REAL type data"); - ERR; - } - - /* first allocate array and read in packed data */ - - /* disk_length includes extra data, so subtract off */ - packed_bytes = disk_length - get_extra_data_length(int_hdr); - - /* ---------------------------- - * Possible alternative envisaged, that reads in slightly more data to - * give tolerance against any situation where LBNREC does not include - * the extra data. However, this is now of dubious gain because reading in - * the extra data requires LBNREC to be used consistently so that the extra - * data can be found at the end of the record where the packed data that precedes - * it is of variable length. - * - * packed_bytes = disk_length; - *----------------------------- - */ - - - /* An exception to the usual strategy heap memory management: no heaplist available, so use plain malloc - * and be careful to free even if an error arose. - * - * (This is fairly much unavoidable: heaplist is attached to a File struct, but these exist only while - * parsing the file metadata, not while the read callback is executed. A Python File object will exist - * in the calling code, but that's not the same thing: it may have been instantiated with parse=False; - * see the Python code.) - */ - CKP( packed_data = malloc(packed_bytes) ); - ERRIF( read(fd, packed_data, packed_bytes) != packed_bytes ); - - /* NOW UNPACK ACCORDING TO PACKING TYPE (including byte swapping where necessary). */ - - switch(pack) - { - case 1: - /* WGDOS */ - - /* unwgdos routine wants to know number of native integers in input. - * input type might not be native int, so calculate: - */ - mdi = get_var_real_fill_value(real_hdr); - - /* Note - even though we read in raw (unswapped) data from the file, we do not - * byte swap prior to calling unwgdos, as the packed data contains a mixture - * of types of different lengths, so leave it to unwgdos() that knows about - * this and has appropriate byte swapping code. - */ - CKI( unwgdos(packed_data, packed_bytes, data_return, nwords, mdi) ); - - break; - - case 2: - if (byte_ordering == REVERSE_ORDERING) - swap_bytes_sgl(packed_data, packed_bytes / 4); - - for (ipt = 0; ipt < nwords ; ipt++) - ((REAL*) data_return)[ipt] = ((float32_t *) packed_data)[ipt]; - - break; - - case 3: - error_mesg("GRIB unpacking not supported"); - ERR; - - /* break; */ - - case 4: - packed_words = packed_bytes / WORD_SIZE; - if (byte_ordering == REVERSE_ORDERING) - swap_bytes(packed_data, packed_words); - mdi = get_var_real_fill_value(real_hdr); - CKI( unpack_run_length_encoded(packed_data, packed_words, data_return, nwords, mdi) ); - break; - - default: - SWITCH_BUG; - } - free(packed_data); - } - return 0; - err: - GRIPE; - if (packed_data != NULL) - free(packed_data); - return -1; -} - - -int unpack_run_length_encoded(REAL *datain, INTEGER nin, REAL *dataout, INTEGER nout, REAL mdi) -{ - REAL *src, *dest, *end_src, *end_dest, data; - INTEGER repeat; - - /* some pointers: - * src and dest are current positions; - * end_src and end_dest are the first position off the end of each array - */ - src = datain; - dest = dataout; - end_src = src + nin; - end_dest = dest + nout; - - /* syntax reminder: *p++ means first dereference p and then increment p - */ - while (src < end_src && dest < end_dest) - { - data = *src++; - if (data != mdi) - *dest++ = data; - else - { - /* check we didn't read the MDI as the last item in the input */ - ERRIF(src == end_src); - - /* read in next word, round to nearest integer, and output MDI that many times - * while checking we don't go beyond end of output data array - */ - for (repeat = (INTEGER)(0.5 + *src++); repeat > 0 && dest < end_dest; repeat--) - *dest++ = mdi; - - /* check we didn't reach end of output data array with copies of the MDI still to write - * (or read in a negative repeat count) - */ - ERRIF(repeat != 0); - } - } - /* check we reached end of output data, - * (not necessarily end of input data - it could be padded) - */ - ERRIF (dest != end_dest); - - return 0; - ERRBLKI; -} diff --git a/cf/umread_lib/c-lib/type-dep/redefs_dbl b/cf/umread_lib/c-lib/type-dep/redefs_dbl deleted file mode 100644 index 3991c45292..0000000000 --- a/cf/umread_lib/c-lib/type-dep/redefs_dbl +++ /dev/null @@ -1,62 +0,0 @@ -read_record_data_dummy read_record_data_dummy_sgl -get_extra_data_length get_extra_data_length_sgl -get_extra_data_offset_and_length_core get_extra_data_offset_and_length_core_sgl -get_num_data_words get_num_data_words_sgl -get_type get_type_sgl -get_type_and_num_words_core get_type_and_num_words_core_sgl -get_var_compression get_var_compression_sgl -get_var_gridcode get_var_gridcode_sgl -get_var_packing get_var_packing_sgl -get_var_real_fill_value get_var_real_fill_value_sgl -get_var_stash_item get_var_stash_item_sgl -get_var_stash_model get_var_stash_model_sgl -get_var_stash_section get_var_stash_section_sgl -var_is_missing var_is_missing_sgl -get_ff_disk_length get_ff_disk_length_sgl -get_record get_record_sgl -get_valid_records_ff get_valid_records_ff_sgl -read_all_headers read_all_headers_sgl -read_all_headers_ff read_all_headers_ff_sgl -read_all_headers_pp read_all_headers_pp_sgl -read_extra_data_core read_extra_data_core_sgl -read_hdr read_hdr_sgl -read_hdr_at_offset read_hdr_at_offset_sgl -read_record_data_core read_record_data_core_sgl -read_words read_words_sgl -skip_fortran_record skip_fortran_record_sgl -skip_word skip_word_sgl -unpack_run_length_encoded unpack_run_length_encoded_sgl -file_parse_core file_parse_core_sgl -get_vars get_vars_sgl -grid_supported grid_supported_sgl -initialise_records initialise_records_sgl -process_vars process_vars_sgl -set_disambig_index set_disambig_index_sgl -test_skip_var test_skip_var_sgl -var_has_regular_z_t var_has_regular_z_t_sgl -debug_dump_all_headers debug_dump_all_headers_sgl -calendar_type calendar_type_sgl -gregorian_to_secs gregorian_to_secs_sgl -is_time_mean is_time_mean_sgl -mean_period mean_period_sgl -sec_to_day sec_to_day_sgl -time_diff time_diff_sgl -time_set time_set_sgl -compare_dates compare_dates_sgl -compare_levels compare_levels_sgl -compare_lists compare_lists_sgl -compare_mean_periods compare_mean_periods_sgl -compare_records compare_records_sgl -compare_records_between_vars compare_records_between_vars_sgl -compare_records_within_var compare_records_within_var_sgl -compare_times compare_times_sgl -records_from_different_vars records_from_different_vars_sgl -level_type level_type_sgl -lev_set lev_set_sgl -free_t_axis free_t_axis_sgl -free_z_axis free_z_axis_sgl -new_t_axis new_t_axis_sgl -new_z_axis new_z_axis_sgl -t_axis_add t_axis_add_sgl -z_axis_add z_axis_add_sgl -unwgdos unwgdos_sgl diff --git a/cf/umread_lib/c-lib/type-dep/redefs_sgl b/cf/umread_lib/c-lib/type-dep/redefs_sgl deleted file mode 100644 index 7d5b976471..0000000000 --- a/cf/umread_lib/c-lib/type-dep/redefs_sgl +++ /dev/null @@ -1,62 +0,0 @@ -read_record_data_dummy read_record_data_dummy_dbl -get_extra_data_length get_extra_data_length_dbl -get_extra_data_offset_and_length_core get_extra_data_offset_and_length_core_dbl -get_num_data_words get_num_data_words_dbl -get_type get_type_dbl -get_type_and_num_words_core get_type_and_num_words_core_dbl -get_var_compression get_var_compression_dbl -get_var_gridcode get_var_gridcode_dbl -get_var_packing get_var_packing_dbl -get_var_real_fill_value get_var_real_fill_value_dbl -get_var_stash_item get_var_stash_item_dbl -get_var_stash_model get_var_stash_model_dbl -get_var_stash_section get_var_stash_section_dbl -var_is_missing var_is_missing_dbl -get_ff_disk_length get_ff_disk_length_dbl -get_record get_record_dbl -get_valid_records_ff get_valid_records_ff_dbl -read_all_headers read_all_headers_dbl -read_all_headers_ff read_all_headers_ff_dbl -read_all_headers_pp read_all_headers_pp_dbl -read_extra_data_core read_extra_data_core_dbl -read_hdr read_hdr_dbl -read_hdr_at_offset read_hdr_at_offset_dbl -read_record_data_core read_record_data_core_dbl -read_words read_words_dbl -skip_fortran_record skip_fortran_record_dbl -skip_word skip_word_dbl -unpack_run_length_encoded unpack_run_length_encoded_dbl -file_parse_core file_parse_core_dbl -get_vars get_vars_dbl -grid_supported grid_supported_dbl -initialise_records initialise_records_dbl -process_vars process_vars_dbl -set_disambig_index set_disambig_index_dbl -test_skip_var test_skip_var_dbl -var_has_regular_z_t var_has_regular_z_t_dbl -debug_dump_all_headers debug_dump_all_headers_dbl -calendar_type calendar_type_dbl -gregorian_to_secs gregorian_to_secs_dbl -is_time_mean is_time_mean_dbl -mean_period mean_period_dbl -sec_to_day sec_to_day_dbl -time_diff time_diff_dbl -time_set time_set_dbl -compare_dates compare_dates_dbl -compare_levels compare_levels_dbl -compare_lists compare_lists_dbl -compare_mean_periods compare_mean_periods_dbl -compare_records compare_records_dbl -compare_records_between_vars compare_records_between_vars_dbl -compare_records_within_var compare_records_within_var_dbl -compare_times compare_times_dbl -records_from_different_vars records_from_different_vars_dbl -level_type level_type_dbl -lev_set lev_set_dbl -free_t_axis free_t_axis_dbl -free_z_axis free_z_axis_dbl -new_t_axis new_t_axis_dbl -new_z_axis new_z_axis_dbl -t_axis_add t_axis_add_dbl -z_axis_add z_axis_add_dbl -unwgdos unwgdos_dbl diff --git a/cf/umread_lib/c-lib/type-dep/umfile_test_typedep.c b/cf/umread_lib/c-lib/type-dep/umfile_test_typedep.c deleted file mode 100644 index 4daad26106..0000000000 --- a/cf/umread_lib/c-lib/type-dep/umfile_test_typedep.c +++ /dev/null @@ -1,154 +0,0 @@ -/* datatype dependent parts of the test code */ - -#include -#include -#include -#include - -#include "umfileint.h" - -//Rec *rec_alloc() -//{ -// Rec *rec; -// rec = xmalloc(sizeof(Rec)); -// rec->internp = xmalloc(sizeof(struct _Rec)); -// rec->int_hdr = xmalloc(45 * sizeof(INTEGER)); -// rec->real_hdr = xmalloc(19 * sizeof(REAL)); -// return rec; -//} -// -//void rec_free(Rec *rec) -//{ -// _rec_internals_free(rec); -// { -// INTEGER *ihdr = rec->int_hdr; -// REAL *rhdr = rec->real_hdr; -// printf("freeing rec with ihdr=%d %d... rhdr=%f %f...\n", -// ihdr[0], ihdr[1], rhdr[0], rhdr[1]); -// } -// xfree(rec->int_hdr); -// xfree(rec->real_hdr); -// xfree(rec); -//} -// -// -//Rec *rec_create_dummy(int k) -//{ -// int i; -// Rec *rec; -// rec = rec_alloc(); -// for (i = 0; i < 45 ; i++) -// ((INTEGER *)rec->int_hdr)[i] = k * 100 + i; -// for (i = 0; i < 19 ; i++) -// ((REAL *)rec->real_hdr)[i] = k + i / 100.; -// rec->header_offset = k; -// rec->data_offset = 500 + k; -// rec->internp->blahblah = 200 + k; -// return rec; -//} -// -//int get_type_and_length_dummy(const void *int_hdr, Data_type *type_rtn, size_t *num_words_rtn) -//{ -// const INTEGER *int_hdr_4 = int_hdr; -// *num_words_rtn = int_hdr_4[0]; -// *type_rtn = real_type; -// return 0; -//} -// -void read_record_data_dummy(size_t nwords, - void *data_return) -{ - int i; - REAL *data_return_4 = data_return; - for (i = 0; i < nwords; i++) - { - data_return_4[i] = i / 100.; - } -} - - -// int read_record_data_core(int fd, -// size_t data_offset, -// size_t disk_length, -// Byte_ordering byte_ordering, -// int word_size, -// const void *int_hdr, -// const void *real_hdr, -// size_t nwords, -// void *data_return) -// { -// int i; -// assert(byte_ordering == little_endian); -// assert(word_size == 4); -// -// printf("start of int header seen in read_record_data_dummy():"); -// for (i = 0; i < 5; i++) -// printf(" %d", ((INTEGER *) int_hdr)[i]); -// printf("\n"); -// printf("start of real header seen in read_record_data_dummy():"); -// for (i = 0; i < 5; i++) -// printf(" %f", ((REAL *) real_hdr)[i]); -// printf("\n"); -// -// read_record_data_dummy(nwords, data_return); -// return 0; -// } -// - -#ifdef MAIN -int main() -{ - int i, j, k, nrec; - int fd; - File *file; - File_type file_type; - Var *var; - Rec *rec; - REAL *data; - int word_size; - Data_type data_type; - size_t nwords, nbytes; - - fd = 3; - detect_file_type(fd, &file_type); - printf("word size = %d\n", file_type.word_size); - - file = file_parse(fd, file_type); - for (i = 0; i < file->nvars; i++) - { - printf("var %d\n", i); - var = file->vars[i]; - printf("nz = %d, nt = %d\n", var->nz, var->nt); - nrec = var->nz * var->nt; - for (j = 0; j < nrec; j++) - { - rec = var->recs[j]; - printf("var %d rec %d\n", i, j); - printf("int header:\n"); - for (k = 0; k < 45; k++) - printf(" ihdr[%d] = %d\n", k, ((INTEGER *)rec->int_hdr)[k]); - printf("real header:\n"); - for (k = 0; k < 19; k++) - printf(" rhdr[%d] = %f\n", k, ((REAL *)rec->real_hdr)[k]); - - word_size = file_type.word_size; - get_type_and_length(word_size, rec->int_hdr, &data_type, &nwords); - nbytes = word_size * nwords;; - printf("data (%ld items)\n", nwords); - data = xmalloc(nbytes * sizeof(float)); - read_record_data(fd, - rec->data_offset, - file_type.byte_ordering, - file_type.word_size, - rec->int_hdr, - rec->real_hdr, - nwords, - data); - for (k = 0; k < nwords; k++) - printf(" data[%d] = %f\n", k, data[k]); - xfree(data); - } - } - return 0; -} -#endif diff --git a/cf/umread_lib/c-lib/type-dep/umfile_typedep.a b/cf/umread_lib/c-lib/type-dep/umfile_typedep.a deleted file mode 100644 index d1a02b38cb2cae1968ff2e1e15a9cca0db78bc3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 124124 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 np$>`vtY%n-eD{>;=RKexCgtx58XV -#include -#include -#include -#include - -#include "umfileint.h" - - -#define TRUE 1 -#define FALSE 0 - -static int xpnd(int, int32_t *, REAL *, REAL, int, REAL, int, REAL); -static int extrin(int32_t *, int, int, int, int *, int); -static int bit_test(void *, int); -static void move_bits(void *, int, int, void *); -static float32_t get_float32(void *); -static int16_t get_int16(void *, Byte_ordering); -static int32_t get_int32(void *, Byte_ordering); - - -int unwgdos(void *datain, int nbytes, REAL *dataout, int nout, REAL mdi) -{ - int /* len, */ isc, ix, iy; - REAL prec, base; - int icx, j; - int ibit, nop; - int swap; - char *p, *p1; - - /* Determine if data needs byte swapping */ - - p = datain; - - swap = -1; - - ix = get_int16(p + 8, big_endian); - iy = get_int16(p + 10, big_endian); - if (ix*iy == nout) swap = 0; - - if (swap == -1) - { - /* see if data is byte swapped with 4 byte words */ - ix = get_int16(p + 10, little_endian); - iy = get_int16(p + 8, little_endian); - if (ix*iy == nout) swap = 4; - } - - if (swap == -1) - { - /* see if data is byte swapped with 8 byte words */ - ix = get_int16(p + 14, little_endian); - iy = get_int16(p + 12, little_endian); - if (ix*iy == nout) swap = 8; - } - - if (swap == -1) - { - error_mesg("WGDOS data header record mismatch "); - return 1; - } - else if (swap == 4) - { - swap_bytes_sgl(datain, nbytes / 4); - } - else if (swap == 8) - { - swap_bytes_dbl(datain, nbytes / 8); - } - - - /* Below only works for 32 bit integers, therefore there must be a - 32 bit integer type */ - - /* Extract scale factor and number of columns and rows from header */ - isc = get_int32(p + 4, big_endian); - ix = get_int16(p + 8, big_endian); - iy = get_int16(p + 10, big_endian); - - /* Expand compressed data */ - - prec = pow(2.0, (double) isc); - icx = 3; - - for (j=0; j= 128) - { - btzer = TRUE; - btmap = TRUE; - ibit -= 128; - } - - /* check if bitmap used for minimum values */ - - if (ibit >= 64) - { - btmin = TRUE; - btmap = TRUE; - ibit -= 64; - } - - /* check if bitmap used for missing data values */ - - if (ibit >= 32) - { - btmis = TRUE; - btmap = TRUE; - ibit -= 32; - } - - if (ibit > 32) - { - error_mesg("Number of bits used to pack wgdos data = %d must be <= 32 ", - ibit); - return 1; - } - - if (btmap) - { - if ( (imap = malloc (ix*sizeof(int))) == NULL ) - { - error_mesg("Error unable to allocate memory for imap in xpnd ix = %d ", - ix); - return 1; - } - - for (j=0; j 0) - jbit--; - else - { - jbit = 31; - jword++; - } - } - } - - /* Extract minimum value bitmap */ - - if (btmin) - { - if ( (imin = malloc (ix*sizeof(int))) == NULL ) - { - error_mesg("Error unable to allocate memory for imin in xpnd ix = %d ", - ix); - return 1; - } - - for (j=0; j 0) - jbit--; - else - { - jbit = 31; - jword++; - } - } - } - - /* Extract zero value bitmap */ - - if (btzer) - { - if ( (izer = malloc (ix*sizeof(int))) == NULL ) - { - error_mesg("Error unable to allocate memory for izer in xpnd ix = %d ", - ix); - return 1; - } - - for (j=0; j 0) - jbit--; - else - { - jbit = 31; - jword++; - } - } - } - - - /* If bitmap used reset pointers to beginning of 32 bit boundary */ - - if (btmap && jbit != 31) - { - jbit = 31; - jword++; - } - - if (ibit > 0) - { - /* Unpack scaled values */ - - for (j=0; j> ibit) & ~(~0 << 1); - - if (i == 1) - return TRUE; - else - return FALSE; -} - -/* - * Move nbits from 32 bit word1 starting at start1 into 32 bit word2. - * 0 =< nbits <= 32, bits can cross into word1+1. - */ - -static void move_bits(void *word1, int start1, int nbits, void *word2) -{ - uint32_t *ui1, *ui2, temp1, temp2; - - ui1 = (uint32_t *) word1; - ui2 = (uint32_t *) word2; - - if (start1+1-nbits >= 0) - { - /* move bits within one word */ - - ui2[0] = (ui1[0] >> (start1+1-nbits)) & ~(~0 << nbits); - } - else - { - /* move bits within two words */ - - temp1 = (ui1[0] << (nbits-start1-1)) & ~(~0 << nbits); - temp2 = (ui1[1] >> (32+start1+1-nbits)) & ~(~0 << (nbits-start1-1)); - ui2[0] = temp1 | temp2; - } -} - - -#define I32_INFP 0x7f800000 -#define I32_INFN 0xff800000 -#define I32_ZEROP 0x00000000 -#define I32_ZERON 0x80000000 - -/* based on ibmr4_to_r4 */ -static float32_t get_float32(void *in) -{ - unsigned char *pin; - unsigned long man; - int exp, sign; - double d; - uint32_t i32; - - pin = (unsigned char *) in; - - sign = pin[0] & 0x80; - exp = pin[0] & 0x7f; - man = ((unsigned long) pin[1] << 16) | - ((unsigned long) pin[2] << 8) | (unsigned long) pin[3]; - - d = ldexp((double) man ,4*(exp-64-6)); - - if (d > (double) FLT_MAX || errno == ERANGE) - { - i32 = (sign ? I32_INFN : I32_INFP); - return *(float32_t *) &i32; - } - else if (d < (double) FLT_MIN) - { - i32 = (sign ? I32_ZERON : I32_ZEROP); - return *(float32_t *) &i32; - } - else - return (sign ? -d : d); -} - - -/* functions to get data values that are stored starting at a specified - * pointer with the specified byte ordering; if they are in native byte - * ordering then just copy them, otherwise byte-swap them - */ -static int16_t get_int16(void *start, Byte_ordering byte_ordering) -{ - if (byte_ordering == NATIVE_ORDERING) - { - return *(int16_t *) start; - } - else - { - char *in, out[2]; - in = (char *) start; - out[0] = in[1]; - out[1] = in[0]; - return *(int16_t *) out; - } -} - -static int32_t get_int32(void *start, Byte_ordering byte_ordering) -{ - if (byte_ordering == NATIVE_ORDERING) - { - return *(int32_t *) start; - } - else - { - char *in, out[4]; - in = (char *) start; - out[0] = in[3]; - out[1] = in[2]; - out[2] = in[1]; - out[3] = in[0]; - return *(int32_t *) out; - } -} - diff --git a/cf/umread_lib/c-lib/umfile.c b/cf/umread_lib/c-lib/umfile.c deleted file mode 100644 index bff514ba81..0000000000 --- a/cf/umread_lib/c-lib/umfile.c +++ /dev/null @@ -1,165 +0,0 @@ -#include -#include - -#include "umfileint.h" - -int get_type_and_num_words(int word_size, - const void *int_hdr, - Data_type *type_rtn, - size_t *num_words_rtn) -{ - errorhandle_init(); - switch (word_size) - { - case 4: - return get_type_and_num_words_core_sgl(int_hdr, type_rtn, num_words_rtn); - case 8: - return get_type_and_num_words_core_dbl(int_hdr, type_rtn, num_words_rtn); - default: - return -1; - } -} - -int get_extra_data_offset_and_length(int word_size, - const void *int_hdr, - size_t data_offset, - size_t disk_length, - size_t *extra_data_offset_rtn, - size_t *extra_data_length_rtn) -{ - errorhandle_init(); - switch (word_size) - { - case 4: - return get_extra_data_offset_and_length_core_sgl(int_hdr, - data_offset, - disk_length, - extra_data_offset_rtn, - extra_data_length_rtn); - case 8: - return get_extra_data_offset_and_length_core_dbl(int_hdr, - data_offset, - disk_length, - extra_data_offset_rtn, - extra_data_length_rtn); - default: - return -1; - } -} - -int detect_file_type(int fd, File_type *file_type) -{ - errorhandle_init(); - return detect_file_type_(fd, file_type); -} - -int read_extra_data(int fd, - size_t extra_data_offset, - size_t extra_data_length, - Byte_ordering byte_ordering, - int word_size, - void *extra_data_return) -{ - errorhandle_init(); - switch (word_size) - { - case 4: - return read_extra_data_core_sgl(fd, - extra_data_offset, - extra_data_length, - byte_ordering, - extra_data_return); - case 8: - return read_extra_data_core_dbl(fd, - extra_data_offset, - extra_data_length, - byte_ordering, - extra_data_return); - default: - return -1; - } -} - -int read_header(int fd, - size_t header_offset, - Byte_ordering byte_ordering, - int word_size, - void *int_hdr_rtn, - void *real_hdr_rtn) -{ - errorhandle_init(); - switch (word_size) - { - case 4: - return read_hdr_at_offset_sgl(fd, header_offset, byte_ordering, - int_hdr_rtn, real_hdr_rtn); - case 8: - return read_hdr_at_offset_dbl(fd, header_offset, byte_ordering, - int_hdr_rtn, real_hdr_rtn); - default: - return -1; - } -} - - -File *file_parse(int fd, - File_type file_type) -{ - File *file; - - errorhandle_init(); - - switch (file_type.word_size) - { - case 4: - CKP( file = file_parse_core_sgl(fd, file_type) ); - break; - case 8: - CKP( file = file_parse_core_dbl(fd, file_type) ); - break; - default: - ERR; - } - return file; - ERRBLKP; -} - - -void file_free(File *file) -{ - errorhandle_init(); - - CKI( free_file(file) ); - return; - - err: - GRIPE; -} - - -int read_record_data(int fd, - size_t data_offset, - size_t disk_length, - Byte_ordering byte_ordering, - int word_size, - const void *int_hdr, - const void *real_hdr, - size_t nwords, - void *data_return) -{ - errorhandle_init(); - - switch(word_size) - { - case 4: - CKI( read_record_data_core_sgl(fd, data_offset, disk_length, byte_ordering, - int_hdr, real_hdr, nwords, data_return) ); - return 0; - case 8: - CKI( read_record_data_core_dbl(fd, data_offset, disk_length, byte_ordering, - int_hdr, real_hdr, nwords, data_return) ); - return 0; - } - /* invalid word size falls through to error return */ - ERRBLKI; -} diff --git a/cf/umread_lib/c-lib/umfile.h b/cf/umread_lib/c-lib/umfile.h deleted file mode 100644 index fdccde8e91..0000000000 --- a/cf/umread_lib/c-lib/umfile.h +++ /dev/null @@ -1,204 +0,0 @@ -/* - * ============================================================== - * Header file only for stuff intended to be called directly from - * python. Everything else should be in umfileint.h. - * ============================================================== - */ - -#include - -typedef enum -{ - plain_pp, - fields_file -} - File_format; - -typedef enum -{ - little_endian, - big_endian -} - Byte_ordering; - -typedef enum -{ - int_type, - real_type -} - Data_type; - - -/* ---------------- - * Placeholders for internal structures to be defined in umfileint.h - * Here just need to predeclare them so that we can have pointers to them. - */ -struct _File; -struct _Var; -struct _Rec; -/* ---------------- */ - -typedef struct -{ - File_format fmt; - Byte_ordering byte_ordering; - int word_size; -} - File_type ; - -typedef struct -{ - void *int_hdr; - void *real_hdr; - size_t header_offset; /* in bytes */ - size_t data_offset; /* in bytes */ - size_t disk_length; /* in bytes */ - struct _Rec *internp; -} - Rec; - -typedef struct -{ - Rec **recs; - int nz; - int nt; - int supervar_index; - struct _Var *internp; -} - Var; - -typedef struct -{ - int fd; - File_type file_type; - int nvars; - Var **vars; - struct _File *internp; -} - File; - -/* ------------------------------------------------------------------- */ - -int detect_file_type(int fd, File_type *file_type_rtn); -/* - Given an open file, detect type of file (caller provides storage for info - returned). if detection was successful, returns 0 and populates the - File_type structure provided by the caller, otherwise returns 1. -*/ - -File *file_parse(int fd, - File_type file_type); -/* - Given an open file handle, parse a file into a File structure, with - embedded Var and Rec structures, relating the PP records to variables - within the file. - - Caller should pass a File_type structure as either returned from - detect_file_type() or populated by the caller. -*/ - -/* commented out - to handle in Python */ -/* void close_fd(File *file); */ -/* int reopen_fd(File *file); */ - -void file_free(File *file); -/* - * Free memory associated with a File structure (including anything hung off - * the internal pointer) and all variables and records underneath it - */ - -/* ------------------------------------------------------------------- */ -/* functions for reading the actual data, not dependent on the above objects - * (although may call common code in the implementation) - */ - -int read_header(int fd, - size_t header_offset, - Byte_ordering byte_ordering, - int word_size, - void *int_hdr_rtn, - void *real_hdr_rtn); -/* - reads a PP header at specified offset; function will do byte-swapping - as necessary, but returned header data will match word size, and - caller must provide storage of appropriate length to contain these - (45 and 19 words respectively) -*/ - - -int get_type_and_length(int word_size, - const void *int_hdr, - Data_type *type_rtn, - size_t *num_words_rtn); -/* - Parses integer PP header, works out number of words and data type. - - Caller provides integer header as array of 4 or 8 byte ints - as appropriate to passed word_size, and provides storage for - returned info. - - Return value is 0 for success, otherwise 1. - */ - -int read_record_data(int fd, - size_t data_offset, - size_t disk_length, - Byte_ordering byte_ordering, - int word_size, - const void *int_hdr, - const void *real_hdr, - size_t nwords, - void *data_return); -/* - Reads record data at specified offset; function will do byte-swapping and - unpacking as necessary. Caller provides PP headers as arrays of 4 or 8 - byte ints and floats/doubles as appropriate to passed word_size (real - header needed for missing data value). This must match actual file word - size, and there will be no casting of data except as appropriate when - unpacking packed fields. - - Returns in data_return an array of int or float at this word size; caller - must provide storage of size nwords words, and nwords must have been - obtained by calling get_nwords(). - - Return value is 0 for success, 1 for failure. -*/ -/* ------------------------------------------------------------------- */ - - -int get_extra_data_offset_and_length(int word_size, - const void *int_hdr, - size_t data_offset, - size_t disk_length, - size_t *extra_data_offset_rtn, - size_t *extra_data_length_rtn); -/* - Parses integer PP header in conjunction with data offset and length - (where the length includes the extra data), to works out the offset - and length of extra data - - Caller provides integer header as array of 4 or 8 byte ints - as appropriate to passed word_size, and provides storage for - returned info. - - Return value is length, or -1 on failure - */ - -int read_extra_data(int fd, - size_t extra_data_offset, - size_t extra_data_length, - Byte_ordering byte_ordering, - int word_size, - void *extra_data_return); - -/* - Reads extra data at specified offset; function will do byte-swapping - as necessary. - - Returns raw data (aside from the byte swapping) in extra_data_return; - caller must provide storage of size extra_data_length * word_size bytes, - and extra_data_length must have been obtained by calling - get_extra_data_length(). - - Return value is 0 for success, 1 for failure. -*/ diff --git a/cf/umread_lib/c-lib/umfileint.h b/cf/umread_lib/c-lib/umfileint.h deleted file mode 100644 index 7b0502d8ca..0000000000 --- a/cf/umread_lib/c-lib/umfileint.h +++ /dev/null @@ -1,25 +0,0 @@ -#include "umfile.h" - -/*---------------------------*/ - -#include "bits/constants.h" -#include "bits/datatype.h" -#include "bits/ordering.h" -#include "bits/typedefs.h" -#include "bits/type_indep_protos.h" -#include "bits/pp_header.h" -#include "bits/err_macros.h" - -/* ----------------------------------------------------------- */ - -#if defined(SINGLE) || defined(DOUBLE) -#include "bits/type_dep_redefs.h" -#include "bits/type_dep_protos.h" -#else -#define WITH_LEN(x) x ## _sgl -#include "bits/type_dep_entry_protos.h" -#undef WITH_LEN -#define WITH_LEN(x) x ## _dbl -#include "bits/type_dep_entry_protos.h" -#undef WITH_LEN -#endif diff --git a/cf/umread_lib/cInterface.py b/cf/umread_lib/cInterface.py deleted file mode 100644 index cbe4a2409d..0000000000 --- a/cf/umread_lib/cInterface.py +++ /dev/null @@ -1,656 +0,0 @@ -import ctypes as CT -import os - -import numpy -import numpy.ctypeslib - -from . import umfile - -_len_real_hdr = 19 -_len_int_hdr = 45 - - -class File_type(CT.Structure): - _fields_ = [ - ("fmt", CT.c_int), - ("byte_ordering", CT.c_int), - ("word_size", CT.c_int), - ] - - -def _get_ctypes_array(dtype, size=None): - """Get ctypes corresponding to a numpy array of a given type. - - The size should not be necessary unless the storage for the array is - allocated in the C code. - - """ - kwargs = { - "dtype": dtype, - "ndim": 1, - "flags": ("C_CONTIGUOUS", "WRITEABLE"), - } - if size: - kwargs["shape"] = (size,) - - return numpy.ctypeslib.ndpointer(**kwargs) - - -def _gen_rec_class(int_type, float_type): - class Rec(CT.Structure): - """ctypes object corresponding to the `Rec` object in the C - code.""" - - _fields_ = [ - ("int_hdr", _get_ctypes_array(int_type, _len_int_hdr)), - ("real_hdr", _get_ctypes_array(float_type, _len_real_hdr)), - ("header_offset", CT.c_size_t), - ("data_offset", CT.c_size_t), - ("disk_length", CT.c_size_t), - ("_internp", CT.c_void_p), - ] - - return Rec - - -Rec32 = _gen_rec_class(numpy.int32, numpy.float32) -Rec64 = _gen_rec_class(numpy.int64, numpy.float64) - - -def _gen_var_class(rec_class): - class Var(CT.Structure): - """ctypes object corresponding to the `Var` object in the C - code.""" - - _fields_ = [ - ("recs", CT.POINTER(CT.POINTER(rec_class))), - ("nz", CT.c_int), - ("nt", CT.c_int), - ("supervar_index", CT.c_int), - ("_internp", CT.c_void_p), - ] - - return Var - - -Var32 = _gen_var_class(Rec32) -Var64 = _gen_var_class(Rec64) - - -def _gen_file_class(var_class): - class File(CT.Structure): - """ctypes object corresponding to the `File` object in the C - code.""" - - _fields_ = [ - ("fd", CT.c_int), - ("file_type", File_type), - ("nvars", CT.c_int), - ("vars", CT.POINTER(CT.POINTER(var_class))), - ("_internp", CT.c_void_p), - ] - - return File - - -File32 = _gen_file_class(Var32) -File64 = _gen_file_class(Var64) - - -class Enum: - def __init__(self, *names): - self.names = names - - def as_name(self, val): - if isinstance(val, str): - return val - else: - return self.names[val] - - def as_index(self, val): - if isinstance(val, int): - return val - - return self.names.index(val) - - -enum_file_format = Enum("PP", "FF") -enum_byte_ordering = Enum("little_endian", "big_endian") -enum_data_type = Enum("integer", "real") - - -class CInterface: - """Interface to the C shared library functions.""" - - def __init__(self, lib_name="umfile.so"): - """**Initialisation** - - :Parameters: - - lib_name: `str` - The name of the C library binary. - - """ - lib_dir = os.path.join(os.path.dirname(__file__) or ".", "c-lib") - lib_path = os.path.join(lib_dir, lib_name) - self.lib = CT.CDLL(lib_path) - - def _is_null_pointer(self, ptr): - """True if the pointer is a null pointer. - - :Returns: - - `bool` - - """ - try: - ptr.contents - return False - except ValueError: - return True - - def detect_file_type(self, fd): - """Auto-detect file type. - - :Parameters: - - fd: `int` - The file descriptor of the open file. - - :Returns: - - `File_type` - A `File_type` ctypes object that can be passed to - `file_parse`, or raises an exception if file type cannot - be detected. - - """ - file_type = File_type() - rv = self.lib.detect_file_type(fd, CT.pointer(file_type)) - if rv != 0: - raise umfile.UMFileException("File type could not be detected") - - return file_type - - def file_type_obj_to_dict(self, file_type): - """Converts a `File_type` object returned by `detect_file_type` into a - dictionary that includes meaningful string values in place of the - integers that derive from the C enum statments, specifically: - 'fmt': 'PP' or 'FF' 'byte_ordering': 'little_endian' or - 'big_endian' and also 'word_size': 4 or 8 - - :Returns: - - `dict` - - """ - fmt = enum_file_format.as_name(file_type.fmt) - byte_ordering = enum_byte_ordering.as_name(file_type.byte_ordering) - word_size = file_type.word_size - return { - "fmt": fmt, - "byte_ordering": byte_ordering, - "word_size": word_size, - } - - def create_file_type(self, fmt, byte_ordering, word_size): - """Creates a `File_type` object for passing to `file_parse`. - - :Parameters: - - fmt: `str` - 'PP' or 'FF' - - byte_ordering: `str` - 'little_endian' or 'big_endian' - - word_size: `str` - 4 or 8 - - :Returns: - - `File_type` - A `File_type` object (ctypes structure containing integer - values) that can be passed to `file_parse`. - - """ - return File_type( - fmt=enum_file_format.as_index(fmt), - byte_ordering=enum_byte_ordering.as_index(byte_ordering), - word_size=word_size, - ) - - def set_word_size(self, val): - """Sets the word size used to interpret returned pointers from - subsequent calls, in particular the pointers to PP headers - embedded in the tree of objects returned by `file_parse` and the - data array that is populated by `read_record_data`. - - :Parameters: - - val: `int` or `File_type` - Either just the word_size value to use or a `File_type` - object from which it is to be extracted. - - """ - if isinstance(val, File_type): - word_size = val.word_size - else: - word_size = val - - if word_size == 4: - self.file_class = File32 - self.file_data_int_type = numpy.int32 - self.file_data_real_type = numpy.float32 - self._int_ptr = CT.POINTER(CT.c_int32) - self._real_ptr = CT.POINTER(CT.c_float) - elif word_size == 8: - self.file_class = File64 - self.file_data_int_type = numpy.int64 - self.file_data_real_type = numpy.float64 - self._int_ptr = CT.POINTER(CT.c_int64) - self._real_ptr = CT.POINTER(CT.c_double) - else: - raise ValueError(f"Word size must be 4 or 8 (not {word_size!r})") - - def _get_ctypes_int_array(self, size=None): - """Get ctypes corresponding to the `numpy` integer array.""" - return _get_ctypes_array(self.file_data_int_type, size) - - def _get_ctypes_real_array(self, size=None): - """Get ctypes corresponding to the `numpy` real array.""" - return _get_ctypes_array(self.file_data_real_type, size) - - def _get_empty_real_array(self, size): - """Get empty `numpy` real array according to word size - previously set with `set_word_size`.""" - return numpy.empty(size, dtype=self.file_data_real_type) - - def _get_empty_int_array(self, size): - """Get empty `numpy` integer array according to word size - previously set with `set_word_size`.""" - return numpy.empty(size, dtype=self.file_data_int_type) - - def parse_file(self, fh, file_type): - """Given an open file handle, work out information from the - file. - - :Parameters: - - fh: `int` - Low-level file handle. - - file_type: `File_type` - `File_type` object as returned by `detect_file_type` or - `create_file_type`. - - :Returns: - - `dict` - The information from the file. Currently the only key - actually implemented is ``'vars'``, containing a list of - variables, as that is all that the caller requires. - - """ - func = self.lib.file_parse - file_p_type = CT.POINTER(self.file_class) - func.restype = file_p_type - - file_p = func(fh, file_type) - if self._is_null_pointer(file_p): - raise umfile.UMFileException("File parsing failed") - - file = file_p.contents - c_vars = file.vars[: file.nvars] - rv = {"vars": list(map(self.c_var_to_py_var, c_vars))} - - # Now that we have copied all the data into python objects for - # the caller, free any memory allocated in the C code before - # returning - free_func = self.lib.file_free - free_func._fields_ = file_p_type - free_func(file_p) - - return rv - - def c_var_to_py_var(self, c_var_p): - """Create a `umfile.Var` object from a ctypes object - corresponding to 'Var*' in the C code. - - :Returns: - - `umfile.Var` - - """ - c_var = c_var_p.contents - nz = c_var.nz - nt = c_var.nt - svi = c_var.supervar_index - if svi < 0: - svi = None - - c_recs = c_var.recs - recs = [ - self.c_rec_to_py_rec(c_recs[recid]) for recid in range(nz * nt) - ] - - return umfile.Var(recs, nz, nt, svi) - - def c_rec_to_py_rec(self, c_rec_p): - """Create a `umfile.Rec` object from a ctypes object - corresponding to 'Rec*' in the C code. - - :Returns: - - `umfile.Rec` - - """ - c_rec = c_rec_p.contents - - # ============================================================ - # Previous code - causing memory leaks per - # https://github.com/numpy/numpy/issues/6511 - # - # # numpy.copy used here so we can go back and free the memory - # # allocated by C without affecting the python object - # int_hdr = numpy.copy(numpy.ctypeslib.as_array(c_rec.int_hdr)) - # real_hdr = numpy.copy(numpy.ctypeslib.as_array(c_rec.real_hdr)) - # ============================================================ - - # - # ------------------------------- - # Workaround: instead cast to a pointer, obtain the values knowing - # the length of the header arrays, and copy into an appropriate - # numpy array - # - ptr = CT.cast(c_rec.int_hdr, self._int_ptr) - int_hdr = numpy.array( - ptr[:_len_int_hdr], dtype=self.file_data_int_type - ) - - ptr = CT.cast(c_rec.real_hdr, self._real_ptr) - real_hdr = numpy.array( - ptr[:_len_real_hdr], dtype=self.file_data_real_type - ) - # ============================================================ - - header_offset = c_rec.header_offset - data_offset = c_rec.data_offset - disk_length = c_rec.disk_length - return umfile.Rec( - int_hdr, real_hdr, header_offset, data_offset, disk_length - ) - - def get_type_and_num_words(self, int_hdr): - """From the integer header, work out data type and number of - words to read (`read_record_data` requires this). - - :Returns: - - `str`, `int` - The datatype ('integer' or 'real') and the number of words. - - """ - word_size = int_hdr.itemsize - self.lib.get_type_and_num_words.argtypes = [ - CT.c_int, - self._get_ctypes_int_array(), - CT.POINTER(CT.c_int), - CT.POINTER(CT.c_size_t), - ] - data_type = CT.c_int() - num_words = CT.c_size_t() - rv = self.lib.get_type_and_num_words( - word_size, int_hdr, CT.pointer(data_type), CT.pointer(num_words) - ) - if rv != 0: - raise umfile.UMFileException( - "Error determining data type and size from integer header" - ) - - return enum_data_type.as_name(data_type.value), num_words.value - - def get_extra_data_offset_and_length( - self, int_hdr, data_offset, disk_length - ): - """From the integer header, gets offset and length of extra - data. - - :Returns: - - `int`, `int` - The offset and length, both in units of BYTES. - - """ - word_size = int_hdr.itemsize - func = self.lib.get_extra_data_offset_and_length - func.argtypes = [ - CT.c_int, - self._get_ctypes_int_array(), - CT.c_size_t, - CT.c_size_t, - CT.POINTER(CT.c_size_t), - CT.POINTER(CT.c_size_t), - ] - extra_data_offset = CT.c_size_t() - extra_data_length = CT.c_size_t() - rv = func( - word_size, - int_hdr, - data_offset, - disk_length, - CT.pointer(extra_data_offset), - CT.pointer(extra_data_length), - ) - if rv != 0: - raise umfile.UMFileException( - "Error determining extra data length from integer header" - ) - - return extra_data_offset.value, extra_data_length.value - - def read_header(self, fd, header_offset, byte_ordering, word_size): - """Reads the header from open file. - - :Returns: - - `numpy.ndarray`, `numpy.ndarray` - The integer and real parts of the header. - - """ - self.lib.read_header.argtypes = [ - CT.c_int, - CT.c_size_t, - CT.c_int, - CT.c_int, - self._get_ctypes_int_array(), - self._get_ctypes_real_array(), - ] - - int_hdr = self._get_empty_int_array(_len_int_hdr) - real_hdr = self._get_empty_real_array(_len_real_hdr) - rv = self.lib.read_header( - fd, - header_offset, - enum_byte_ordering.as_index(byte_ordering), - word_size, - int_hdr, - real_hdr, - ) - if rv != 0: - raise umfile.UMFileException("Error reading header data") - - return int_hdr, real_hdr - - def read_extra_data( - self, - fd, - extra_data_offset, - extra_data_length, - byte_ordering, - word_size, - ): - """Reads record data from open file. - - inputs: - fd - integer low-level file descriptor - extra_data_offset - offset in bytes - extra_disk_length - disk length of extra data in bytes - byte_ordering - 'little_endian' or 'big_endian' - word_size - 4 or 8 - - returns: extra data as string - - """ - extra_data = b"\0" * extra_data_length - - self.lib.read_extra_data.argtypes = [ - CT.c_int, - CT.c_size_t, - CT.c_size_t, - CT.c_int, - CT.c_int, - CT.c_char_p, - ] - - rv = self.lib.read_extra_data( - fd, - extra_data_offset, - extra_data_length, - enum_byte_ordering.as_index(byte_ordering), - word_size, - extra_data, - ) - if rv != 0: - raise umfile.UMFileException("Error reading extra data") - - return extra_data - - def read_record_data( - self, - fd, - data_offset, - disk_length, - byte_ordering, - word_size, - int_hdr, - real_hdr, - data_type, - nwords, - ): - """Reads record data from open file. - - inputs: - fd - integer low-level file descriptor - data_offset - offset in words - disk_length - disk length of data record in words - byte_ordering - 'little_endian' or 'big_endian' - word_size - 4 or 8 - int_hdr - integer PP headers (numpy array) - real_hdr - real PP headers (numpy array) - data_type - 'integer' or 'real' - nwords - number of words to read - type and nwords should have been returned by - get_type_and_num_words() - - """ - if data_type == "integer": - data = self._get_empty_int_array(nwords) - ctypes_data = self._get_ctypes_int_array() - elif data_type == "real": - data = self._get_empty_real_array(nwords) - ctypes_data = self._get_ctypes_real_array() - else: - raise ValueError("data_type must be 'integer' or 'real'") - - self.lib.read_record_data.argtypes = [ - CT.c_int, - CT.c_size_t, - CT.c_size_t, - CT.c_int, - CT.c_int, - self._get_ctypes_int_array(), - self._get_ctypes_real_array(), - CT.c_size_t, - ctypes_data, - ] - - rv = self.lib.read_record_data( - fd, - data_offset, - disk_length, - enum_byte_ordering.as_index(byte_ordering), - word_size, - int_hdr, - real_hdr, - nwords, - data, - ) - - if rv != 0: - raise umfile.UMFileException("Error reading record data") - - return data - - -if __name__ == "__main__": - import sys - - c = CInterface() - fd = os.open(sys.argv[1], os.O_RDONLY) - file_type = c.detect_file_type(fd) - print(c.file_type_obj_to_dict(file_type)) - c.set_word_size(file_type) - info = c.parse_file(fd, file_type) - - for var in info["vars"]: - print("nz = %s, nt = %s" % (var.nz, var.nt)) - for rec in var.recs: - print(rec.hdr_offset) - print("data offset", rec.data_offset) - print("disk length", rec.disk_length) - print("int hdr", rec.int_hdr) - print("real hdr", rec.real_hdr) - data_type, nwords = c.get_type_and_num_words(rec.int_hdr) - print("data_type = %s nwords = %s" % (data_type, nwords)) - - data = c.read_record_data( - fd, - rec.data_offset, - rec.disk_length, - file_type.byte_ordering, - file_type.word_size, - rec.int_hdr, - rec.real_hdr, - data_type, - nwords, - ) - print( - "data (%s values): %s ... %s" - % (len(data), data[:10], data[-10:]) - ) - ( - extra_data_offset, - extra_data_length, - ) = c.get_extra_data_offset_and_length( - rec.int_hdr, rec.data_offset, rec.disk_length - ) - print("extra data offset: %s" % extra_data_offset) - print("extra data length: %s" % extra_data_length) - extra_data = c.read_extra_data( - fd, - extra_data_offset, - extra_data_length, - file_type.byte_ordering, - file_type.word_size, - ) - print("extra data (%s bytes) read" % (len(extra_data))) - - print( - c.read_header( - fd, - info["vars"][0].recs[0].hdr_offset, - file_type.byte_ordering, - file_type.word_size, - ) - ) diff --git a/cf/umread_lib/extraData.py b/cf/umread_lib/extraData.py deleted file mode 100644 index 6559bde36c..0000000000 --- a/cf/umread_lib/extraData.py +++ /dev/null @@ -1,171 +0,0 @@ -import sys - -import numpy as np - - -def cmp(a, b): - """Workaround to get a Python-2-like `cmp` function in Python 3.""" - return (a > b) - (a < b) - - -_codes = { - 1: ("x", float), - 2: ("y", float), - 3: ("y_domain_lower_bound", float), - 4: ("x_domain_lower_bound", float), - 5: ("y_domain_upper_bound", float), - 6: ("x_domain_upper_bound", float), - 7: ("z_domain_lower_bound", float), - 8: ("z_domain_upper_bound", float), - 10: ("title", str), - 11: ("domain_title", str), - 12: ("x_lower_bound", float), - 13: ("x_upper_bound", float), - 14: ("y_lower_bound", float), - 15: ("y_upper_bound", float), -} - - -class ExtraData(dict): - """Extends dictionary class with a comparison method between extra - data for different records.""" - - _key_to_type = dict([(key, typ) for key, typ in _codes.values()]) - - def sorted_keys(self): - k = self.keys() - k.sort() - return k - - _tolerances = {np.dtype(np.float32): 1e-5, np.dtype(np.float64): 1e-13} - - def _cmp_floats(self, a, b, tolerance): - if a == b: - return 0 - - delta = abs(b * tolerance) - if a < b - delta: - return -1 - - if a > b + delta: - return 1 - - return 0 - - def _cmp_float_arrays(self, avals, bvals): - n = len(avals) - c = cmp(n, len(bvals)) - if c != 0: - return c - - tolerance = self._tolerances[avals.dtype] - for i in range(n): - c = self._cmp_floats(avals[i], bvals[i], tolerance) - if c != 0: - return c - - return 0 - - def __cmp__(self, other): - """Compare two extra data dictionaries returned by unpacker.""" - if other is None: - return 1 - ka = self.sorted_keys() - kb = other.sorted_keys() - c = cmp(ka, kb) - if c != 0: - return c - - for key in ka: - valsa = self[key] - valsb = other[key] - typ = self._key_to_type[key] - if typ == float: - c = self._cmp_float_arrays(valsa, valsb) - elif type == str: - c = cmp(valsa, valsb) - else: - assert False - - if c != 0: - return c - - return 0 - - -class ExtraDataUnpacker: - _int_types = {4: np.int32, 8: np.int64} - _float_types = {4: np.float32, 8: np.float64} - - def __init__(self, raw_extra_data, word_size, byte_ordering): - self.rdata = raw_extra_data - self.ws = word_size - self.itype = self._int_types[word_size] - self.ftype = self._float_types[word_size] - # byte_ordering is 'little_endian' or 'big_endian' - # sys.byteorder is 'little' or 'big' - self.is_swapped = not byte_ordering.startswith(sys.byteorder) - - def next_words(self, n): - """return next n words as raw data string, and pop them off the - front of the string.""" - pos = n * self.ws - rv = self.rdata[:pos] - assert len(rv) == pos - self.rdata = self.rdata[pos:] - return rv - - def convert_bytes_to_string(self, st): - """Convert bytes to string. - - :Returns: - - `str` - - """ - if self.is_swapped: - indices = slice(None, None, -1) - else: - indices = slice(None) - - st = "".join( - [ - st[pos : pos + self.ws][indices].decode("utf-8") - for pos in range(0, len(st), self.ws) - ] - ) - - while st.endswith("\x00"): - st = st[:-1] - - return st - - def get_data(self): - """Get the (key, value) pairs for extra data. - - :Returns: - - `ExtraData` - - """ - d = {} - while self.rdata: - i = np.frombuffer(self.next_words(1), self.itype)[0] - if i == 0: - break - - ia, ib = divmod(i, 1000) - key, etype = _codes[ib] - - rawvals = self.next_words(ia) - if etype == float: - vals = np.frombuffer(rawvals, self.ftype) - elif etype == str: - vals = np.array([self.convert_bytes_to_string(rawvals)]) - - if key not in d: - d[key] = vals - else: - d[key] = np.append(d[key], vals) - - return ExtraData(d) diff --git a/cf/umread_lib/umfile.py b/cf/umread_lib/umfile.py deleted file mode 100644 index 7183653e64..0000000000 --- a/cf/umread_lib/umfile.py +++ /dev/null @@ -1,517 +0,0 @@ -import os -from functools import cmp_to_key - -import numpy -from cfdm.read_write.exceptions import DatasetTypeError - -from . import cInterface -from .extraData import ExtraDataUnpacker - - -class UMFileException(Exception): - pass - - -# Lookup header pointers -LBLREC = 14 # Length of data record (including any extra data) -LBPACK = 20 # Packing method indicator -LBEGIN = 28 # Disk address/Start Record - - -class File: - """A class for a UM file that gives a view of the file including - sets of PP records combined into variables.""" - - def __init__( - self, path, byte_ordering=None, word_size=None, fmt=None, parse=True - ): - """Open and parse a UM file. - - The optional *byte_ordering*, *word_size* and *fmt* arguments - specify the file type. If all three are set, then this forces the - file type; otherwise, the file type is autodetected and any of - them that are set are ignored. - - :Parameters: - - path: `str` - The name of the UM file. - - byte_ordering: `str`, optional - 'little_endian' or 'big_endian' - - word_size: `int`, optional - The size in bytes of one word. Either ``4`` or ``8``. - - fmt: `str`, optional - 'FF' or 'PP' - - parse: `bool`, optional - The default action is to open the file, store the file - type from the arguments or autodetection as described - above, and then parse the contents, giving a tree of - variables and records under the `File` object. However, if - *parse* is False, then an object is returned in which the - last step is omitted, so only the file type is stored, and - there are no variables under it. Such an object can be - passed when instantiating Rec objects, and contains - sufficient info about the file type to ensure that the - `get_data` method of those `Rec` objects will work. - - """ - c = cInterface.CInterface() - self._c_interface = c - - self.path = path - self.fd = None - self.open_fd() - - if byte_ordering and word_size and fmt: - self.fmt = fmt - self.byte_ordering = byte_ordering - self.word_size = word_size - else: - self._detect_file_type() - - self.path = path - file_type_obj = c.create_file_type( - self.fmt, self.byte_ordering, self.word_size - ) - - # Set the word size used to interpret file pointers - c.set_word_size(file_type_obj) - - if parse: - # -------------------------------------------------------- - # Work out information from the file and store it in the - # `vars` attribute. - # - # Note that the word size used to interpret file pointers - # needs to have been previously set. - # -------------------------------------------------------- - info = c.parse_file(self.fd, file_type_obj) - self.vars = info["vars"] - self._add_back_refs() - - def open_fd(self): - """(Re)open the low-level file descriptor. - - :Returns: - - `int` - The file descriptor. - - """ - if self.fd is None: - self.fd = os.open(self.path, os.O_RDONLY) - - return self.fd - - def close_fd(self): - """Close the low-level file descriptor. - - :Returns: - - `None` - - """ - if self.fd: - os.close(self.fd) - - self.fd = None - - def _detect_file_type(self): - """Store string values describing the auto-detected file type. - - :Returns: - - `None` - - """ - c = self._c_interface - try: - file_type_obj = c.detect_file_type(self.fd) - except Exception: - self.close_fd() - raise DatasetTypeError( - f"Can't open {self.path} as a PP or UM dataset" - ) - - d = c.file_type_obj_to_dict(file_type_obj) - self.fmt = d["fmt"] - self.byte_ordering = d["byte_ordering"] - self.word_size = d["word_size"] - - def _add_back_refs(self): - """Add file attribute to `Var` objects, and both `!file` and - `!var` attributes to `Rec` objects. - - The important one is the file attribute in the `Rec` object, as - this is used when reading data. The others are provided for extra - convenience. - - :Returns: - - `None` - - """ - for var in self.vars: - var.file = self - for rec in var.recs: - rec.var = var - rec.file = self - - -class Var: - """Container for some information about variables.""" - - def __init__(self, recs, nz, nt, supervar_index=None): - self.recs = recs - self.nz = nz - self.nt = nt - self.supervar_index = supervar_index - - @staticmethod - def _compare(x, y): - """Method equivalent to the Python 2 'cmp'. - - Note that (x > y) - (x < y) is equivalent but not as performant - since it would not short-circuit. - - :Returns: - - `int` - - """ - if x == y: - return 0 - elif x > y: - return 1 - else: - return -1 - - def _compare_recs_by_extra_data(self, a, b): - """Compare records with respect to their extra data. - - :Returns: - - `int` - - """ - return self._compare(a.get_extra_data(), b.get_extra_data()) - - def _compare_recs_by_orig_order(self, a, b): - """Compare records with respect to their original order. - - :Returns: - - `int` - - """ - return self._compare(self.recs.index(a), self.recs.index(b)) - - def group_records_by_extra_data(self): - """Group records by matching extra data. - - Returns a list of (sub)lists of records where each record - within each sublist has matching extra data (if - any). Therefore, if the whole variable has consistent extra - data then the returned value will be a list of length 1. - - Within each group, the ordering of returned records is the - same as in the `!recs` attribute. - - :Returns: - - `list` - - """ - compare = self._compare_recs_by_extra_data - recs = self.recs[:] - n = len(recs) - if n == 0: - # shouldn't have a var without records, but... - return [] - - # recs.sort(compare) #python2 - recs.sort(key=cmp_to_key(compare)) - - # optimise simple case - if two ends of a sorted list match, - # the whole list matches - if not compare(recs[0], recs[-1]): - return [self.recs[:]] - - groups = [] - this_grp = [] - for i, rec in enumerate(recs): - this_grp.append(rec) - if i == n - 1 or compare(rec, recs[i + 1]): - this_grp.sort(key=self._compare_recs_by_orig_order) - groups.append(this_grp) - this_grp = [] - - return groups - - -class Rec: - """Container for some information about records.""" - - def __init__( - self, - int_hdr, - real_hdr, - hdr_offset, - data_offset, - disk_length, - file=None, - ): - """Default instantiation, which stores the supplied headers and - offsets. - - :Parameters: - - file: `File`, optional - Used to set the `!file` attribute. Does not need to be - supplied, but if it is not then it will have to be set on - the returned `Rec` object before calling `get_data` will - work. If set it should be set to the `File` object that - contains the returned `Rec` object. Normally this would be - done by the calling code instantiating via `File` rather - than directly. - - """ - self.int_hdr = int_hdr - self.real_hdr = real_hdr - self.hdr_offset = hdr_offset - self.data_offset = data_offset - self.disk_length = disk_length - self._extra_data = None - if file: - self.file = file - - @classmethod - def from_file_and_offsets( - cls, file, hdr_offset, data_offset=None, disk_length=None - ): - """Instantiate a `Rec` object from the `File` object and the - header and data offsets. - - The lookup header is read from disk immediately, and the - returned record object is ready for calling `get_data`. - - :Parameters: - - file: `File` - A view of a file including sets of PP records combined - into variables. - - hdr_offset: `int` - The file start address of the header, in bytes. - - data_offset: `int`, optional - The file start address of the data, in bytes. If - `None`, the default, then the data offset will be - calculated from the integer header. - - disk_length: `int` - The length in bytes of the data in the file. If - `None`, the default, then the disk length will be - calculated from the integer header. - - :Returns: - - `Rec` - - """ - c = file._c_interface - word_size = file.word_size - int_hdr, real_hdr = c.read_header( - file.fd, hdr_offset, file.byte_ordering, word_size - ) - - if data_offset is None: - # Calculate the data offset from the integer header - if file.fmt == "PP": - # We only support 64-word headers, so the data starts - # 66 words after the header_offset, i.e. after 64 - # words of the header, plus 2 block control words. - data_offset = hdr_offset + 66 * word_size - else: - # Fields file - data_offset = int_hdr[LBEGIN] * word_size - - if disk_length is None: - # Calculate the disk length from the integer header - disk_length = int_hdr[LBLREC] - if int_hdr[LBPACK] % 10 == 2: - # Cray 32-bit packing - disk_length = disk_length * 4 - else: - disk_length = disk_length * word_size - - return cls( - int_hdr, - real_hdr, - hdr_offset, - data_offset, - disk_length, - file=file, - ) - - def read_extra_data(self): - """Read the extra data associated with the record. - - :Returns: - - `numpy.ndarray` - - """ - file = self.file - c = file._c_interface - - ( - extra_data_offset, - extra_data_length, - ) = c.get_extra_data_offset_and_length( - self.int_hdr, self.data_offset, self.disk_length - ) - - raw_extra_data = c.read_extra_data( - file.fd, - extra_data_offset, - extra_data_length, - file.byte_ordering, - file.word_size, - ) - - edu = ExtraDataUnpacker( - raw_extra_data, file.word_size, file.byte_ordering - ) - - return edu.get_data() - - def get_extra_data(self): - """Get extra data associated with the record. - - This is done either by reading or using cached read. - - :Returns: - - `numpy.ndarray` - - """ - if self._extra_data is None: - self._extra_data = self.read_extra_data() - - return self._extra_data - - def get_type_and_num_words(self): - """Get the data type and number of words. - - :Returns: - - `numpy.dtype`, `int` - - """ - c = self.file._c_interface - ntype, num_words = c.get_type_and_num_words(self.int_hdr) - if ntype == "integer": - dtype = numpy.dtype(c.file_data_int_type) - elif ntype == "real": - dtype = numpy.dtype(c.file_data_real_type) - - return dtype, num_words - - def get_data(self): - """Get the data array associated with the record. - - :Returns: - - `numpy.ndarray` - - """ - file = self.file - c = file._c_interface - int_hdr = self.int_hdr - data_type, nwords = c.get_type_and_num_words(int_hdr) - - return c.read_record_data( - file.fd, - self.data_offset, - self.disk_length, - file.byte_ordering, - file.word_size, - int_hdr, - self.real_hdr, - data_type, - nwords, - ) - - -if __name__ == "__main__": - import sys - - path = sys.argv[1] - f = File(path) - print(f.fmt, f.byte_ordering, f.word_size) - print("num variables: %s" % len(f.vars)) - for varno, var in enumerate(f.vars): - print() - print("var %s: nz = %s, nt = %s" % (varno, var.nz, var.nt)) - for recno, rec in enumerate(var.recs): - print("var %s record %s" % (varno, recno)) - print("hdr offset: %s" % rec.hdr_offset) - print("data offset: %s" % rec.data_offset) - print("disk length: %s" % rec.disk_length) - print("int hdr: %s" % rec.int_hdr) - print("real hdr: %s" % rec.real_hdr) - print("data: %s" % rec.get_data()) - print("extra_data: %s" % rec.get_extra_data()) - print("type %s, num words: %s" % rec.get_type_and_num_words()) - # if recno == 1: - # rec._extra_data['y'] += .01 - # print("massaged_extra_data: %s" % rec.get_extra_data()) - print("-----------------------") - - print("all records", var.recs) - print( - "records grouped by extra data ", var.group_records_by_extra_data() - ) - print("===============================") - - f.close_fd() - - # also read a record using saved metadata - if f.vars: - fmt = f.fmt - byte_ordering = f.byte_ordering - word_size = f.word_size - myrec = f.vars[0].recs[0] - hdr_offset = myrec.hdr_offset - data_offset = myrec.data_offset - disk_length = myrec.disk_length - - del f - - fnew = File( - path, - fmt=fmt, - byte_ordering=byte_ordering, - word_size=word_size, - parse=False, - ) - - rnew = Rec.from_file_and_offsets( - fnew, hdr_offset, data_offset, disk_length - ) - print("record read using saved file type and offsets:") - print("int hdr: %s" % rnew.int_hdr) - print("real hdr: %s" % rnew.real_hdr) - print("data: %s" % rnew.get_data()) - print("extra data: %s" % rnew.get_extra_data()) - print("nx = %s" % rnew.int_hdr[18]) - print("ny = %s" % rnew.int_hdr[17]) - rdata = open("recdata0.txt", "w") - for value in rnew.get_data(): - rdata.write("%s\n" % value) - rdata.close() diff --git a/setup.py b/setup.py index 4848db62e2..66e790cb82 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,6 @@ import fnmatch import os import re -import subprocess -from distutils.command.build import build from setuptools import find_packages, setup @@ -48,64 +46,9 @@ def _get_version(): version = _get_version() packages = ["cf"] etc_files = [f for f in find_package_data_files("cf/etc")] -umread_files = [f for f in find_package_data_files("cf/umread_lib/c-lib")] test_files = [f for f in find_test_files()] -package_data = etc_files + umread_files + test_files - - -class build_umread(build): - """Adpated from https://github.com/Turbo87/py- - xcsoar/blob/master/setup.py.""" - - def run(self): - # Run original build code - build.run(self) - - # Build umread - print("Running build_umread") - - build_dir = os.path.join( - os.path.abspath(self.build_lib), "cf/umread_lib/c-lib" - ) - - cmd = ["make", "-C", build_dir] - - def compile(): - print("*" * 80) - print("Running:", " ".join(cmd), "\n") - - try: - rc = subprocess.call(cmd) - except Exception as error: - print(error) - rc = 40 - - print("\n", "-" * 80) - if not rc: - print("SUCCESSFULLY built UM read C library") - else: - print("WARNING: Failed to build the UM read C library.") - print( - " Attempting to read UKMO PP and UM format files " - "will result in failure." - ) - print( - " This will not affect any other cf functionality." - ) - print( - " In particular, netCDF file processing is " - "unaffected." - ) - - print("-" * 80) - print("\n", "*" * 80) - print() - print("cf build successful") - print() - - self.execute(compile, [], "compiling umread") - +package_data = etc_files + test_files long_description = """ CF Python @@ -324,6 +267,4 @@ def compile(): # 'udunits2==2.2.25', # ], # - # https://docs.python.org/2/distutils/apiref.html: - cmdclass={"build": build_umread}, ) From 885ade50b814dbb4e95e5e4e8d56c1cd89a0862e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Mon, 3 Aug 2026 13:41:42 +0100 Subject: [PATCH 25/43] dev --- cf/mixin/fielddomain.py | 15 +++++++++------ cf/test/test_functions.py | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 8ea6e63c0f..546f01e33a 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3931,13 +3931,17 @@ def to_xarray(self, group=True): {{cf_xarray description}} Note that ``ds = f.to_xarray()`` is identical to ``ds = - cf.write(f, fmt='XARRAY')``; and multiple {{class_lower}}s may - be written to the same `xarray` dataset with - `cf.{{class}}List.to_xarray`, or with `cf.write` (e.g. ``ds = - cf.write([f, g], fmt='XARRAY')``). Also, `cf.write` allows a - mixture of fields and domains to be written to the + {{package}}.write(f, fmt='XARRAY')``; and multiple + {{class_lower}}s may be written to the same `xarray` dataset + with `{{package}}.write` (e.g. ``ds = {{package}}.write([f, + g], fmt='XARRAY')``). Also, `{{package}}.write` allows a + mixture a mixture of fields and domains to be written to the same `xarray` dataset. + An `xarray` dataset can be converted to one or more fields + with ``f = {{package}}.read(ds)``, or domains with ``f = + {{package}}.read(ds, domain=True)``. + .. versionadded:: NEXTVERSION .. seealso:: `cf.{{class}}List.to_xarray`, `cf.write` @@ -3945,7 +3949,6 @@ def to_xarray(self, group=True): :Parameter: group: `bool`, optional - If False then create a "flat" dataset, i.e. one with only the root group, regardless of any group structure specified by the netCDF interfaces of the diff --git a/cf/test/test_functions.py b/cf/test/test_functions.py index b639ed016d..23f0306ada 100644 --- a/cf/test/test_functions.py +++ b/cf/test/test_functions.py @@ -331,6 +331,7 @@ def test_environment(self): "cfplot", "cf", "xarray", + "umfive", ] # Ensure all expected components are present From 34ea25128ae559414fba811f83bfd0fcd5a7e850 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Thu, 13 Aug 2026 22:52:19 +0100 Subject: [PATCH 26/43] dev --- cf/mixin/fielddomain.py | 10 ++-- cf/test/test_2d_latlon.py | 108 +++++++++++++++++++------------------- 2 files changed, 59 insertions(+), 59 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index 546f01e33a..b082a01403 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -482,7 +482,7 @@ def _indices(self, config, data_axes, ancillary_mask, kwargs): arg0, arg1 = value.value if arg0 > arg1: # Query has swapped operands (i.e. arg0 > - # arg1) => Create a new equivalant Query + # arg1) => Create a new equivalent Query # that has arg0 < arg1, for a new # arg1. E.g. for a period of 360, # cf.wi(355, 5) is transformed to @@ -560,7 +560,7 @@ def _indices(self, config, data_axes, ancillary_mask, kwargs): raise ValueError( "Error: Can't specify multiple " "conditions for a single axis when " - f"one of those condtions ({value!r}) " + f"one of those conditions ({value!r}) " "is effectively a cyclic slice: " f"{index}. Consider applying the " "conditions separately." @@ -705,7 +705,7 @@ def _indices(self, config, data_axes, ancillary_mask, kwargs): for value, construct in zip(points, transposed_constructs) ] - # Find loctions that are True in all of the + # Find locations that are True in all of the # constructs' matches item_match = item_matches.pop() for m in item_matches: @@ -2428,7 +2428,7 @@ def healpix_to_ugrid(self, cache=True, inplace=False): del _ # We are guaranteed unique node values when - # nodes=y_indices*y_indice.size+x_indices + # nodes=y_indices*y_indices.size+x_indices nodes = y_indices del y_indices nodes *= nodes.size @@ -2802,7 +2802,7 @@ def cyclic( # Note: We have to do a "dry run" on the 'autocyclic' call # in the if test in order to prevent corrupting # self._cyclic in the case that an axis tested by - # autocyclic is already marked as cylcic, but + # autocyclic is already marked as cyclic, but # nonetheless autocyclic returns False (sounds # niche, but this really happens!). if len(cyclic) < len( diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index 5de4cc0e28..db316715e3 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -33,6 +33,7 @@ def check_paris(g, atol=1e13, verbose=False): + """Check if field `g` has Paris coordinates.""" if verbose: print( [ @@ -41,6 +42,7 @@ def check_paris(g, atol=1e13, verbose=False): ], [paris_lon, paris_lat], ) + ok = np.allclose(g.auxiliary_coordinate("X"), paris_lon, rtol=0, atol=atol) ok = ok & np.allclose( g.auxiliary_coordinate("Y"), paris_lat, rtol=0, atol=atol @@ -48,15 +50,8 @@ def check_paris(g, atol=1e13, verbose=False): return ok -def set_easting_northing(f, easting, northing): - x = f.dimension_coordinate("X") - x[...] = easting - - y = f.dimension_coordinate("Y") - y[...] = northing - - def set_coordinate_conversion(f, parameters): + """Replace the coordinate reference of field `f` with new parameters.""" cr = f.coordinate_reference() cr.coordinate_conversion.clear_parameters() @@ -64,57 +59,22 @@ def set_coordinate_conversion(f, parameters): def field_paris(proj): - """Create a field for Paris with a projection grid.""" + """Return a field for Paris with projection grid `proj`.""" t = pyproj.Transformer.from_crs(longlat, proj, always_xy=1) - easting, northing = t.transform(paris_lon, paris_lat) + x_coords, y_coords = t.transform(paris_lon, paris_lat) f = f0.copy() - set_easting_northing(f, easting, northing) - return f - - -class LatLon2dTest(unittest.TestCase): - """Test the creation of 2-d lat/lon coordinatesx.""" - - def test_Field_2d_create_latlon_coordinates_bounds(self): - """Test lat/on bounds.""" - f = cf.read("rotated_pole.pp")[0] - - cr = f.coordinate_reference() - self.assertEqual( - cr.coordinate_conversion.parameters(), - { - "grid_mapping_name": "rotated_latitude_longitude", - "grid_north_pole_latitude": 38, - "grid_north_pole_longitude": 190, - }, - ) + x = f.dimension_coordinate("X") + x[...] = x_coords - self.assertFalse(f.auxiliary_coordinates()) + y = f.dimension_coordinate("Y") + y[...] = y_coords - self.assertIsNone(f.create_latlon_coordinates(inplace=True)) + return f - # Compare the 2-d lat/lon corodinates against - # known-to-be-correct values - lat = f.auxiliary_coordinate("latitude") - self.assertEqual(lat.shape, (110, 106)) - self.assertTrue(np.allclose(lat[0, 0].array, 67.1246604)) - self.assertTrue( - np.allclose( - lat[0, 0].bounds.array, - [67.13411912, 66.82618815, 67.11220769, 67.42286415], - ) - ) - lon = f.auxiliary_coordinate("longitude") - self.assertEqual(lon.shape, (110, 106)) - self.assertTrue(np.allclose(lon[0, 0].array, -45.98136153)) - self.assertTrue( - np.allclose( - lon[0, 0].bounds.array, - [-46.7492162, -45.94548426, -45.21355527, -46.01992883], - ) - ) +class LatLon2dTest(unittest.TestCase): + """Test the creation of 2-d lat/lon coordinates.""" def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): """Test albers_equal_area.""" @@ -509,7 +469,7 @@ def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( self, ): """Test rotated_latitude_longitude.""" - # Get the easting and northing for Paris + # Get the rotated longitude and latitude Paris lon_0 = 190 o_lat_p = 38 o_lon_p = 0 @@ -640,7 +600,7 @@ def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): "latitude_of_projection_origin": lat_0, "scale_factor_at_central_meridian": k_0, "false_easting": x_0, - "false_northin": y_0, + "false_northing": y_0, }, ) g = f.create_latlon_coordinates() @@ -694,6 +654,46 @@ def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + def test_Field_2d_create_latlon_coordinates_bounds(self): + """Test lat/lon bounds.""" + f = cf.read("rotated_pole.pp")[0] + + cr = f.coordinate_reference() + self.assertEqual( + cr.coordinate_conversion.parameters(), + { + "grid_mapping_name": "rotated_latitude_longitude", + "grid_north_pole_latitude": 38, + "grid_north_pole_longitude": 190, + }, + ) + + self.assertFalse(f.auxiliary_coordinates()) + + self.assertIsNone(f.create_latlon_coordinates(inplace=True)) + + # Compare the 2-d lat/lon coordinates against + # known-to-be-correct values + lat = f.auxiliary_coordinate("latitude") + self.assertEqual(lat.shape, (110, 106)) + self.assertTrue(np.allclose(lat[0, 0].array, 67.1246604)) + self.assertTrue( + np.allclose( + lat[0, 0].bounds.array, + [67.13411912, 66.82618815, 67.11220769, 67.42286415], + ) + ) + + lon = f.auxiliary_coordinate("longitude") + self.assertEqual(lon.shape, (110, 106)) + self.assertTrue(np.allclose(lon[0, 0].array, -45.98136153)) + self.assertTrue( + np.allclose( + lon[0, 0].bounds.array, + [-46.7492162, -45.94548426, -45.21355527, -46.01992883], + ) + ) + if __name__ == "__main__": print("Run date:", datetime.datetime.now()) From 7ad50202d97d5692220965b1f754abb6629add83 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 14 Aug 2026 10:25:21 +0100 Subject: [PATCH 27/43] dev --- Changelog.rst | 17 ++- cf/mixin/utils/grid_mapping.py | 73 ++++++------ cf/mixin/utils/latlon_utils.py | 38 ++++-- cf/read_write/read.py | 21 +++- cf/test/test_2d_latlon.py | 153 +++++++++++++++++++------ cf/test/test_Field.py | 136 +++++++++++----------- docs/source/check_docs_api_coverage.py | 85 ++++++++++++-- 7 files changed, 358 insertions(+), 165 deletions(-) diff --git a/Changelog.rst b/Changelog.rst index 119c58defa..1f753104bc 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -2,10 +2,23 @@ Version NEXTVERSION -------------- **2026-08-??** - +* New keywords to `cf.read`: ``backend``, ``backend_options``, + ``cfa_filesystem``, ``cfa_backend``, ``cfa_backend_options`` + (https://github.com/NCAS-CMS/cf-python/issues/???) +* Deprecated keyword to `cfdm.read`: ``netcdf_backend`` + (https://github.com/NCAS-CMS/cfdm/issues/417) +* Dataset reads are now entirely managed by `xnetcdf` (via + `cfdm.read`) (https://github.com/NCAS-CMS/cf-python/issues/???) +* Read with `cfdm.read` anything that can be read by `xarray` + (https://github.com/NCAS-CMS/cfdm/issues/417) +* Convert `xarray.Dataset` and `xarray.DataTree` to `cf.Field` via + `cf.read` (https://github.com/NCAS-CMS/cfdm/issues/417) +* Convert `pyfive.File`, `zarr.Group`, `h5py.File`, `umfile.File`, and + `xnetcdf.Dataset` to `cf.Field` via `cf.read` + (https://github.com/NCAS-CMS/cf-python/issues/???) * In `cf.write`, set sensible dataset chunksizes by default for 1-d data, controlled by the new ``one_d_chunks`` keyword - (https://github.com/NCAS-CMS/cfdm/issues/414) + (https://github.com/NCAS-CMS/cf-python/issues/???) * New methods to convert to `xarray`: `cf.Field.to_xarray`, `cf.FieldList.to_xarray`, `cf.Domain.to_xarray`, and `cf.DomainList.to_xarray` diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index ccd2d94a47..84dd741865 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -186,17 +186,13 @@ def _crs_wkt_parameters(cr): return pyproj.CRS.from_wkt(crs_wkt).to_dict() -def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): +def _create_pyproj_CRS(kwargs, cr, ellipsoid_only=False): """Create a `pyproj.CRS` instance. .. versionadded:: NEXTVERSION :Parameters: - cr: `CoordinateReference` - The coordinate reference construct from which *kwargs* was - derived. - kwargs: `dict` A dictionary of keyword arguments for initialising the the `pyproj.CRS` instance. @@ -208,6 +204,14 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): converted to `pyproj.CRS` keyword arguments that are automatically included. + cr: `CoordinateReference` + The coordinate reference construct from which *kwargs* was + derived. + + ellipsoid_only: `bool`, optional + Return the CRS defined only by the ellipsoid and prime + meridian. + :Returns: `pyproj.CRS` or `None` @@ -235,7 +239,7 @@ def _create_pyproj_CRS(kwargs, cr, latitude_longitude=False): return if ( - latitude_longitude + ellipsoid_only and cr.coordinate_conversion.get_parameter("grid_mapping_name", None) != "latitude_longitude" ): @@ -258,8 +262,8 @@ def _cc_parameter(p, parameter, default=None): If there is not a ``crs_wkt`` parameter then: - * If *default* is not `None`, then *default* will be returned if - the *parameter* does not exist. + * If *default* is not `None`, then that *default* will be returned + if the *parameter* does not exist. * If *default* is `None`, then a `KeyError` will be raised if the *parameter* does not exist. @@ -268,6 +272,8 @@ def _cc_parameter(p, parameter, default=None): for a missing CF grid mapping parameter (which happens later on in `_create_pyproj_CRS`). + .. versionadded:: NEXTVERSION + :Parameters: p: `dict` @@ -275,9 +281,10 @@ def _cc_parameter(p, parameter, default=None): parameters. parameter: `str` - The name of the parameter to get. + The name of the parameter. default: optional + What to do if the parmaeter doesn not exist (see above). :Returns: @@ -294,13 +301,13 @@ def _cc_parameter(p, parameter, default=None): # ==================================================================== -# Functions for creating `pyproj.CRS` instances for each CF grid +# Functions for creating a `pyproj.CRS` instance for each CF grid # mapping type. # ==================================================================== def albers_equal_area(cr): - """Create an azimuthal_equidistant CRS. + """Create an albers_equal_area CRS. https://proj.org/en/stable/operations/projections/aea.html @@ -309,7 +316,7 @@ def albers_equal_area(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -354,7 +361,7 @@ def azimuthal_equidistant(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -385,7 +392,7 @@ def geostationary(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -457,7 +464,7 @@ def lambert_azimuthal_equal_area(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -487,7 +494,7 @@ def lambert_conformal_conic(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -532,7 +539,7 @@ def lambert_cylindrical_equal_area(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -560,19 +567,19 @@ def lambert_cylindrical_equal_area(cr): def latitude_longitude(cr): - """create a latitude_longitude CRS. + """Create a latitude_longitude CRS. .. versionadded:: NEXTVERSION :Parameters: cr: `CoordinateReference` - The latitude_longitude coordinate reference construct from - which to create the CRS, or `None` if there isn't one (in - which case a spherical CRS is created). + The coordinate reference construct from which to create + the CRS, or `None` if there isn't one (in which case a + spherical CRS is created). - .. note:: Only the datum parameters are used, so the - coordinate reference construct does not not need + .. note:: Only the datum parameters of *cr* are used, so + the coordinate reference construct does not need to be a latitude_longitude grid mapping. :Returns: @@ -583,7 +590,7 @@ def latitude_longitude(cr): """ kwargs = {"proj": "longlat"} - return _create_pyproj_CRS(kwargs, cr, latitude_longitude=True) + return _create_pyproj_CRS(kwargs, cr, ellipsoid_only=True) def mercator(cr): @@ -596,7 +603,7 @@ def mercator(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -633,7 +640,7 @@ def oblique_mercator(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -665,7 +672,7 @@ def orthographic(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -695,7 +702,7 @@ def polar_stereographic(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -763,7 +770,7 @@ def rotated_latitude_longitude(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -807,7 +814,7 @@ def sinusoidal(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -837,7 +844,7 @@ def stereographic(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -868,7 +875,7 @@ def transverse_mercator(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: @@ -900,7 +907,7 @@ def vertical_perspective(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct. + The coordinate reference construct from the CRS is deived. :Returns: diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index c1a01322c0..8e68735d11 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -92,7 +92,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): # ---------------------------------------------------------------- # Get the source 1-d grid coordinates and axes # ---------------------------------------------------------------- - one_d = _get_1d_coordinates(f, cr, grid_mapping_name) + one_d = _get_1d_coordinates(f, cr) if one_d is None: if is_log_level_info(logger): logger.info( @@ -236,7 +236,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): f"Error during pyproj transformation: {error}" ) # pragma: no cover - return (None, None) + return (None, None) del x_mesh, y_mesh @@ -273,7 +273,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): return (lat_key, lon_key) -def _get_1d_coordinates(f, cr, grid_mapping_name): +def _get_1d_coordinates(f, cr): """Get 1-d dimension coordinates and axes. .. versionadded:: NEXTVERSION @@ -288,28 +288,39 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): The coordinate reference construct that defines or implies the 1-d dimension coordinates. - grid_mapping_name: `str` - The grid_mapping_name parameter of *cr*. - :Returns: `dict` or `None` The 1-d coordinates and axes in the following dictionary keys: - * ``'x'``: The X coordinate construct. - * ``'y'``: The Y coordinate construct. + * ``'x'``: The X dimension coordinate construct. + * ``'y'``: The Y dimension coordinate construct. * ``'axis_x'``: The X domain axis construct key. * ``'axis_y'``: The Y domain axis construct key. If both 1-d dimension coordinates could not be found then `None` is returned. + **Examples:** + + >>> _get_1d_coordinates(f, cr) + {'x': , + 'y': , + 'axis_x': 'domainaxis1', + 'axis_y': 'domainaxis0'} + + >>> _get_1d_coordinates(f, cr) + {'x': , + 'y': , + 'axis_x': 'domainaxis1', + 'axis_y': 'domainaxis0'} + """ x = None y = None - # Look for 1-d coordinates named by the coordinate reference + # Look for dimension coordinates named by the coordinate reference for key in cr.coordinates(): dc = f.dimension_coordinate(f"key%{key}", default=None) if dc is None: @@ -324,6 +335,9 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): if x is None and y is None: # Look for 1-d coordinates by identity + grid_mapping_name = cr.coordinate_conversion.get_parameter( + "grid_mapping_name", None + ) match grid_mapping_name: case "rotated_latitude_longitude": identity_x = "grid_longitude" @@ -340,11 +354,11 @@ def _get_1d_coordinates(f, cr, grid_mapping_name): ) if x is None or y is None: - # Can't find all 1-d dimension coordinates + # Can't find both 1-d dimension coordinates return - # Make sure the 1-d coordinates are referenced from the coordinate - # reference + # Make sure that the 1-d coordinates are referenced from the + # coordinate reference cr.set_coordinates((key_x, key_y)) return { diff --git a/cf/read_write/read.py b/cf/read_write/read.py index a4ff4b167c..e1f96489bc 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -223,11 +223,11 @@ class read(cfdm.read): {{read backend: `None` or (sequence of) `str`, optional}} - .. versionadded:: (cfdm) NEXTVERSION + .. versionadded:: NEXTVERSION {{read backend_options: `None` or `dict`, optional}} - .. versionadded:: (cfdm) NEXTVERSION + .. versionadded:: NEXTVERSION {{read storage_options: `dict` or `None`, optional}} @@ -257,6 +257,18 @@ class read(cfdm.read): .. versionadded:: 3.17.0 + {{read cfa_filesystem: `None` or filesystem, optional}} + + .. versionadded:: NEXTVERSION + + {{read cfa_backend: `None` or (sequence of) `str`, optional}} + + .. versionadded:: NEXTVERSION + + {{cfa_backend_options: `None` or `dict`, optional}} + + .. versionadded:: NEXTVERSION + {{read to_memory: (sequence of) `str`, optional}} .. versionadded:: 3.17.0 @@ -269,6 +281,10 @@ class read(cfdm.read): .. versionadded:: 3.20.0 + {{read _noncompliance_report: `bool`, optional}} + + ..versionadded:: NEXTVERSION + umversion: deprecated at version 3.0.0 Use the *um* parameter instead. @@ -307,6 +323,7 @@ class read(cfdm.read): `FieldList` or `DomainList` The field or domain constructs found in the input dataset(s). The list may be empty. + **Examples** >>> x = cf.read('file.nc') diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index db316715e3..f7152450fe 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -7,19 +7,19 @@ import cf ellps = "WGS84" -units = "km" +km = "km" f0 = cf.example_field(0)[0, 0] key_x, x = f0.dimension_coordinate("X", item=True) x.del_bounds() x.standard_name = "projection_x_coordinate" -x.override_units(units, inplace=True) +x.override_units(km, inplace=True) key_y, y = f0.dimension_coordinate("Y", item=True) y.del_bounds() y.standard_name = "projection_y_coordinate" -y.override_units(units, inplace=True) +y.override_units(km, inplace=True) cr = cf.CoordinateReference() cr.datum.set_parameters({"reference_ellipsoid_name": ellps}) @@ -70,6 +70,13 @@ def field_paris(proj): y = f.dimension_coordinate("Y") y[...] = y_coords + rotated_latitude_longitude = proj.to_dict().get("proj") == "ob_tran" + if rotated_latitude_longitude: + x.standard_name = "grid_longitude" + y.standard_name = "grid_latitude" + x.override_units("degrees", inplace=True) + y.override_units("degrees", inplace=True) + return f @@ -78,7 +85,10 @@ class LatLon2dTest(unittest.TestCase): def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): """Test albers_equal_area.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_1 = 43 lat_2 = 62 lat_0 = 30 @@ -92,7 +102,7 @@ def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -109,6 +119,8 @@ def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -121,7 +133,10 @@ def test_Field_2d_create_latlon_coordinates_albers_equal_area(self): def test_Field_2d_create_latlon_coordinates_azimuthal_equidistant(self): """Test azimuthal_equidistant.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 48.8584 lon_0 = 2.2945 proj = pyproj.CRS( @@ -131,7 +146,7 @@ def test_Field_2d_create_latlon_coordinates_azimuthal_equidistant(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -146,6 +161,8 @@ def test_Field_2d_create_latlon_coordinates_azimuthal_equidistant(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -158,7 +175,10 @@ def test_Field_2d_create_latlon_coordinates_azimuthal_equidistant(self): def test_Field_2d_create_latlon_coordinates_geostationary(self): """Test geostationary.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. h = 35785831 lon_0 = 0 sweep = "y" @@ -170,7 +190,7 @@ def test_Field_2d_create_latlon_coordinates_geostationary(self): y_0=0, sweep=sweep, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -187,6 +207,8 @@ def test_Field_2d_create_latlon_coordinates_geostationary(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g, atol=1e-12)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, {"grid_mapping_name": "geostationary", "crs_wkt": proj.to_wkt()} ) @@ -197,7 +219,10 @@ def test_Field_2d_create_latlon_coordinates_lambert_azimuthal_equal_area( self, ): """Test lambert_azimuthal_equal_area.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 52 lon_0 = 10 x_0 = 4321000 @@ -209,7 +234,7 @@ def test_Field_2d_create_latlon_coordinates_lambert_azimuthal_equal_area( x_0=x_0, y_0=y_0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -226,6 +251,8 @@ def test_Field_2d_create_latlon_coordinates_lambert_azimuthal_equal_area( g = f.create_latlon_coordinates() self.assertTrue(check_paris(g, atol=1e-8)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -238,7 +265,10 @@ def test_Field_2d_create_latlon_coordinates_lambert_azimuthal_equal_area( def test_Field_2d_create_latlon_coordinates_lambert_conformal_conic(self): """Test lambert_conformal_conic.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_1 = 33 lat_2 = 45 lat_0 = 39 @@ -252,7 +282,7 @@ def test_Field_2d_create_latlon_coordinates_lambert_conformal_conic(self): ellps=ellps, x_0=0, y_0=0, - units=units, + units=km, ) f = field_paris(proj) set_coordinate_conversion( @@ -267,6 +297,8 @@ def test_Field_2d_create_latlon_coordinates_lambert_conformal_conic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -281,7 +313,10 @@ def test_Field_2d_create_latlon_coordinates_lambert_cylindrical_equal_area( self, ): """Test lambert_cylindrical_equal_area.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lon_0 = 0 lat_ts = 30 proj = pyproj.CRS( @@ -291,7 +326,7 @@ def test_Field_2d_create_latlon_coordinates_lambert_cylindrical_equal_area( x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -306,6 +341,8 @@ def test_Field_2d_create_latlon_coordinates_lambert_cylindrical_equal_area( g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -318,7 +355,10 @@ def test_Field_2d_create_latlon_coordinates_lambert_cylindrical_equal_area( def test_Field_2d_create_latlon_coordinates_mercator(self): """Test mercator.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lon_0 = 0 lat_ts = 0 proj = pyproj.CRS( @@ -328,7 +368,7 @@ def test_Field_2d_create_latlon_coordinates_mercator(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -343,6 +383,8 @@ def test_Field_2d_create_latlon_coordinates_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, {"grid_mapping_name": "mercator", "crs_wkt": proj.to_wkt()} ) @@ -351,7 +393,10 @@ def test_Field_2d_create_latlon_coordinates_mercator(self): def test_Field_2d_create_latlon_coordinates_oblique_mercator(self): """Test oblique_mercator.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 45 lonc = 10 alpha = 45 @@ -365,7 +410,7 @@ def test_Field_2d_create_latlon_coordinates_oblique_mercator(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -382,6 +427,8 @@ def test_Field_2d_create_latlon_coordinates_oblique_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -394,7 +441,10 @@ def test_Field_2d_create_latlon_coordinates_oblique_mercator(self): def test_Field_2d_create_latlon_coordinates_orthographic(self): """Test orthographic.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 48.8584 lon_0 = 2.2945 proj = pyproj.CRS( @@ -404,7 +454,7 @@ def test_Field_2d_create_latlon_coordinates_orthographic(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -419,6 +469,8 @@ def test_Field_2d_create_latlon_coordinates_orthographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, {"grid_mapping_name": "orthographic", "crs_wkt": proj.to_wkt()} ) @@ -427,7 +479,10 @@ def test_Field_2d_create_latlon_coordinates_orthographic(self): def test_Field_2d_create_latlon_coordinates_polar_stereographic(self): """Test polar_stereographic.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_ts = 90 lat_0 = 90 lon_0 = 0 @@ -439,7 +494,7 @@ def test_Field_2d_create_latlon_coordinates_polar_stereographic(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -455,6 +510,8 @@ def test_Field_2d_create_latlon_coordinates_polar_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -469,7 +526,10 @@ def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( self, ): """Test rotated_latitude_longitude.""" - # Get the rotated longitude and latitude Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lon_0 = 190 o_lat_p = 38 o_lon_p = 0 @@ -480,7 +540,7 @@ def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( o_lat_p=o_lat_p, lon_0=lon_0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -496,6 +556,8 @@ def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -508,7 +570,10 @@ def test_Field_2d_create_latlon_coordinates_rotated_latitude_longitude( def test_Field_2d_create_latlon_coordinates_sinusoidal(self): """Test sinusoidal.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lon_0 = 0 proj = pyproj.CRS( proj="sinu", @@ -516,7 +581,7 @@ def test_Field_2d_create_latlon_coordinates_sinusoidal(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -530,6 +595,8 @@ def test_Field_2d_create_latlon_coordinates_sinusoidal(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, {"grid_mapping_name": "sinusoidal", "crs_wkt": proj.to_wkt()} ) @@ -538,7 +605,10 @@ def test_Field_2d_create_latlon_coordinates_sinusoidal(self): def test_Field_2d_create_latlon_coordinates_stereographic(self): """Test stereographic.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 90 lon_0 = 0 k_0 = 0.994 @@ -550,7 +620,7 @@ def test_Field_2d_create_latlon_coordinates_stereographic(self): x_0=0, y_0=0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -566,6 +636,8 @@ def test_Field_2d_create_latlon_coordinates_stereographic(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, {"grid_mapping_name": "stereographic", "crs_wkt": proj.to_wkt()} ) @@ -574,7 +646,10 @@ def test_Field_2d_create_latlon_coordinates_stereographic(self): def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): """Test transverse_mercator.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. lat_0 = 0 lon_0 = 3 k_0 = 0.9996012717 @@ -588,7 +663,7 @@ def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): x_0=x_0, y_0=y_0, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -606,6 +681,8 @@ def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -618,7 +695,10 @@ def test_Field_2d_create_latlon_coordinates_transverse_mercator(self): def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): """Test vertical_perspective.""" - # Get the easting and northing for Paris + # Get the correct projected coordinates for Paris, convert + # these projected coordinates to lat/lon using + # `cf.Field.create_latlon_coordinates`, and check that we end + # up in Paris. h = 3000000 lat_0 = 48.8584 lon_0 = 2.2945 @@ -628,7 +708,7 @@ def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): lat_0=lat_0, h=h, ellps=ellps, - units=units, + units=km, ) f = field_paris(proj) @@ -644,6 +724,8 @@ def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): g = f.create_latlon_coordinates() self.assertTrue(check_paris(g)) + # Do the same, but with a WKT-defined coordinate reference + # construct. set_coordinate_conversion( f, { @@ -656,6 +738,7 @@ def test_Field_2d_create_latlon_coordinates_vertical_perspective(self): def test_Field_2d_create_latlon_coordinates_bounds(self): """Test lat/lon bounds.""" + # Check that lat/lon coordinate bounds are correctly created. f = cf.read("rotated_pole.pp")[0] cr = f.coordinate_reference() @@ -677,6 +760,8 @@ def test_Field_2d_create_latlon_coordinates_bounds(self): lat = f.auxiliary_coordinate("latitude") self.assertEqual(lat.shape, (110, 106)) self.assertTrue(np.allclose(lat[0, 0].array, 67.1246604)) + + self.assertEqual(lat.bounds.units, lat.units) self.assertTrue( np.allclose( lat[0, 0].bounds.array, @@ -687,6 +772,8 @@ def test_Field_2d_create_latlon_coordinates_bounds(self): lon = f.auxiliary_coordinate("longitude") self.assertEqual(lon.shape, (110, 106)) self.assertTrue(np.allclose(lon[0, 0].array, -45.98136153)) + + self.assertEqual(lon.bounds.units, lon.units) self.assertTrue( np.allclose( lon[0, 0].bounds.array, diff --git a/cf/test/test_Field.py b/cf/test/test_Field.py index b2db15d2f4..f79dcb7343 100644 --- a/cf/test/test_Field.py +++ b/cf/test/test_Field.py @@ -145,7 +145,7 @@ def test_Field_compress_uncompress(self): u = f.uncompress() self.assertFalse(bool(u.data.get_compression_type()), message) - self.assertTrue(f.equals(u, verbose=2), message) + self.assertTrue(f.equals(u), message) for method1 in methods: message += ", method1=" + method1 @@ -160,16 +160,16 @@ def test_Field_compress_uncompress(self): bool(c.data.get_compression_type()), message ) - self.assertTrue(u.equals(c, verbose=2), message) - self.assertTrue(f.equals(c, verbose=2), message) + self.assertTrue(u.equals(c), message) + self.assertTrue(f.equals(c), message) c = f.compress(method1) self.assertTrue( bool(c.data.get_compression_type()), message ) - self.assertTrue(u.equals(c, verbose=2), message) - self.assertTrue(f.equals(c, verbose=2), message) + self.assertTrue(u.equals(c), message) + self.assertTrue(f.equals(c), message) cf.write(c, tmpfile) c = cf.read(tmpfile)[0] @@ -177,7 +177,7 @@ def test_Field_compress_uncompress(self): self.assertTrue( bool(c.data.get_compression_type()), message ) - self.assertTrue(f.equals(c, verbose=2), message) + self.assertTrue(f.equals(c), message) def test_Field_apply_masking(self): f = self.f0.copy() @@ -194,7 +194,7 @@ def test_Field_apply_masking(self): d = f.data.copy() g = f.copy() self.assertIsNone(f.apply_masking(inplace=True)) - self.assertTrue(f.equals(g, verbose=1)) + self.assertTrue(f.equals(g)) x = 0.11 y = 0.1 @@ -205,31 +205,31 @@ def test_Field_apply_masking(self): g = f.apply_masking() e = d.apply_masking(fill_values=[x]) - self.assertTrue(e.equals(g.data, verbose=1)) + self.assertTrue(e.equals(g.data)) self.assertEqual(g.data.array.count(), g.data.size - 1) f.set_property("valid_range", [y, z]) d = f.data.copy() g = f.apply_masking() e = d.apply_masking(fill_values=[x], valid_range=[y, z]) - self.assertTrue(e.equals(g.data, verbose=1)) + self.assertTrue(e.equals(g.data)) f.del_property("valid_range") f.set_property("valid_min", y) g = f.apply_masking() e = d.apply_masking(fill_values=[x], valid_min=y) - self.assertTrue(e.equals(g.data, verbose=1)) + self.assertTrue(e.equals(g.data)) f.del_property("valid_min") f.set_property("valid_max", z) g = f.apply_masking() e = d.apply_masking(fill_values=[x], valid_max=z) - self.assertTrue(e.equals(g.data, verbose=1)) + self.assertTrue(e.equals(g.data)) f.set_property("valid_min", y) g = f.apply_masking() e = d.apply_masking(fill_values=[x], valid_min=y, valid_max=z) - self.assertTrue(e.equals(g.data, verbose=1)) + self.assertTrue(e.equals(g.data)) def test_Field_flatten(self): f = self.f.copy() @@ -242,16 +242,16 @@ def test_Field_flatten(self): g = f.flatten() h = f.flatten(list(range(f.ndim))) - self.assertTrue(h.equals(g, verbose=2)) + self.assertTrue(h.equals(g)) g = f.flatten("time") - self.assertTrue(g.equals(f, verbose=2)) + self.assertTrue(g.equals(f)) for i in (0, 1, 2): g = f.flatten(i) - self.assertTrue(g.equals(f, verbose=2)) + self.assertTrue(g.equals(f)) g = f.flatten([i, "time"]) - self.assertTrue(g.equals(f, verbose=2)) + self.assertTrue(g.equals(f)) for axes in axes_combinations(f): g = f.flatten(axes) @@ -271,7 +271,7 @@ def test_Field_flatten(self): self.assertEqual(g.ndim, f.ndim - len(axes) + 1) self.assertEqual(g.size, f.size) - self.assertTrue(f.equals(f.flatten([]), verbose=2)) + self.assertTrue(f.equals(f.flatten([]))) self.assertIsNone(f.flatten(inplace=True)) def test_Field_bin(self): @@ -344,14 +344,14 @@ def test_Field_weights(self): w = f.weights(None) self.assertIsInstance(w, cf.Field) - self.assertTrue(w.data.equals(cf.Data(1.0, "1"), verbose=2)) + self.assertTrue(w.data.equals(cf.Data(1.0, "1"))) w = f.weights(data=True) self.assertIsInstance(w, cf.Data) w = f.weights(None, data=True) self.assertIsInstance(w, cf.Data) - self.assertTrue(w.equals(cf.Data(1.0, "1"), verbose=2)) + self.assertTrue(w.equals(cf.Data(1.0, "1"))) w = f.weights(components=True) self.assertIsInstance(w, dict) @@ -369,7 +369,7 @@ def test_Field_weights(self): w = f.weights() x = f.weights(w) - self.assertTrue(x.equals(w, verbose=2)) + self.assertTrue(x.equals(w)) for components in (False, True): for m in (False, True): @@ -460,7 +460,7 @@ def test_Field_collapse(self): a = f.collapse(method, axes=axes, weights=weights).data b = getattr(f.data, method)(axes=axes) self.assertTrue( - a.equals(b, rtol=1e-05, atol=1e-08, verbose=2), + a.equals(b, rtol=1e-05, atol=1e-08), ) for method in ( @@ -480,7 +480,7 @@ def test_Field_collapse(self): a = f.collapse(method, axes=axes, weights=weights).data b = getattr(f.data, method)(axes=axes, weights=d_weights) self.assertTrue( - a.equals(b, rtol=1e-05, atol=1e-08, verbose=2), + a.equals(b, rtol=1e-05, atol=1e-08), ) for method in ("integral",): @@ -491,7 +491,7 @@ def test_Field_collapse(self): ).data b = getattr(f.data, method)(axes=axes, weights=d_weights) self.assertTrue( - a.equals(b, rtol=1e-05, atol=1e-08, verbose=2), + a.equals(b, rtol=1e-05, atol=1e-08), ) for axes in axes_combinations(f): @@ -510,7 +510,7 @@ def test_Field_collapse(self): axes=axes, ddof=1, weights=d_weights ) self.assertTrue( - a.equals(b, rtol=1e-05, atol=1e-08, verbose=2), + a.equals(b, rtol=1e-05, atol=1e-08), ) for method in ("mean_of_upper_decile",): @@ -523,7 +523,7 @@ def test_Field_collapse(self): a = f.collapse(method, axes=axes, weights=weights).data b = getattr(f.data, method)(axes=axes, weights=d_weights) self.assertTrue( - a.equals(b, rtol=1e-05, atol=1e-08, verbose=2), + a.equals(b, rtol=1e-05, atol=1e-08), ) # Test the remove_vertical_crs keyword @@ -577,23 +577,23 @@ def test_Field_atol_rtol(self): f = self.f g = f.copy() - self.assertTrue(f.equals(g, verbose=2)) + self.assertTrue(f.equals(g)) g[0, 0, 0] += 0.001 self.assertFalse(f.equals(g)) - self.assertTrue(f.equals(g, atol=0.1, verbose=2)) + self.assertTrue(f.equals(g, atol=0.1)) self.assertFalse(f.equals(g)) self.assertEqual(cf.atol(), cf.ATOL()) atol = cf.atol(0.1) - self.assertTrue(f.equals(g, verbose=2)) + self.assertTrue(f.equals(g)) cf.atol(atol) self.assertFalse(f.equals(g)) - self.assertTrue(f.equals(g, rtol=10, verbose=2)) + self.assertTrue(f.equals(g, rtol=10)) self.assertFalse(f.equals(g)) self.assertEqual(cf.rtol(), cf.RTOL()) rtol = cf.rtol(10) - self.assertTrue(f.equals(g, verbose=2)) + self.assertTrue(f.equals(g)) cf.rtol(rtol) self.assertFalse(f.equals(g)) @@ -730,15 +730,15 @@ def test_Field__add__(self): f = self.f.copy() g = f * 0 - self.assertTrue((f + g).equals(f, verbose=2)) - self.assertTrue((g + f).equals(f, verbose=2)) + self.assertTrue((f + g).equals(f)) + self.assertTrue((g + f).equals(f)) g.transpose(inplace=True) - self.assertTrue((f + g).equals(f, verbose=2)) + self.assertTrue((f + g).equals(f)) for g in (f, f.copy(), f * 0): - self.assertTrue((f + g).equals(g + f, verbose=2)) - self.assertTrue((g + f).equals(f + g, verbose=2)) + self.assertTrue((f + g).equals(g + f)) + self.assertTrue((g + f).equals(f + g)) g = f.subspace(grid_longitude=[0]) * 0 @@ -752,8 +752,8 @@ def test_Field__add__(self): for key in a.cell_measures(filter_by_axis=(axis,), axis_mode="or"): a.del_construct(key) - self.assertTrue(a.equals(b, verbose=2)) - self.assertTrue(b.equals(a, verbose=2)) + self.assertTrue(a.equals(b)) + self.assertTrue(b.equals(a)) with self.assertRaises(TypeError): f + ("a string",) @@ -792,7 +792,7 @@ def test_Field_cumsum(self): g = f.copy() h = g.cumsum(1) self.assertIsNone(g.cumsum(1, inplace=True)) - self.assertTrue(g.equals(h, verbose=2)) + self.assertTrue(g.equals(h)) # Check that a new cell method that has been added cell_methods = h.cell_methods(todict=True) @@ -838,23 +838,23 @@ def test_Field_flip(self): g = f.subspace(**kwargs) h = f.flip() - self.assertTrue(h.equals(g, verbose=1)) + self.assertTrue(h.equals(g)) h = f.flip(f.get_data_axes()) - self.assertTrue(h.equals(g, verbose=1)) + self.assertTrue(h.equals(g)) h = f.flip(list(range(f.ndim))) - self.assertTrue(h.equals(g, verbose=1)) + self.assertTrue(h.equals(g)) h = f.flip(["X", "Z", "Y"]) - self.assertTrue(h.equals(g, verbose=1)) + self.assertTrue(h.equals(g)) h = f.flip((re.compile("^atmos"), "grid_latitude", "grid_longitude")) - self.assertTrue(h.equals(g, verbose=1)) + self.assertTrue(h.equals(g)) g = f.subspace(grid_longitude=slice(None, None, -1)) self.assertIsNone(f.flip("X", inplace=True)) - self.assertTrue(f.equals(g, verbose=1)) + self.assertTrue(f.equals(g)) def test_Field_anchor(self): f = self.f.copy() @@ -1127,8 +1127,8 @@ def test_Field_get_data_axes(self): def test_Field_equals(self): f = self.f.copy() g = f.copy() - self.assertTrue(f.equals(f, verbose=2)) - self.assertTrue(f.equals(g, verbose=2)) + self.assertTrue(f.equals(f)) + self.assertTrue(f.equals(g)) g.set_property("foo", "bar") self.assertFalse(f.equals(g)) g = f.copy() @@ -1137,12 +1137,12 @@ def test_Field_equals(self): # Symmetry f = cf.example_field(2) g = f.copy() - self.assertTrue(f.equals(g, verbose=2)) - self.assertTrue(g.equals(f, verbose=2)) + self.assertTrue(f.equals(g)) + self.assertTrue(g.equals(f)) g.del_construct("dimensioncoordinate0") - self.assertFalse(f.equals(g, verbose=2)) - self.assertFalse(g.equals(f, verbose=2)) + self.assertFalse(f.equals(g)) + self.assertFalse(g.equals(f)) def test_Field_insert_dimension(self): f = self.f.copy() @@ -2269,9 +2269,7 @@ def test_Field_auxiliary_coordinate(self): for identity in ("auxiliarycoordinate1", "latitude"): key, c = f.construct_item(identity) - self.assertTrue( - f.auxiliary_coordinate(identity).equals(c, verbose=2) - ) + self.assertTrue(f.auxiliary_coordinate(identity).equals(c)) self.assertEqual(f.auxiliary_coordinate(identity, key=True), key) with self.assertRaises(ValueError): @@ -2303,9 +2301,7 @@ def test_Field_coordinate_reference(self): key = f.construct_key(identity) c = f.construct(identity) - self.assertTrue( - f.coordinate_reference(identity).equals(c, verbose=2) - ) + self.assertTrue(f.coordinate_reference(identity).equals(c)) self.assertEqual(f.coordinate_reference(identity, key=True), key) key = f.construct_key( @@ -2336,9 +2332,7 @@ def test_Field_coordinate_reference(self): key = f.construct_key(identity) c = f.construct(identity) - self.assertTrue( - f.get_coordinate_reference(identity).equals(c, verbose=2) - ) + self.assertTrue(f.get_coordinate_reference(identity).equals(c)) self.assertEqual( f.get_coordinate_reference(identity, key=True), key ) @@ -2360,7 +2354,7 @@ def test_Field_coordinate_reference(self): cr = f.del_coordinate_reference( "standard_name:atmosphere_hybrid_height_coordinate" ) - self.assertTrue(cr.equals(c, verbose=2)) + self.assertTrue(cr.equals(c)) self.assertEqual(len(f.coordinate_references()), 1) self.assertEqual(len(f.domain_ancillaries()), 0) @@ -2414,9 +2408,7 @@ def test_Field_dimension_coordinate(self): else: key, c = f.construct(identity, item=True) - self.assertTrue( - f.dimension_coordinate(identity).equals(c, verbose=2) - ) + self.assertTrue(f.dimension_coordinate(identity).equals(c)) self.assertEqual(f.dimension_coordinate(identity, key=True), key) k, v = f.dimension_coordinate(identity, item=True) @@ -2439,10 +2431,10 @@ def test_Field_cell_measure(self): for identity in ("measure:area", "cellmeasure0"): key, c = f.construct_item(identity) - self.assertTrue(f.cell_measure(identity).equals(c, verbose=2)) + self.assertTrue(f.cell_measure(identity).equals(c)) self.assertEqual(f.cell_measure(identity, key=True), key) - self.assertTrue(f.cell_measure(identity).equals(c, verbose=2)) + self.assertTrue(f.cell_measure(identity).equals(c)) self.assertEqual(f.cell_measure(identity, key=True), key) self.assertEqual(len(f.cell_measures()), 1) @@ -2460,7 +2452,7 @@ def test_Field_cell_method(self): for identity in ("method:mean", "cellmethod0"): key, c = f.construct_item(identity) - self.assertTrue(f.cell_method(identity).equals(c, verbose=2)) + self.assertTrue(f.cell_method(identity).equals(c)) self.assertEqual(f.cell_method(identity, key=True), key) def test_Field_domain_ancillary(self): @@ -2468,7 +2460,7 @@ def test_Field_domain_ancillary(self): for identity in ("surface_altitude", "domainancillary0"): key, c = f.construct_item(identity) - self.assertTrue(f.domain_ancillary(identity).equals(c, verbose=2)) + self.assertTrue(f.domain_ancillary(identity).equals(c)) self.assertEqual(f.domain_ancillary(identity, key=True), key) with self.assertRaises(ValueError): @@ -2479,7 +2471,7 @@ def test_Field_field_ancillary(self): for identity in ("ancillary0", "fieldancillary0"): key, c = f.construct_item(identity) - self.assertTrue(f.field_ancillary(identity).equals(c, verbose=2)) + self.assertTrue(f.field_ancillary(identity).equals(c)) self.assertEqual(f.field_ancillary(identity, key=True), key) with self.assertRaises(ValueError): @@ -2492,7 +2484,7 @@ def test_Field_transpose(self): # Null transpose g = f.transpose([0, 1, 2]) - self.assertTrue(f0.equals(g, verbose=2)) + self.assertTrue(f0.equals(g)) self.assertIsNone(f.transpose([0, 1, 2], inplace=True)) self.assertTrue(f0.equals(f)) @@ -2510,7 +2502,7 @@ def test_Field_transpose(self): inplace=True, ) - self.assertTrue(h.equals(h0, verbose=2)) + self.assertTrue(h.equals(h0)) self.assertTrue((h.array == f.array).all()) with self.assertRaises(Exception): @@ -2538,9 +2530,9 @@ def test_Field_where(self): g = f.where(landfrac >= 54, cf.masked) self.assertTrue(g.data.count() == 9 * 6, g.data.count()) - self.assertTrue(f.equals(f.where(None), verbose=2)) + self.assertTrue(f.equals(f.where(None))) self.assertIsNone(f.where(None, inplace=True)) - self.assertTrue(f.equals(f0, verbose=2)) + self.assertTrue(f.equals(f0)) g = f.where(cf.wi(25, 31), -99, 11, construct="grid_longitude") g = f.where(cf.wi(25, 31), f * 9, f * -7, construct="grid_longitude") diff --git a/docs/source/check_docs_api_coverage.py b/docs/source/check_docs_api_coverage.py index 8cdd5f8aa2..32463bedc9 100644 --- a/docs/source/check_docs_api_coverage.py +++ b/docs/source/check_docs_api_coverage.py @@ -1,4 +1,6 @@ -"""Check the method-coverage of all classes in docs/source/class.rst. +"""Check the class-coverage and method-coverage of the API reference. + +All classes are extracted and checked for an entry in docs/source/class.rst All non-private methods of all such classes are checked for having an entry in their corresponding class's file in docs/source/class/ @@ -12,6 +14,8 @@ """ +import inspect +import re import os import sys @@ -29,9 +33,13 @@ if not source.endswith("source"): raise ValueError(f"Given directory {source} does not end with 'source'") +n_undocumented_classes = 0 n_undocumented_methods = 0 n_missing_files = 0 +duplicate_method_entries = [] + + for core in ("", "_core"): if core: if package.__name__ != "cfdm": @@ -43,41 +51,96 @@ with open(os.path.join(source, "class" + core + ".rst")) as f: api_contents = f.read() + # TODO: after #958 is resolved, replace this by grabbing all classes from + # '__all__', which defines the public API and therefore what must be + # include - and do the same for the methods etc. class_names = [ - i.split(".")[-1] - for i in api_contents.split("\n") - if package.__name__ + "." in i + name + for name, klass in inspect.getmembers(package, inspect.isclass) + if klass.__module__.startswith(package.__name__ + ".") + # Because of docstring substitution in cfdm, all functions imported + # from there emerge as classes, with: + # type= + # so when we try to extract classes only we end up with a lot of + # functions mixed in. To filter these out, we can use the fact that + # the functions emerge from just some modules, notably .functions etc.: + and not klass.__module__.startswith(package.__name__ + ".functions") + and not klass.__module__.startswith(package.__name__ + ".constants") + # This just counts top-level read-write i.e. cf.read and cf.write + and not klass.__module__.startswith(package.__name__ + ".read_write") ] for class_name in class_names: class_name = class_name.rstrip() + full_class_name = f"{package.__name__}.{class_name}" + + if full_class_name not in api_contents: + print(f"Class {full_class_name} not in docs/source/class{core}.rst") + n_missing_files += 1 + n_undocumented_classes += 1 + continue + klass = getattr(package, class_name) methods = [ method for method in dir(klass) if not method.startswith("_") ] - class_name = ".".join([package.__name__, class_name]) - rst_file = os.path.join(source, "class", class_name + ".rst") + rst_file = os.path.join(source, "class", full_class_name + ".rst") try: with open(rst_file) as f: rst_contents = f.read() for method in methods: - method = ".".join([class_name, method]) - if method not in rst_contents: + method = ".".join([full_class_name, method]) + count = rst_contents.count(method) + if count == 0: n_undocumented_methods += 1 - print(f"Method {method} not in {rst_file}") + print( + f"Method {method} not in " + f"{os.path.join(source, 'class', rst_file)}" + ) + elif count > 1: + # The method appears more than once, but may be a + # sub-string of another method name, e.g. this gets caught: + # [cfdm.List.]nc_set_variable + # due to the presence of this method: + # [cfdm.List.]nc_set_variable_groups + # so we must account for that. Checking next character + # of duplicate(s) is something other than a newline or + # whitespace, seems robust and simplest. + end_loc = [ + m.end(0) for m in re.finditer(method, rst_contents) + ] + chars = [rst_contents[c] for c in [e for e in end_loc]] + + # Any character that isn't a newline or whitespace + # indicates another method which the method is a substring + # of and can be excluded. If there are still duplicates, + # we have genuine duplicate listing entries to report. + if chars.count("\n") + chars.count(" ") > 1: + duplicate_method_entries.append(method) + except FileNotFoundError: n_missing_files += 1 - print(f"File {rst_file} does not exist") + print(f"File {rst_file} does not exist for existing class ") if n_undocumented_methods or n_missing_files: raise ValueError( - f"Found {n_undocumented_methods} undocumented methods and " + f"Found {n_undocumented_classes} undocumented classes, " + f"{n_undocumented_methods} undocumented methods and " f"{n_missing_files} missing .rst files" ) +if duplicate_method_entries: + duplicate_method_entries.sort() + entries = "\n".join(duplicate_method_entries) # can't set \n in f-string! + print( + "WARNING: some methods are listed multiple times inside one class " + "file/page. Decide if the duplicates are intended and if not remove " + f"them. They are:\n{entries}\n" + ) + print("All methods are documented") From 94e267135b1784c2f3820196848d5a6458a866b0 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 14 Aug 2026 10:57:23 +0100 Subject: [PATCH 28/43] dev --- Changelog.rst | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Changelog.rst b/Changelog.rst index 1f753104bc..70872adca9 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -4,21 +4,28 @@ Version NEXTVERSION **2026-08-??** * New keywords to `cf.read`: ``backend``, ``backend_options``, ``cfa_filesystem``, ``cfa_backend``, ``cfa_backend_options`` - (https://github.com/NCAS-CMS/cf-python/issues/???) + (https://github.com/NCAS-CMS/cf-python/issues/961) * Deprecated keyword to `cfdm.read`: ``netcdf_backend`` - (https://github.com/NCAS-CMS/cfdm/issues/417) + (https://github.com/NCAS-CMS/cf-python/issues/961) * Dataset reads are now entirely managed by `xnetcdf` (via - `cfdm.read`) (https://github.com/NCAS-CMS/cf-python/issues/???) -* Read with `cfdm.read` anything that can be read by `xarray` - (https://github.com/NCAS-CMS/cfdm/issues/417) + `cfdm.read`) (https://github.com/NCAS-CMS/cf-python/issues/961) +* Read with `cf.read` anything that can be read by `xarray` + (https://github.com/NCAS-CMS/cf-python/issues/961) * Convert `xarray.Dataset` and `xarray.DataTree` to `cf.Field` via - `cf.read` (https://github.com/NCAS-CMS/cfdm/issues/417) + `cf.read` (https://github.com/NCAS-CMS/cf-python/issues/961) * Convert `pyfive.File`, `zarr.Group`, `h5py.File`, `umfile.File`, and `xnetcdf.Dataset` to `cf.Field` via `cf.read` - (https://github.com/NCAS-CMS/cf-python/issues/???) + (https://github.com/NCAS-CMS/cf-python/issues/961) +* Extend `cf.Field.create_latlon_coordinates` to allow the creation of + 2-d latitudes/longitudes from plane projection and rotated pole + coordinates (https://github.com/NCAS-CMS/cf-python/issues/962) +* Automatically create consolidated HDF5 metadata with `cf.write`. + New keywords to `cf.write`: ``hdf5_consolidated_metadata`` and + ``hdf5_expansion_factor`` + (https://github.com/NCAS-CMS/cfdm/issues/413) * In `cf.write`, set sensible dataset chunksizes by default for 1-d data, controlled by the new ``one_d_chunks`` keyword - (https://github.com/NCAS-CMS/cf-python/issues/???) + (https://github.com/NCAS-CMS/cfdm/issues/414) * New methods to convert to `xarray`: `cf.Field.to_xarray`, `cf.FieldList.to_xarray`, `cf.Domain.to_xarray`, and `cf.DomainList.to_xarray` From 37d872bcb9d4d9e675a7936c7bc62009759e9960 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 14 Aug 2026 16:28:02 +0100 Subject: [PATCH 29/43] dev --- Changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/Changelog.rst b/Changelog.rst index 70872adca9..848bec0f5d 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -2,6 +2,7 @@ Version NEXTVERSION -------------- **2026-08-??** + * New keywords to `cf.read`: ``backend``, ``backend_options``, ``cfa_filesystem``, ``cfa_backend``, ``cfa_backend_options`` (https://github.com/NCAS-CMS/cf-python/issues/961) From b4a1e89cdcea19ea33358ae2a0967f6e7e02c242 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:02:14 +0100 Subject: [PATCH 30/43] Typos Co-authored-by: Sadie L. Bartholomew --- cf/mixin/fielddomain.py | 2 +- cf/mixin/utils/grid_mapping.py | 9 +++++---- cf/mixin/utils/latlon_utils.py | 4 ++-- cf/read_write/read.py | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/cf/mixin/fielddomain.py b/cf/mixin/fielddomain.py index b082a01403..2bd226ef47 100644 --- a/cf/mixin/fielddomain.py +++ b/cf/mixin/fielddomain.py @@ -3935,7 +3935,7 @@ def to_xarray(self, group=True): {{class_lower}}s may be written to the same `xarray` dataset with `{{package}}.write` (e.g. ``ds = {{package}}.write([f, g], fmt='XARRAY')``). Also, `{{package}}.write` allows a - mixture a mixture of fields and domains to be written to the + mixture of fields and domains to be written to the same `xarray` dataset. An `xarray` dataset can be converted to one or more fields diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 84dd741865..72e7d8528e 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -194,7 +194,7 @@ def _create_pyproj_CRS(kwargs, cr, ellipsoid_only=False): :Parameters: kwargs: `dict` - A dictionary of keyword arguments for initialising the the + A dictionary of keyword arguments for initialising the `pyproj.CRS` instance. The keyword arguments should not include a description of @@ -284,7 +284,7 @@ def _cc_parameter(p, parameter, default=None): The name of the parameter. default: optional - What to do if the parmaeter doesn not exist (see above). + What to do if the parameter doesn't not exist (see above). :Returns: @@ -316,7 +316,8 @@ def albers_equal_area(cr): :Parameters: cr: `CoordinateReference` - The coordinate reference construct from the CRS is deived. + The coordinate reference construct from which the CRS is + derived. :Returns: @@ -761,7 +762,7 @@ def polar_stereographic(cr): def rotated_latitude_longitude(cr): - """Create a rotated_latitude_longitude CRS`. + """Create a rotated_latitude_longitude CRS. https://proj.org/en/stable/operations/projections/ob_tran.html diff --git a/cf/mixin/utils/latlon_utils.py b/cf/mixin/utils/latlon_utils.py index 8e68735d11..a8725e895e 100644 --- a/cf/mixin/utils/latlon_utils.py +++ b/cf/mixin/utils/latlon_utils.py @@ -43,7 +43,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): cr_latlon: `CoordinateReference` or `None` The coordinate reference construct for the - latitude_longitude grid mapping, or `None` is there isn't + latitude_longitude grid mapping, or `None` if there isn't one, in which case a spherical latitude_longitude grid mapping is assumed. @@ -206,7 +206,7 @@ def create_2d_latlon_coordinates(f, cr, cr_latlon, longitude_at_pole=None): xb = xb.array yb = yb.array - # Create meshes of and y vertices. + # Create meshes of x and y vertices. shape = (y.size, x.size) xb = np.broadcast_to(xb[np.newaxis, :, :], shape + (2,)) yb = np.broadcast_to(yb[:, np.newaxis, :], shape + (2,)) diff --git a/cf/read_write/read.py b/cf/read_write/read.py index e1f96489bc..4ec846acf3 100644 --- a/cf/read_write/read.py +++ b/cf/read_write/read.py @@ -559,7 +559,7 @@ def _finalise(self): # We can't trust the the standard_name to provide the # identity for UM fields (multiple STASH codes can # have the same standard name), so instead we have to - # the um_identity propery (which encapsulates the + # the um_identity property (which encapsulates the # submodel, stash/field code and UM version). aggregate_options["field_identity"] = "um_identity" From d99f91ec1beb5f0b18866c1c1cce658de0195b90 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 10:55:56 +0100 Subject: [PATCH 31/43] xnetcdf note --- Changelog.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Changelog.rst b/Changelog.rst index 848bec0f5d..6f7b43b844 100644 --- a/Changelog.rst +++ b/Changelog.rst @@ -8,8 +8,9 @@ Version NEXTVERSION (https://github.com/NCAS-CMS/cf-python/issues/961) * Deprecated keyword to `cfdm.read`: ``netcdf_backend`` (https://github.com/NCAS-CMS/cf-python/issues/961) -* Dataset reads are now entirely managed by `xnetcdf` (via - `cfdm.read`) (https://github.com/NCAS-CMS/cf-python/issues/961) +* Dataset reads are now entirely managed by `xnetcdf` and its backend + libraries (via `cfdm.read`) + (https://github.com/NCAS-CMS/cf-python/issues/961) * Read with `cf.read` anything that can be read by `xarray` (https://github.com/NCAS-CMS/cf-python/issues/961) * Convert `xarray.Dataset` and `xarray.DataTree` to `cf.Field` via From e845141218d08d0349bbac986c337a6b100415d9 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:19:27 +0100 Subject: [PATCH 32/43] Typo Co-authored-by: Sadie L. Bartholomew --- cf/test/test_kerchunk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf/test/test_kerchunk.py b/cf/test/test_kerchunk.py index aedf513dd7..0eb4202f7b 100644 --- a/cf/test/test_kerchunk.py +++ b/cf/test/test_kerchunk.py @@ -78,7 +78,7 @@ def test_read_dict(self): self.assertEqual(len(cf.read(kerchunk)), 1) def test_read_bytes(self): - """Test cfdm.read with a Kerchunk raw bytes representation.""" + """Test cf.read with a Kerchunk raw bytes representation.""" with open(kerchunk_file, "r") as fh: d = json.load(fh) From a19f852bf9d6280fc3131813fbc4e0d9afc2004e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:19:49 +0100 Subject: [PATCH 33/43] Corrected lat_ts description Co-authored-by: Sadie L. Bartholomew --- cf/mixin/utils/grid_mapping.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cf/mixin/utils/grid_mapping.py b/cf/mixin/utils/grid_mapping.py index 72e7d8528e..0e5735e8fa 100644 --- a/cf/mixin/utils/grid_mapping.py +++ b/cf/mixin/utils/grid_mapping.py @@ -35,11 +35,9 @@ * lat_2: Second standard parallel. -* lat_ts: Defines the latitude where scale is not distorted. It is - only taken into account for Polar Stereographic formulations - (lat_0 = +/- 90 ), and then defaults to the lat_0 value. If - set to a value different from +/- 90, it takes precedence - over k_0 if both options are used together. +* lat_ts: Latitude of true scale. Defines the latitude where scale + is not distorted. Takes precedence over +k_0 if both + options are used together. * lon_0: Central meridian/longitude of natural origin, longitude of origin or longitude of false origin (naming and meaning From c012a92a1de8befacc6c67856ab3052ce43695da Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:20:47 +0100 Subject: [PATCH 34/43] Typo Co-authored-by: Sadie L. Bartholomew --- cf/test/test_kerchunk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf/test/test_kerchunk.py b/cf/test/test_kerchunk.py index 0eb4202f7b..f1b8b38aa4 100644 --- a/cf/test/test_kerchunk.py +++ b/cf/test/test_kerchunk.py @@ -66,7 +66,7 @@ def test_kerchunk_original_filenames(self): self.assertEqual(k.get_original_filenames(), set()) def test_read_dict(self): - """Test cfdm.read with an Kerchunk dictionary.""" + """Test cf.read with a Kerchunk dictionary.""" with open(kerchunk_file, "r") as fh: d = json.load(fh) From de89ced1a83e4095aeba87dc79ad13779336cbef Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:19:06 +0100 Subject: [PATCH 35/43] checker order changes --- cf/test/test_zarr.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cf/test/test_zarr.py b/cf/test/test_zarr.py index edd315f0a4..d9b164126f 100644 --- a/cf/test/test_zarr.py +++ b/cf/test/test_zarr.py @@ -282,6 +282,9 @@ def test_zarr_groups_DSG(self): self.assertTrue(z.equals(n)) self.assertTrue(z.equals(f)) + # Check 'closest_ancestor' + cf.read(grouped_file, group_dimension_search="closest_ancestor") + def test_zarr_groups_geometry(self): """Test Zarr groups containing cell geometries.""" f = cf.example_field(6) From f70648b938567d1cb197e19c4241af3ad4140e3e Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:23:25 +0100 Subject: [PATCH 36/43] update comment --- cf/test/test_Field.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf/test/test_Field.py b/cf/test/test_Field.py index f79dcb7343..798709337c 100644 --- a/cf/test/test_Field.py +++ b/cf/test/test_Field.py @@ -3298,7 +3298,7 @@ def test_Field_create_latlon_coordinates_healpix(self): g.auxiliary_coordinate(c).equals(f.auxiliary_coordinate(c)) ) - # pole_longitude. Note that bounds index 0 is the + # longitude_at_pole. Note that bounds index 0 is the # northern-most vertex, and bounds index 2 is the # southern-most vertex. f = self.f12 From a13e32c125c5cbe0c38136c478d1f546ddb5eda9 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:29:56 +0100 Subject: [PATCH 37/43] remove umread_lib --- .github/workflows/run-test-suite.yml | 7 ------- .gitignore | 5 ----- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/run-test-suite.yml b/.github/workflows/run-test-suite.yml index 4f47b0cae3..d1b7cbb869 100644 --- a/.github/workflows/run-test-suite.yml +++ b/.github/workflows/run-test-suite.yml @@ -100,13 +100,6 @@ jobs: cd ${{ github.workspace }}/main pip install --no-deps -e . - # Make UMRead library - - name: Make UMRead - shell: bash -l {0} - run: | - cd ${{ github.workspace }}/main/cf/umread_lib/c-lib - make - # Install the coverage library # We do so with conda which was setup in a previous step. - name: Install coverage diff --git a/.gitignore b/.gitignore index 7a465fe460..fc6090568c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,11 +21,6 @@ cf/test/*.txt cf/test/.coverage cf/test/cf_coverage_report/ -# C object files generated by tests under cf/umread_lib/ and its sub-dirs: -cf/umread_lib/c-lib/*.o -cf/umread_lib/c-lib/*.so -cf/umread_lib/c-lib/type-dep/*.o - # packaging-related files changing with environment adjustments: *.egg-info/ From 6aca0fd3c5d8a6b61298c73903d6cac0f6dc846d Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:34:20 +0100 Subject: [PATCH 38/43] pyproj optional dependency --- docs/source/installation.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 9143c914bf..f9f8a25591 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -312,6 +312,13 @@ environments for which these features are not required. regridding, some changes to the refinement level, and some collapses. +.. rubric:: Grid mapping manipulations + +* `pyproj `_, version 3.7.2 or + newer. This package is required to create 2-d latitude and longitude + coordinates from grid mappings. + + ---- .. _Tests: From 77997f7db78ddab7754d51836bbe30e0ba682d22 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:46:10 +0100 Subject: [PATCH 39/43] remove proj.to_dict warning --- cf/test/test_2d_latlon.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cf/test/test_2d_latlon.py b/cf/test/test_2d_latlon.py index f7152450fe..297177d1bf 100644 --- a/cf/test/test_2d_latlon.py +++ b/cf/test/test_2d_latlon.py @@ -70,7 +70,7 @@ def field_paris(proj): y = f.dimension_coordinate("Y") y[...] = y_coords - rotated_latitude_longitude = proj.to_dict().get("proj") == "ob_tran" + rotated_latitude_longitude = "+proj=ob_tran" in proj.srs if rotated_latitude_longitude: x.standard_name = "grid_longitude" y.standard_name = "grid_latitude" From fe5c285bde1482af6fda6ccecda14b23d3d9d7c2 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:53:21 +0100 Subject: [PATCH 40/43] update cfdm version --- cf/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cf/__init__.py b/cf/__init__.py index 06ed22fb0f..35b6f81332 100644 --- a/cf/__init__.py +++ b/cf/__init__.py @@ -104,8 +104,8 @@ # Check the version of cfdm (this is worth doing because of the very # tight coupling between cf and cfdm, and the risk of bad things # happening at run time if the versions are mismatched). -_minimum_vn = "1.13.2.1" -_maximum_vn = "1.13.3.0" +_minimum_vn = "1.13.3.0" +_maximum_vn = "1.13.4.0" _cfdm_vn = Version(cfdm.__version__) if _cfdm_vn < Version(_minimum_vn) or _cfdm_vn >= Version(_maximum_vn): raise RuntimeError( From 7522b16f0417282ed733866b069ddc9b66a9cb0a Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:54:17 +0100 Subject: [PATCH 41/43] update cfdm version --- docs/source/installation.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index f9f8a25591..2ef7e90b8f 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -227,8 +227,8 @@ Required * `scipy `_, version 1.10.0 or newer. -* `cfdm `_, version 1.13.2.1 or up to, - but not including, 1.13.3.0. +* `cfdm `_, version 1.13.3.0 or up to, + but not including, 1.13.4.0. * `cfunits `_, version 3.3.7 or newer. From e02193839f20aa0590d01edaff887e220c1cdefc Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:54:47 +0100 Subject: [PATCH 42/43] update cfdm version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 26133c246d..5341bb8b79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ netCDF4>=1.7.2 cftime>=1.6.4 numpy>=2.0.0 -cfdm>=1.13.2.1, <1.13.3.0 +cfdm>=1.13.3.0, <1.13.4.0 psutil>=0.6.0 cfunits>=3.3.7 dask>=2025.5.1 From bebf6fe01ea1d713076b5024ee37942ab0d1cd20 Mon Sep 17 00:00:00 2001 From: David Hassell Date: Fri, 28 Aug 2026 11:57:24 +0100 Subject: [PATCH 43/43] linting --- generate_stub_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/generate_stub_files.py b/generate_stub_files.py index afa1632fc5..df6a114641 100644 --- a/generate_stub_files.py +++ b/generate_stub_files.py @@ -9,7 +9,6 @@ import re import sys - from pathlib import Path if len(sys.argv) > 1: @@ -19,6 +18,7 @@ OUT_DIR = SOURCE_DIR.parent + def generate_stub_files(rst_path: Path): content = rst_path.read_text(encoding="utf-8") @@ -54,7 +54,7 @@ def generate_stub_files(rst_path: Path): out_dir = OUT_DIR / "method" else: out_dir = OUT_DIR / "attribute" - + out_dir.mkdir(parents=True, exist_ok=True) out_file = out_dir / f"{entry}.rst"