Source code for iris.fileformats.netcdf.ugrid_load

# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.

r"""Allow the construction of :class:`~iris.mesh.MeshXY`.

Extension functions for Iris NetCDF loading, to construct
:class:`~iris.mesh.MeshXY` from UGRID data in files.

.. seealso::

    The UGRID Conventions,
    https://ugrid-conventions.github.io/ugrid-conventions/

"""

from itertools import groupby
from pathlib import Path
import warnings

from iris.config import get_logger
from iris.coords import AuxCoord
from iris.io import decode_uri, expand_filespecs
from iris.mesh.components import Connectivity, MeshXY
from iris.util import guess_coord_axis
from iris.warnings import IrisCfWarning, IrisDefaultingWarning, IrisIgnoringWarning

# NOTE: all imports from iris.fileformats.netcdf must be deferred, to avoid circular
# imports.
# This is needed so that load_mesh/load_meshes can be included in the iris.mesh API.


# Configure the logger.
logger = get_logger(__name__, propagate=True, handler=False)


class _WarnComboCfDefaulting(IrisCfWarning, IrisDefaultingWarning):
    """One-off combination of warning classes - enhances user filtering."""

    pass


class _WarnComboCfDefaultingIgnoring(_WarnComboCfDefaulting, IrisIgnoringWarning):
    """One-off combination of warning classes - enhances user filtering."""

    pass


def _meshes_from_cf(cf_reader):
    """Mesh from cf, common behaviour for extracting meshes from a CFReader.

    Simple now, but expected to increase in complexity as Mesh sharing develops.

    """
    # Mesh instances are shared between file phenomena.
    # TODO: more sophisticated Mesh sharing between files.
    # TODO: access external Mesh cache?
    meshes = {}
    if cf_reader._with_ugrid:
        mesh_vars = cf_reader.cf_group.meshes
        meshes = {
            name: _build_mesh(cf_reader, var, cf_reader.filename)
            for name, var in mesh_vars.items()
        }
    return meshes


[docs] def load_mesh(uris, var_name=None): """Load a single :class:`~iris.mesh.MeshXY` object from one or more NetCDF files. Raises an error if more/less than one :class:`~iris.mesh.MeshXY` is found. Parameters ---------- uris : str or iterable of str One or more filenames/URI's. Filenames can include wildcards. Any URI's must support OpenDAP. var_name : str, optional Only return a :class:`~iris.mesh.MeshXY` if its var_name matches this value. Returns ------- :class:`iris.mesh.MeshXY` """ meshes_result = load_meshes(uris, var_name) result = set([mesh for file in meshes_result.values() for mesh in file]) mesh_count = len(result) if mesh_count != 1: message = f"Expecting 1 mesh, but input file(s) produced: {mesh_count} ." raise ValueError(message) return result.pop() # Return the single element
[docs] def load_meshes(uris, var_name=None): r"""Load :class:`~iris.mesh.MeshXY` objects from one or more NetCDF files. Parameters ---------- uris : str or iterable of str One or more filenames/URI's. Filenames can include wildcards. Any URI's must support OpenDAP. var_name : str, optional Only return :class:`~iris.mesh.MeshXY` that have var_names matching this value. Returns ------- dict A dictionary mapping each mesh-containing file path/URL in the input ``uris`` to a list of the :class:`~iris.mesh.MeshXY` returned from each. """ # NOTE: no constraints or callbacks supported - these assume they are operating # on a Cube. # NOTE: dynamic imports avoid circularity : see note with module imports from iris.fileformats import FORMAT_AGENT from iris.fileformats.cf import CFReader import iris.fileformats.netcdf.loader as nc_loader if isinstance(uris, str): uris = [uris] # Group collections of uris by their iris handler # Create list of tuples relating schemes to part names. uri_tuples = sorted(decode_uri(uri) for uri in uris) valid_sources = [] for scheme, groups in groupby(uri_tuples, key=lambda x: x[0]): # Call each scheme handler with the appropriate URIs if scheme == "file": filenames = [x[1] for x in groups] sources = expand_filespecs(filenames) elif scheme in ["http", "https"]: sources = [":".join(x) for x in groups] else: message = f"Iris cannot handle the URI scheme: {scheme}" raise ValueError(message) for source in sources: if scheme == "file": with open(source, "rb") as fh: handling_format_spec = FORMAT_AGENT.get_spec(Path(source).name, fh) else: handling_format_spec = FORMAT_AGENT.get_spec(source, None) if handling_format_spec.handler == nc_loader.load_cubes: valid_sources.append(source) else: message = f"Ignoring non-NetCDF file: {source}" logger.info(msg=message, extra=dict(cls=None)) result = {} for source in valid_sources: with CFReader(source) as cf_reader: meshes_dict = _meshes_from_cf(cf_reader) meshes = list(meshes_dict.values()) if var_name is not None: meshes = list(filter(lambda m: m.var_name == var_name, meshes)) if meshes: result[source] = meshes return result
def _build_aux_coord(coord_var, file_path): """Construct a :class:`~iris.coords.AuxCoord`. Construct a :class:`~iris.coords.AuxCoord` from a given :class:`~iris.fileformats.cf.CFUGridAuxiliaryCoordinateVariable`, and guess its mesh axis. """ # NOTE: dynamic imports avoid circularity : see note with module imports from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names from iris.fileformats.cf import CFUGridAuxiliaryCoordinateVariable from iris.fileformats.netcdf import loader as nc_loader assert isinstance(coord_var, CFUGridAuxiliaryCoordinateVariable) attributes = {} attr_units = get_attr_units(coord_var, attributes) points_data = nc_loader._get_cf_var_data(coord_var, file_path) # Bounds will not be loaded: # Bounds may be present, but the UGRID conventions state this would # always be duplication of the same info provided by the mandatory # connectivities. # Fetch climatological - not allowed for a Mesh, but loading it will # mean an informative error gets raised. climatological = False # TODO: use CF_ATTR_CLIMATOLOGY on re-integration, when no longer # 'experimental'. attr_climatology = getattr(coord_var, "climatology", None) if attr_climatology is not None: climatology_vars = coord_var.cf_group.climatology climatological = attr_climatology in climatology_vars standard_name, long_name, var_name = get_names(coord_var, None, attributes) coord = AuxCoord( points_data, standard_name=standard_name, long_name=long_name, var_name=var_name, units=attr_units, attributes=attributes, # TODO: coord_system climatological=climatological, ) axis = guess_coord_axis(coord) if axis is None: if var_name[-2] == "_": # Fall back on UGRID var_name convention. axis = var_name[-1] else: message = f"Cannot guess axis for UGRID coord: {var_name} ." raise ValueError(message) return coord, axis def _build_connectivity(connectivity_var, file_path, element_dims): """Construct a :class:`~iris.mesh.Connectivity`. Construct a :class:`~iris.mesh.Connectivity` from a given :class:`~iris.fileformats.cf.CFUGridConnectivityVariable`, and identify the name of its first dimension. """ # NOTE: dynamic imports avoid circularity : see note with module imports from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names from iris.fileformats.cf import CFUGridConnectivityVariable from iris.fileformats.netcdf import loader as nc_loader assert isinstance(connectivity_var, CFUGridConnectivityVariable) attributes = {} attr_units = get_attr_units(connectivity_var, attributes) indices_data = nc_loader._get_cf_var_data(connectivity_var, file_path) cf_role = connectivity_var.cf_role start_index = connectivity_var.start_index dim_names = connectivity_var.dimensions # Connectivity arrays must have two dimensions. assert len(dim_names) == 2 if dim_names[1] in element_dims: location_axis = 1 else: location_axis = 0 standard_name, long_name, var_name = get_names(connectivity_var, None, attributes) connectivity = Connectivity( indices=indices_data, cf_role=cf_role, standard_name=standard_name, long_name=long_name, var_name=var_name, units=attr_units, attributes=attributes, start_index=start_index, location_axis=location_axis, ) return connectivity, dim_names[0] def _build_mesh(cf, mesh_var, file_path): """Construct a :class:`~iris.mesh.MeshXY`. Construct a :class:`~iris.mesh.MeshXY` from a given :class:`~iris.fileformats.cf.CFUGridMeshVariable`. """ # NOTE: dynamic imports avoid circularity : see note with module imports from iris.fileformats._nc_load_rules.helpers import get_attr_units, get_names from iris.fileformats.cf import CFUGridMeshVariable from iris.fileformats.netcdf import loader as nc_loader assert isinstance(mesh_var, CFUGridMeshVariable) attributes = {} attr_units = get_attr_units(mesh_var, attributes) cf_role_message = None if not hasattr(mesh_var, "cf_role"): cf_role_message = f"{mesh_var.cf_name} has no cf_role attribute." cf_role = "mesh_topology" else: cf_role = getattr(mesh_var, "cf_role") if cf_role != "mesh_topology": cf_role_message = f"{mesh_var.cf_name} has an inappropriate cf_role: {cf_role}." if cf_role_message: cf_role_message += " Correcting to 'mesh_topology'." warnings.warn( cf_role_message, category=_WarnComboCfDefaulting, ) if hasattr(mesh_var, "volume_node_connectivity"): topology_dimension = 3 elif hasattr(mesh_var, "face_node_connectivity"): topology_dimension = 2 elif hasattr(mesh_var, "edge_node_connectivity"): topology_dimension = 1 else: # Nodes only. We aren't sure yet whether this is a valid option. topology_dimension = 0 if not hasattr(mesh_var, "topology_dimension"): msg = ( f"MeshXY variable {mesh_var.cf_name} has no 'topology_dimension'" f" : *Assuming* topology_dimension={topology_dimension}" ", consistent with the attached connectivities." ) warnings.warn(msg, category=_WarnComboCfDefaulting) else: quoted_topology_dimension = mesh_var.topology_dimension if quoted_topology_dimension != topology_dimension: msg = ( f"*Assuming* 'topology_dimension'={topology_dimension}" f", from the attached connectivities of the mesh variable " f"{mesh_var.cf_name}. However, " f"{mesh_var.cf_name}:topology_dimension = " f"{quoted_topology_dimension}" " -- ignoring this as it is inconsistent." ) warnings.warn( msg, category=_WarnComboCfDefaultingIgnoring, ) node_dimension = None edge_dimension = getattr(mesh_var, "edge_dimension", None) face_dimension = getattr(mesh_var, "face_dimension", None) node_coord_args = [] edge_coord_args = [] face_coord_args = [] for coord_var in mesh_var.cf_group.ugrid_coords.values(): coord_and_axis = _build_aux_coord(coord_var, file_path) coord = coord_and_axis[0] if coord.var_name in mesh_var.node_coordinates.split(): node_coord_args.append(coord_and_axis) node_dimension = coord_var.dimensions[0] elif coord.var_name in getattr(mesh_var, "edge_coordinates", "").split(): edge_coord_args.append(coord_and_axis) elif coord.var_name in getattr(mesh_var, "face_coordinates", "").split(): face_coord_args.append(coord_and_axis) # TODO: support volume_coordinates. else: message = ( f"Invalid UGRID coord: {coord.var_name} . Must be either a" f"node_, edge_ or face_coordinate." ) raise ValueError(message) if node_dimension is None: message = "'node_dimension' could not be identified from mesh node coordinates." raise ValueError(message) # Used for detecting transposed connectivities. element_dims = (edge_dimension, face_dimension) connectivity_args = [] for connectivity_var in mesh_var.cf_group.connectivities.values(): connectivity, first_dim_name = _build_connectivity( connectivity_var, file_path, element_dims ) assert connectivity.var_name == getattr(mesh_var, connectivity.cf_role) connectivity_args.append(connectivity) # If the mesh_var has not supplied the dimension name, it is safe to # fall back on the connectivity's first dimension's name. if edge_dimension is None and connectivity.location == "edge": edge_dimension = first_dim_name if face_dimension is None and connectivity.location == "face": face_dimension = first_dim_name standard_name, long_name, var_name = get_names(mesh_var, None, attributes) mesh = MeshXY( topology_dimension=topology_dimension, node_coords_and_axes=node_coord_args, connectivities=connectivity_args, edge_coords_and_axes=edge_coord_args, face_coords_and_axes=face_coord_args, standard_name=standard_name, long_name=long_name, var_name=var_name, units=attr_units, attributes=attributes, node_dimension=node_dimension, edge_dimension=edge_dimension, face_dimension=face_dimension, ) mesh_elements = list(mesh.all_coords) + list(mesh.all_connectivities) + [mesh] mesh_elements = filter(None, mesh_elements) for iris_object in mesh_elements: nc_loader._add_unused_attributes(iris_object, cf.cf_group[iris_object.var_name]) return mesh def _build_mesh_coords(mesh, cf_var): """Construct a tuple of :class:`~iris.mesh.MeshCoord`. Construct a tuple of :class:`~iris.mesh.MeshCoord` using from a given :class:`~iris.mesh.MeshXY` and :class:`~iris.fileformats.cf.CFVariable`. """ # Identify the cube's mesh dimension, for attaching MeshCoords. element_dimensions = { "node": mesh.node_dimension, "edge": mesh.edge_dimension, "face": mesh.face_dimension, } location = getattr(cf_var, "location", "<empty>") if location is None or location not in element_dimensions: # We should probably issue warnings and recover, but that is too much # work. Raising a more intelligible error is easy to do though. msg = ( f"mesh data variable {cf_var.name!r} has an invalid location={location!r}." ) raise ValueError(msg) mesh_dim_name = element_dimensions.get(location) if mesh_dim_name is None: msg = f"mesh {mesh.name!r} has no {location} dimension." raise ValueError(msg) if mesh_dim_name in cf_var.dimensions: mesh_dim = cf_var.dimensions.index(mesh_dim_name) else: msg = ( f"mesh data variable {cf_var.name!r} does not have the " f"{location} mesh dimension {mesh_dim_name!r}, in its dimensions." ) raise ValueError(msg) mesh_coords = mesh.to_MeshCoords(location=cf_var.location) return mesh_coords, mesh_dim