Source code for xugrid.ugrid.conventions

"""
This module deals with extracting the relevant data from the UGRID attributes.

It takes some inspiration from: https://github.com/xarray-contrib/cf-xarray
"""

import warnings
from collections import ChainMap
from itertools import chain
from typing import Dict, List, Optional, Set, Tuple

import xarray as xr


class UgridDimensionError(Exception):
    pass


class UgridCoordinateError(Exception):
    pass


_DIM_NAMES = {
    1: ("node_dimension", "edge_dimension"),
    2: ("node_dimension", "face_dimension", "edge_dimension"),
}

_COORD_NAMES = {
    1: ("node_coordinates", "edge_coordinates"),
    2: ("node_coordinates", "face_coordinates", "edge_coordinates"),
}
_COORD_DIMS = {
    "node_coordinates": "node_dimension",
    "edge_coordinates": "edge_dimension",
    "face_coordinates": "face_dimension",
}

_CONNECTIVITY_NAMES = {
    1: ("edge_node_connectivity",),
    2: (
        "face_node_connectivity",
        "edge_node_connectivity",
        "face_edge_connectivity",
        "face_face_connectivity",
        "edge_face_connectivity",
        "boundary_node_connectivity",
    ),
}

_CONNECTIVITY_DIMS = {
    "face_node_connectivity": ("face_dimension", None),
    "edge_node_connectivity": ("edge_dimension", 2),
    "face_edge_connectivity": ("face_dimension", None),
    "face_face_connectivity": ("face_dimension", None),
    "edge_face_connectivity": ("edge_dimension", 2),
    "boundary_node_connectivity": ("boundary_edge_dimension", 2),
}

X_STANDARD_NAMES = ("projection_x_coordinate", "longitude")
Y_STANDARD_NAMES = ("projection_y_coordinate", "latitude")

PROJECTED = True
GEOGRAPHIC = False
DEFAULT_ATTRS = {
    "node_x": {
        PROJECTED: {
            "standard_name": "projection_x_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "longitude",
        },
    },
    "node_y": {
        PROJECTED: {
            "standard_name": "projection_y_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "latitude",
        },
    },
    "edge_x": {
        PROJECTED: {
            "standard_name": "projection_x_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "longitude",
        },
    },
    "edge_y": {
        PROJECTED: {
            "standard_name": "projection_y_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "latitude",
        },
    },
    "face_x": {
        PROJECTED: {
            "standard_name": "projection_x_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "longitude",
        },
    },
    "face_y": {
        PROJECTED: {
            "standard_name": "projection_y_coordinate",
        },
        GEOGRAPHIC: {
            "standard_name": "latitude",
        },
    },
    "face_node_connectivity": {
        "cf_role": "face_node_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
    "edge_node_connectivity": {
        "cf_role": "edge_node_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
    "face_edge_connectivity": {
        "cf_role": "face_edge_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
    "face_face_connectivity": {
        "cf_role": "face_face_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
    "edge_face_connectivity": {
        "cf_role": "edge_face_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
    "boundary_node_connectivity": {
        "cf_role": "boundary_node_connectivity",
        "start_index": 0,
        "_FillValue": -1,
    },
}


def default_topology_attrs(name: str, topology_dimension: int):
    if topology_dimension == 1:
        return {
            "cf_role": "mesh_topology",
            "long_name": "Topology data of 1D network",
            "topology_dimension": 1,
            "node_dimension": f"{name}_nNodes",
            "edge_dimension": f"{name}_nEdges",
            "edge_node_connectivity": f"{name}_edge_nodes",
            "node_coordinates": f"{name}_node_x {name}_node_y",
            "edge_coordinates": f"{name}_edge_x {name}_edge_y",
        }
    elif topology_dimension == 2:
        return {
            "cf_role": "mesh_topology",
            "long_name": "Topology data of 2D mesh",
            "topology_dimension": 2,
            "node_dimension": f"{name}_nNodes",
            "edge_dimension": f"{name}_nEdges",
            "face_dimension": f"{name}_nFaces",
            "max_face_nodes_dimension": f"{name}_nMax_face_nodes",
            "boundary_edge_dimension": f"{name}_nBoundary_edges",
            "edge_node_connectivity": f"{name}_edge_nodes",
            "face_node_connectivity": f"{name}_face_nodes",
            "face_edge_connectivity": f"{name}_face_edges",
            "edge_face_connectivity": f"{name}_edge_faces",
            "boundary_node_connectivity": f"{name}_boundary_nodes",
            "face_face_connectivity": f"{name}_face_faces",
            "node_coordinates": f"{name}_node_x {name}_node_y",
            "edge_coordinates": f"{name}_edge_x {name}_edge_y",
            "face_coordinates": f"{name}_face_x {name}_face_y",
        }
    else:
        raise ValueError(
            f"topology_dimensions should be 1 or 2, received {topology_dimension}"
        )


def _get_topology(ds: xr.Dataset) -> List[str]:
    return [
        var
        for var in ds.data_vars
        if ds.variables[var].attrs.get("cf_role") == "mesh_topology"
    ]


def _infer_xy_coords(
    ds: xr.Dataset, candidates: List[str]
) -> Tuple[List[str], List[str]]:
    # TODO: add argument for latitude / longitude?
    x = []
    y = []
    for candidate in candidates:
        stdname = ds[candidate].attrs.get("standard_name")
        if stdname in X_STANDARD_NAMES:
            x.append(candidate)
        elif stdname in Y_STANDARD_NAMES:
            y.append(candidate)

    if not x and not y:
        first = candidates[0]
        second = candidates[1]
        warnings.warn(
            f"No standard_name of {X_STANDARD_NAMES + Y_STANDARD_NAMES} in {candidates}.\n"
            f"Using {first} and {second} as projected x and y coordinates.",
            UserWarning,
            stacklevel=2,
        )
        x.append(first)
        y.append(second)
    elif not x:
        raise UgridCoordinateError(
            "No standard_name of {X_STANDARD_NAMES} in {candidates}"
        )
    elif not y:
        raise UgridCoordinateError(
            "No standard_name of {Y_STANDARD_NAMES} in {candidates}"
        )

    return x, y


def _get_coordinates(
    ds: xr.Dataset, topologies: List[str]
) -> Dict[str, Dict[str, Tuple[List[str], List[str]]]]:
    topology_dict = {}
    for topology in topologies:
        attrs = ds[topology].attrs
        topodim = attrs["topology_dimension"]
        vardict = {}
        for name in _COORD_NAMES[topodim]:
            if name in attrs:
                candidates = [c for c in attrs[name].split(" ") if c in ds]
                if len(candidates) == 0:
                    warnings.warn(
                        f"the following variables are specified for UGRID {name}: "
                        f'"{attrs[name]}", but they are not present in the dataset',
                        UserWarning,
                        stacklevel=2,
                    )
                    continue
                if len(candidates) < 2:
                    raise UgridCoordinateError(
                        f"{topology}: at least two values required for UGRID {name},"
                        f' while only "{attrs[name]}" are specified.'
                    )
                vardict[name] = _infer_xy_coords(ds, candidates)

        topology_dict[topology] = vardict

    return topology_dict


def _infer_dims(
    ds: xr.Dataset,
    connectivities: Dict[str, str],
    coordinates: Dict[str, Dict[str, Tuple[List[str]]]],
    vardict: Dict[str, str],
) -> Dict[str, str]:
    """Infer dimensions based on connectivity and coordinates."""
    inferred = {}
    for role, varname in connectivities.items():
        key0, key1 = _CONNECTIVITY_DIMS[role]
        var_dims = ds[varname].dims
        if len(var_dims) != 2:
            raise UgridDimensionError(
                f"Expected {varname} with role {role} to have exactly 2 "
                f"dimensions, found {len(var_dims)}: {var_dims}"
            )

        # Determine orientation using the named dimension.
        declared = vardict.get(key0) or inferred.get(key0)
        dim0, dim1 = var_dims
        if declared is not None:
            if declared not in var_dims:
                raise UgridDimensionError(
                    f"{key0}: {declared} not in {role}: {varname} "
                    f"with dimensions: {var_dims}"
                )
            if declared != dim0:
                dim0, dim1 = dim1, dim0

        if isinstance(key1, int) and ds.sizes[dim1] != key1:
            raise UgridDimensionError(
                f"Expected size {key1} for dimension {dim1} in variable "
                f"{varname} with role {role}, found instead: {ds.sizes[dim1]}"
            )

        inferred[key0] = dim0

    for role, varnames in coordinates.items():
        key = _COORD_DIMS[role]
        declared = vardict.get(key) or inferred.get(key)
        for varname in chain.from_iterable(varnames):
            var_dims = ds[varname].dims
            if len(var_dims) != 1:
                continue
            var_dim = var_dims[0]

            if declared is None:
                inferred[key] = var_dim
                declared = var_dim
            elif declared != var_dim:
                raise UgridDimensionError(
                    f"Conflicting names for {key}: {declared} versus {var_dim}"
                )

    return inferred


def _get_dimensions(
    ds: xr.Dataset,
    topologies: List[str],
    connectivity: Dict[str, Dict[str, str]],
    coordinates: Dict[str, Dict[str, Tuple[List[str]]]],
) -> Dict[str, Dict[str, str]]:
    """
    Get the dimensions from the topology attributes and infer them from
    connectivity arrays or coordinates.
    """
    topology_dict = {}
    for topology in topologies:
        attrs = ds[topology].attrs
        topodim = attrs["topology_dimension"]
        # dimensions are optionally required: only if the dimension order is
        # nonstandard in any of the connectivity variables.
        vardict = {k: attrs[k] for k in _DIM_NAMES[topodim] if k in attrs}
        inferred = _infer_dims(
            ds, connectivity[topology], coordinates[topology], vardict
        )
        topology_dict[topology] = {**inferred, **vardict}

    return topology_dict


def _get_connectivity(
    ds: xr.Dataset, topologies: List[str]
) -> Dict[str, Dict[str, str]]:
    topology_dict = {}
    for topology in topologies:
        attrs = ds[topology].attrs
        topodim = attrs["topology_dimension"]
        topology_dict[topology] = {
            k: attrs[k]
            for k in _CONNECTIVITY_NAMES[topodim]
            if (k in attrs) and (attrs[k] in ds)
        }
    return topology_dict


def _get_grid_mapping_names(
    ds: xr.Dataset,
    topologies: List[str],
    dimensions: Dict[str, Dict[str, str]],
) -> Dict[str, str | None]:
    topology_dict = {}
    varnames = set(ds.variables.keys())
    for topology in topologies:
        topology_dict[topology] = None
        # The grid mapping should be specified per variable.
        # Check which variables have the relevant UGRID dimensions, and extract
        # the grid mapping.
        topo_dims = set(dimensions[topology].values())
        names = {
            var.attrs.get("grid_mapping") or var.encoding.get("grid_mapping")
            for var in ds.variables.values()
            if topo_dims & set(var.dims)
        } - {None}

        if names:
            # In principle, multiple coordinates are allowed to be specified
            # in the topology variable. Let's say there are two sets of coordinates
            # node_coordinates: "mesh2d_node_x1 mesh2d_node_y1 mesh2d_node_x2 mesh2d_node_y2"
            # In this case, we need a grid mapping for (x1, y1) and for (x2, y2),
            # but given that the grid mapping is defined on a data variable, there is no
            # way to link them correctly. Hence the ValueError.
            # See also: https://github.com/ugrid-conventions/ugrid-conventions/issues/64
            if len(names) > 1:
                raise ValueError(
                    f"Multiple grid mappings found for topology '{topology}': "
                    f"{names}. Variables on the same topology are expected to "
                    f"share a single coordinate reference system (CRS). "
                    f"Load the dataset with xarray.open_dataset() and modify "
                    f"the grid_mapping attributes before converting to a "
                    f"UgridDataset."
                )
            name = next(iter(names))
            if name in varnames:
                topology_dict[topology] = name
            else:
                warnings.warn(
                    "The following grid mapping variable is specified in the attribute\n"
                    f"or encoding of one or more variables, but is not present in the dataset: {name}",
                    UserWarning,
                    stacklevel=2,
                )

    return topology_dict


def _infer_projected(
    ds: xr.Dataset,
    topologies: List[str],
    coordinates: Dict[str, Dict[str, Tuple[List[str], List[str]]]],
) -> Dict[str, bool | None]:
    topology_dict = {}
    for topology in topologies:
        inferred = []
        for role, (x_vars, y_vars) in coordinates[topology].items():
            for x_varname, y_varname in zip(x_vars, y_vars):
                # Check x
                stdname = ds[x_varname].attrs.get("standard_name")
                if stdname == X_STANDARD_NAMES[0]:
                    inferred.append((x_varname, True))
                elif stdname == X_STANDARD_NAMES[1]:
                    inferred.append((x_varname, False))
                # Check y
                stdname = ds[y_varname].attrs.get("standard_name")
                if stdname == Y_STANDARD_NAMES[0]:
                    inferred.append((y_varname, True))
                elif stdname == Y_STANDARD_NAMES[1]:
                    inferred.append((y_varname, False))

        # In principle, a geocentric CRS like EPSG:4328 is neither projected
        # nor geographic, but it is very niche we cannot easily support it
        # within xugrid.
        values = {v for _, v in inferred}
        if len(values) == 0:
            projected = None
        elif len(values) == 1:
            projected = values.pop()
        else:
            details = ", ".join(
                f"{n}: {'projected' if v else 'geographic'}" for n, v in inferred
            )
            warnings.warn(
                f"Inconsistent standard_names across coordinates for topology "
                f"'{topology}': {details}. Returning None.",
                UserWarning,
                stacklevel=2,
            )
            projected = None
        topology_dict[topology] = projected

    return topology_dict


[docs] @xr.register_dataset_accessor("ugrid_roles") class UgridRolesAccessor: """ Xarray Dataset "accessor" to retrieve the names of UGRID variables. Examples -------- To get a list of the UGRID dummy variables in the dataset: >>> dataset.ugrid_roles.topology To get the names of the connectivity variables in the dataset: >>> dataset.ugrid_roles.connectivity Names can also be accessed directly through the topology: >>> dataset.ugrid_roles["mesh2d"]["node_dimension"] """
[docs] def __init__(self, ds: xr.Dataset): self._ds = ds
def __getitem__(self, key: str): if key not in self.topology: raise KeyError(key) return ChainMap( self.dimensions[key], self.coordinates[key], self.connectivity[key] ) @property def topology(self) -> List[str]: """ Get the names of the topology dummy variables, marked by a CF-role of ``mesh_topology``. Returns ------- topology: List[str] """ return _get_topology(self._ds) @property def coordinates(self) -> Dict[str, Dict[str, Tuple[List[str], List[str]]]]: """ Get the names of the coordinate variables from the topology attributes. Returns a dictionary with the coordinates for the UGRID coordinates: * node coordinates * edge coordinates * face coordinates Multiple coordinates may be defined. The coordinates are grouped by their role (x or y). Returns ------- coordinates: dict[str, dict[str, Tuple[List[str]]]] """ return _get_coordinates(self._ds, self.topology) @property def dimensions(self) -> Dict[str, Dict[str, str]]: """ Get the dimension names from the topology attributes and infer them from connectivity arrays or coordinates. Returns a dictionary with the UGRID dimensions per topology: * node dimension * edge dimension * face dimension Returns ------- dimensions: dict[str, dict[str, str]] """ return _get_dimensions( self._ds, self.topology, self.connectivity, self.coordinates ) @property def connectivity(self) -> Dict[str, Dict[str, str]]: """ Get the names of the variables containing the UGRID connectivity data. * face_node_connectivity * edge_node_connectivity * face_edge_connectivity * edge_face_connectivity Returns ------- connectivity: Dict[str, Dict[str, str]] """ return _get_connectivity(self._ds, self.topology) @property def grid_mapping_names(self) -> Dict[str, Optional[Set[str]]]: """ Get the names of the grid mapping variables associated with each topology. Returns ------- grid_mapping: dict[str, str | None] """ return _get_grid_mapping_names(self._ds, self.topology, self.dimensions) @property def is_projected(self) -> Dict[str, bool | None]: """ Infer whether each topology uses projected or geographic coordinates from the standard_name attributes of the coordinate variables. Returns ------- is_projected: dict[str, bool | None] True if projected, False if geographic, None if indeterminate. """ return _infer_projected(self._ds, self.topology, self.coordinates) def __repr__(self): dimensions = self.dimensions coordinates = self.coordinates connectivity = self.connectivity grid_mapping_names = self.grid_mapping_names is_projected = self.is_projected def make_text_section(subtitle, entries, vardict): tab = " " rows = [f"{tab}{subtitle}"] for role in entries: if role in vardict: rows += [f"{tab}{tab}{role}: {vardict[role]}"] else: rows += [f"{tab}{tab}{role}: n/a"] rows.append("") return rows rows = [] for topology in self.topology: topodim = self._ds[topology].attrs["topology_dimension"] rows += [f"UGRID {topodim}D Topology {topology}:"] rows += make_text_section( "Dimensions:", _DIM_NAMES[topodim], dimensions[topology] ) rows += make_text_section( "Connectivity:", _CONNECTIVITY_NAMES[topodim], connectivity[topology] ) rows += make_text_section( "Coordinates:", _COORD_NAMES[topodim], coordinates[topology] ) # CRS summary line name = grid_mapping_names[topology] projected = is_projected[topology] if projected is True: crs_type = "projected" elif projected is False: crs_type = "geographic" else: crs_type = "unknown" name_str = name if name is not None else "n/a" rows += [ f" Coordinate Type: {crs_type}", f"Grid Mapping Name: {name_str}", "", ] return "\n".join(rows)