Source code for grid_process.data_model

# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
#
# Distributed under the terms of the BSD 3-Clause License.
#
# SPDX-License-Identifier: BSD-3-Clause

"""
Module with utility functions to deal with the numerous supported data models
"""

import logging
import json
from typing import Any, TypeVar, Literal, overload
from collections.abc import Callable

import numpy as np
from numpy.typing import NDArray
from pyproj import CRS
import geopandas as gpd
import xarray as xr

from grid_process import CONFIG

logger = logging.getLogger(__name__)

F = TypeVar("F", bound=Callable[..., Any])

DATA_MODEL = CONFIG.data_model
FLAT_DIM: list[str] = DATA_MODEL.grid.unstructured_dim
YX: tuple[tuple[str, str], ...] = tuple(zip(DATA_MODEL.grid.y, DATA_MODEL.grid.x))
LATLON: tuple[tuple[str, str], ...] = tuple(yx for yx in YX if "lat" in yx[0] and "lon" in yx[1])
SURF_ALT: list[str] = DATA_MODEL.grid.surf_alt
REFTIME: list[str] = DATA_MODEL.reftime
VALIDTIME: list[str] = DATA_MODEL.validtime
LEADTIME: list[str] = DATA_MODEL.leadtime
ENS_DIM: list[str] = DATA_MODEL.ens_dim
POI_ALT_COL: list[str] = DATA_MODEL.poi.alt_from


[docs] def get_t_dim(ds: xr.Dataset) -> str: """ Returns name of temporal dimension according to the possible options defined in the config (could be overwritten by setting appropriate env variable). Temporal dimension could be expressed as valid time or lead time. """ validtime_dim = get_validtime(ds) if validtime_dim is not None: return validtime_dim leadtime_dim = get_leadtime(ds) if leadtime_dim is not None: return leadtime_dim raise ValueError( f"Temporal dimension must either be one of {VALIDTIME} (valid time) or " f"{LEADTIME} (lead time) or have an adequate CF standard_name attribute" )
[docs] def get_temporal_dims( data: xr.Dataset | xr.DataArray, ) -> tuple[str | None, str | None, str | None]: """ Returns names of temporal dimensions according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. The returned tuple contains dimension names in the following order: (reftime, leadtime, validtime). """ return _get_temporal(data, "dim")
[docs] def get_temporal_coords( data: xr.Dataset | xr.DataArray, ) -> tuple[str | None, str | None, str | None]: """ Returns names of temporal coordinates according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. The returned tuple contains coordinate names in the following order: (reftime, leadtime, validtime). """ return _get_temporal(data, "coord")
def _get_temporal( data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] ) -> tuple[str | None, str | None, str | None]: """ Returns names of temporal dimensions or coordinates. """ reftime = get_reftime(data, kind) leadtime = get_leadtime(data, kind) validtime = get_validtime(data, kind) return reftime, leadtime, validtime
[docs] def get_reftime(data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] = "dim") -> str | None: """ Returns name of forecast reference time dimension or coordinate according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. """ return _get_supported_dimcoord(data, kind, REFTIME, "forecast_reference_time")
@overload def get_validtime( data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] = "dim", raise_notfound: Literal[True] = True, ) -> str: ... @overload def get_validtime( data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] = "dim", raise_notfound: Literal[False] = False, ) -> str | None: ...
[docs] def get_validtime( data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] = "dim", raise_notfound: bool = False, ) -> str | None: """ Returns name of valid time dimension or coordinate according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. When raise_notfound is set to True, raise a ValueError if a valid time dimension/coordinate can not be found. """ validtime = _get_supported_dimcoord(data, kind, VALIDTIME, "time") if raise_notfound and validtime is None: raise ValueError( f"Could not find a valid time dimension. Must be one of {VALIDTIME}" " or have the CF standard_name attribute 'time'" ) return validtime
[docs] def get_leadtime(data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"] = "dim") -> str | None: """ Returns name of lead time dimension or coordinate according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. """ return _get_supported_dimcoord(data, kind, LEADTIME, "forecast_period")
def _get_supported_dimcoord( data: xr.Dataset | xr.DataArray, kind: Literal["dim", "coord"], config_options: list[str], cf_standard: str, ) -> str | None: """ Returns name of existing dimension or coordinate in ``data`` from the ``config_options`` list or with a CF conventions standard_name attribute ``cf_standard``. """ dim_coord: tuple[str, ...] = tuple(data.coords) if kind == "coord" else tuple(data.dims) # type: ignore[arg-type] for name in config_options: if name in dim_coord: return name for name in dim_coord: if data[name].attrs.get("standard_name", "").lower() == cf_standard: return name return None
[docs] def get_ensemble_dim(data: xr.Dataset | xr.DataArray) -> str: """ Returns name of ensemble members dimension according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF standard name. """ ens_dim = _get_supported_dimcoord(data, "dim", ENS_DIM, "realization") if ens_dim is None: raise ValueError( "Could not find an ensemble members dimension. Must be one of " f"{ENS_DIM} or have the CF standard_name attribute 'realization'" ) return ens_dim
[docs] def set_grid_mapping(ds: xr.Dataset) -> xr.Dataset: """ Set grid_mapping attribute according to internal conventions when the input grid follow CF conventions (grid mapping encoded as a data variable). The data variable encoding the grid mapping is removed. """ def serializable_mapping() -> dict[str, Any]: return { key: float(value) if isinstance(value, np.floating) else value for key, value in ds_out[mapname].attrs.items() } ds_out = ds.copy(deep=False) grid_map = set() for varname, da in ds_out.items(): if "grid_mapping" in da.attrs: grid_map.add(da.attrs.pop("grid_mapping")) elif "grid_mapping_name" in da.attrs: grid_map.add(varname) if len(grid_map) == 0: return ds_out if len(grid_map) > 1: raise ValueError("Several grid mappings in the same dataset is not supported") mapname = grid_map.pop() if {"grid_mapping", "crs"}.isdisjoint(ds_out.attrs) and mapname in ds_out: ds_out.attrs["grid_mapping"] = json.dumps(serializable_mapping()) return ds_out.drop_vars(mapname, errors="ignore")
[docs] def get_spatial_dims(grid: xr.Dataset | xr.DataArray) -> tuple[str, ...]: """ Returns names of spatial dimensions according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF conventions. The returned tuple has size one in case of unstructured grid otherwise contains dimension names in the order (y, x). """ flat_dim = get_flat_dim(grid) if flat_dim is not None: return (flat_dim,) spatial_dims = _get_spatial(grid, tuple(grid.dims)) # type: ignore[arg-type] if None in spatial_dims: raise ValueError( f"Spatial dimensions must either be one of {YX}, have CF conventions" f" attributes for arbitrary names or be one of {FLAT_DIM} in case of" " unstructured grid" ) return spatial_dims # type: ignore[return-value]
[docs] def get_spatial_coords(grid: xr.Dataset | xr.DataArray) -> tuple[str, str]: """ Returns names of non-dimension spatial coordinates according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF conventions. The returned tuple contains coordinate names in the order (y, x). """ notdim_coords = tuple(set(grid.coords) - set(grid.dims)) spatial_coords = _get_spatial(grid, notdim_coords) # type: ignore[arg-type] if None in spatial_coords: raise ValueError( "Could not find non-dimension spatial coordinates. Must be one of " f"{YX} or have CF conventions attributes for arbitrary names" ) return spatial_coords # type: ignore[return-value]
def _get_spatial(grid: xr.Dataset | xr.DataArray, dim_or_coord: tuple[str, ...]) -> tuple[str | None, str | None]: """ Returns names of spatial dimensions or coordinates in ``dim_or_coord``. """ for y, x in YX: if y in dim_or_coord and x in dim_or_coord: return y, x # look for coordinates with CF attributes in coords spatial: list[str | None] = [None, None] for name in dim_or_coord: attrs = grid[name].attrs if (attrs.get("axis", "").upper() == "Y") or ( attrs.get("standard_name", "").lower() in ("latitude", "grid_latitude", "projection_y_coordinate") ): spatial[0] = name elif (attrs.get("axis", "").upper() == "X") or ( attrs.get("standard_name", "").lower() in ("longitude", "grid_longitude", "projection_x_coordinate") ): spatial[1] = name if None not in spatial: break return (spatial[0], spatial[1])
[docs] def get_flat_dim(grid: xr.Dataset | xr.DataArray) -> str | None: """ Returns spatial dimension name for an unstructured grid according to the possible options defined in the config (could be overwritten by setting appropriate env variable). """ for dim in FLAT_DIM: if dim in grid.dims: return dim return None
[docs] def get_latlon_coords(grid: xr.Dataset | xr.DataArray, nondim: bool = True) -> tuple[str, str] | None: """ Returns names of latitude and longitude coordinates if they exist according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF conventions. The returned tuple contains coordinate names in the order (latitude, longitude). When ``nondim`` is True (default), only checks coordinates that are non-dimensional. """ coords = tuple(grid.coords) if nondim: coords = tuple(set(coords) - set(grid.dims)) return _get_latlon(grid, coords) # type: ignore[arg-type]
[docs] def get_latlon_dims(grid: xr.Dataset | xr.DataArray) -> tuple[str, str] | None: """ Returns names of latitude and longitude dimensions if they exist according to the possible options defined in the config (could be overwritten by setting appropriate env variable) or according to CF conventions. The returned tuple contains coordinate names in the order (latitude, longitude). """ return _get_latlon(grid, tuple(grid.dims)) # type: ignore[arg-type]
def _get_latlon(grid: xr.Dataset | xr.DataArray, dim_or_coord: tuple[str, ...]) -> tuple[str, str] | None: """ Returns names of latitude and longitude dimensions or coordinates in ``dim_or_coord``. """ for lat, lon in LATLON: if lat in dim_or_coord and lon in dim_or_coord: return lat, lon # look for coordinates with CF attributes in coords latlon: list[str | None] = [None, None] for name in dim_or_coord: coord_attrs = grid[name].attrs if coord_attrs.get("standard_name", "") == "latitude" or coord_attrs.get("long_name", "") == "latitude": latlon[0] = name elif coord_attrs.get("standard_name", "") == "longitude" or coord_attrs.get("long_name", "") == "longitude": latlon[1] = name if None not in latlon: break if None in latlon: return None return (latlon[0], latlon[1]) # type: ignore[return-value]
[docs] def get_surf_alt(grid: xr.Dataset | xr.DataArray) -> str: """ Returns name of the surface altitude coordinate variable according to the possible options defined in the config (could be overwritten by setting appropriate env variable). """ for name in SURF_ALT: if name in grid.coords: return name raise ValueError(f"Missing one of {SURF_ALT} coordinate variable")
[docs] def get_poi_alt(poi_info: gpd.GeoDataFrame) -> NDArray[np.float32]: """ Returns POIs altitudes from ``poi_info`` according to the column names defined in the config (could be overwritten by setting appropriate env variable) or from a "z" geometry. Altitude data is expected to be in meters above sea level. """ for name in POI_ALT_COL: if name in poi_info.columns: return poi_info[name].values.astype(np.float32) if poi_info.iloc[0].geometry.has_z: return poi_info.geometry.z.values.astype(np.float32) raise ValueError( "GeoDataFrame must have a column with one of the following names " f"{POI_ALT_COL} or a z coordinate. Data is assumed to be in meters" )
[docs] def get_grid_crs(grid: xr.Dataset | xr.DataArray) -> CRS: """ Get correct CRS according to supported conventions. Parameters ---------- grid : xarray Dataset or DataArray contains spatial horizontal coordinates. Must have a "grid_mapping" or "crs" attribute referencing the spatial dimensions unless `longitude` and `latitude` (or configured equivalents) are dimensions. Returns ------- pyproj CRS : pyproj representation of input grid coordinate reference system See also -------- :func:`~grid_process.helpers.set_grid_mapping` """ if "crs" in grid.attrs: return CRS.from_user_input(grid.crs) if "grid_mapping" in grid.attrs: return parse_grid_mapping(grid.grid_mapping) if get_latlon_dims(grid) is not None: return CRS.from_string("EPSG:4326") raise ValueError("The dataset must have a grid_mapping or crs attribute or have latitude and longitude dimensions")
[docs] def parse_grid_mapping(grid_mapping: str | dict[str, Any]) -> CRS: """ Parse internal grid database style "grid_mapping" attribute and decode it with Pyproj. Parameters ---------- grid_mapping : dict or dict encapsulated in a string contains CF conventions attributes parsable by pyproj.CRS.from_cf() or the key "epsg_code" with value being an authority string (e.g. EPSG:4326) Returns ------- pyproj CRS : pyproj representation of input grid_mapping See also -------- :func:`get_grid_crs` """ if isinstance(grid_mapping, str): grid_mapping = json.loads(grid_mapping) if "epsg_code" in grid_mapping: return CRS.from_string(grid_mapping["epsg_code"]) # type: ignore[index] return CRS.from_cf(grid_mapping) # type: ignore[arg-type]
[docs] def get_coords_name_crs( grid: xr.Dataset | xr.DataArray, spatial_dims: tuple[str, ...] | None = None ) -> tuple[str, str, CRS]: """ Get correct coordinate names and CRS according to supported conventions. When non-dimensional longitude and latitude coordinates arrays are present, these are used instead of the native spatial dimension coordinates. Parameters ---------- grid : xarray Dataset or DataArray contains spatial horizontal coordinates. Must have a "grid_mapping" or "crs" attribute referencing the spatial dimensions unless longitude and latitude coordinates are present. spatial_dims : tuple of string(s), optional name of the ``grid`` spatial dimension(s). Has size one in case of unstructured grid otherwise contains dimension names in the order (y, x). If not provided, calls :func:`~grid_process.helpers.get_spatial_dims` Returns ------- grid_y : str name of the northward coordinate. Usually 'x' or 'longitude' grid_x : str name of the eastward coordinate. Usually 'y' or 'latitude'' grid_crs : pyproj CRS pyproj representation of input grid coordinate reference system """ latlon = get_latlon_coords(grid) if latlon is not None: grid_y, grid_x = latlon return grid_y, grid_x, CRS.from_string("EPSG:4326") if spatial_dims is None: spatial_dims = get_spatial_dims(grid) if len(spatial_dims) == 1: grid_y, grid_x = get_spatial_coords(grid) return grid_y, grid_x, get_grid_crs(grid) grid_y, grid_x = spatial_dims if grid_y in grid.coords and grid_x in grid.coords: return grid_y, grid_x, get_grid_crs(grid) raise ValueError("Missing spatial coordinates")
[docs] def is_decreasing1d(coords: xr.DataArray) -> bool: """ Whether 1 dimensional ``coords`` are in decreasing order. """ return bool(np.ediff1d(coords.values[:2])[0] < 0)
[docs] def is_decreasing(coords: xr.DataArray, dim: str) -> bool: """ Checks whether ``coords`` are in decreasing order along dimension ``dim``. """ if coords.ndim > 1: other_dim = next(d for d in coords.dims if d != dim) mid_index = coords.sizes[other_dim] // 2 return is_decreasing1d(coords.isel({other_dim: mid_index})) return is_decreasing1d(coords)