Source code for tracmass.seagrid.coast

"""Land mask and distance to land on a seagrid model's T-cells.

Shared by the per-model ``landmask`` / ``distance_to_land`` functions in
:mod:`tracmass.seagrid.glorys`, :mod:`tracmass.seagrid.ecco2` and
:mod:`tracmass.seagrid.scrippsgog`.  Each model passes its
``grid_definition()`` dataset; everything else is grid-independent.
"""

import warnings

import numpy as np
import xarray as xr
from scipy import ndimage
from scipy.spatial import cKDTree

EARTH_RADIUS_KM = 6371.0


def _kmt_2d(grid: xr.Dataset) -> xr.DataArray:
    """``kmt`` on ``(y, x)``, without the size-1 ``t`` of some grid files."""
    kmt = grid["kmt"]
    if "t" in kmt.dims:
        kmt = kmt.isel(t=0, drop=True)
    return kmt.transpose("y", "x")


[docs] def landmask(grid: xr.Dataset, k: int = 1) -> xr.DataArray: """Land mask of a ``grid_definition()`` dataset. Parameters ---------- grid : xr.Dataset A seagrid ``grid_definition()`` with ``kmt`` (the number of ocean levels per column; 0 on land) and ``lon`` / ``lat`` coordinates. k : int, optional 1-based model level. Default ``1`` (the surface): land is ``kmt == 0``. At deeper levels the sea floor counts as land too: ``kmt < k``. Returns ------- xr.DataArray bool, ``(y, x)``, ``True`` on land, with the grid's ``lon`` / ``lat`` coordinates. NumPy-backed. Raises ------ ValueError If *k* is below 1. """ if k < 1: raise ValueError(f"k is a 1-based level, got {k}") kmt = _kmt_2d(grid) mask = (kmt.astype("int32") < k).compute() mask = mask.assign_coords( {c: grid[c].compute() for c in ("lon", "lat") if c in grid.coords} ) mask.name = "landmask" mask.attrs = dict(long_name="land mask", level=int(k), description=( "True where the column has fewer than `level` ocean levels (kmt < level)" )) return mask
def _unit_xyz(lon: np.ndarray, lat: np.ndarray) -> np.ndarray: """Points on the unit sphere, shape ``(n, 3)``.""" lon, lat = np.deg2rad(lon), np.deg2rad(lat) coslat = np.cos(lat) return np.column_stack([coslat * np.cos(lon), coslat * np.sin(lon), np.sin(lat)]) def _lonlat_2d(mask: xr.DataArray) -> tuple[np.ndarray, np.ndarray]: """Broadcast the mask's 1-D or 2-D ``lon`` / ``lat`` to ``(y, x)``.""" lon, lat = xr.broadcast(mask["lon"], mask["lat"]) lon, lat = lon.transpose("y", "x"), lat.transpose("y", "x") return np.asarray(lon, dtype="float64"), np.asarray(lat, dtype="float64")
[docs] def distance_to_land(mask: xr.DataArray) -> xr.DataArray: """Great-circle distance from every cell centre to the nearest land cell. Parameters ---------- mask : xr.DataArray A land mask from :func:`landmask` (bool, ``(y, x)``, ``True`` on land, with ``lon`` / ``lat`` coordinates in degrees). Returns ------- xr.DataArray float32, ``(y, x)``, in km, with the mask's coordinates. 0 on land; NaN everywhere when the grid has no land cell. Notes ----- Distances run between cell centres: an ocean cell next to the coast is about one cell width from land, not half. The search uses a KD-tree on unit-sphere coordinates, so it is exact on curvilinear grids and across the date line. Only land cells with an ocean neighbour (8-neighbour) go into the tree: any other land cell is farther from the ocean. On a regional grid, land outside the domain is unknown, so distances near the open boundaries can be too large. """ land = np.asarray(mask, dtype=bool) lon, lat = _lonlat_2d(mask) ocean = ~land coast = land & ndimage.binary_dilation(ocean, structure=np.ones((3, 3), bool)) dist = np.zeros(land.shape, dtype="float32") if not land.any(): warnings.warn("grid has no land cells; distance_to_land is NaN", stacklevel=3) dist[:] = np.nan elif coast.any(): # else all land: 0 everywhere tree = cKDTree(_unit_xyz(lon[coast], lat[coast])) chord, _ = tree.query(_unit_xyz(lon[ocean], lat[ocean]), workers=-1) dist[ocean] = 2 * EARTH_RADIUS_KM * np.arcsin(np.minimum(chord / 2, 1.0)) out = xr.DataArray(dist, dims=("y", "x"), coords=mask.coords, name="distance_to_land") out.attrs = dict( units="km", long_name="distance to nearest land cell centre", level=mask.attrs.get("level", 1), ) return out