Source code for tracmass.trajs.coords

"""Coordinate conversion utilities for TRACMASS-JAX.

Provides :func:`xy_to_lonlat` and :func:`lonlat_to_xy` for converting between
fractional TRACMASS grid positions and geographic lon/lat coordinates.  Both
functions are grid-agnostic: any object that exposes ``.lon`` and ``.lat``
arrays (and optionally ``.kmt``) can be passed as the *grid* argument.

Both use the TRACMASS position convention: cell ``i`` (0-based, ``ia - 1``)
spans ``i <= x < i + 1`` and its centre -- where the grid's ``lon``/``lat``
are defined -- sits at ``x = i + 0.5``; likewise for *y*.  The
coordinate arrays may be 1-D (``lon`` ``(imt,)``, ``lat`` ``(jmt,)``) or 2-D
in either axis order — :func:`_orient_lonlat` normalises them to TRACMASS
``(imt, jmt)`` and transposes a ``(jmt, imt)`` layout with a warning.
"""

from __future__ import annotations

import warnings

import numpy as np
from scipy.spatial import cKDTree


def _orient_lonlat(lon_arr, lat_arr, kmt_arr):
    """Return ``lon``/``lat``/``kmt`` as TRACMASS ``(imt, jmt)`` arrays.

    Accepts 1-D or 2-D ``lon``/``lat`` in either axis order and normalises
    them to the layout the interpolators expect: first axis zonal (*x*),
    second axis meridional (*y*).

    - **1-D ``lon`` (imt,) and 1-D ``lat`` (jmt,)** — a regular lon/lat
      grid; broadcast to ``(imt, jmt)`` with ``indexing="ij"``.
    - **2-D ``lon``/``lat``** — kept as-is when ``lon`` already varies
      along axis 0 and ``lat`` along axis 1; transposed (with a warning)
      when they look like ``(jmt, imt)``, e.g. the output of
      ``np.meshgrid(lon_1d, lat_1d)`` (default ``indexing="xy"``).

    ``kmt`` is transposed to match whenever its shape lines up with the
    transposed ``lon``/``lat``.
    """
    lon_arr = np.asarray(lon_arr, dtype=float)
    lat_arr = np.asarray(lat_arr, dtype=float)
    if kmt_arr is not None:
        kmt_arr = np.asarray(kmt_arr)

    if lon_arr.ndim == 1 and lat_arr.ndim == 1:
        imt, jmt = lon_arr.size, lat_arr.size
        lon_arr, lat_arr = np.meshgrid(lon_arr, lat_arr, indexing="ij")
        if (
            kmt_arr is not None
            and kmt_arr.ndim == 2
            and kmt_arr.shape == (jmt, imt)
            and imt != jmt
        ):
            kmt_arr = kmt_arr.T
        return lon_arr, lat_arr, kmt_arr

    if lon_arr.ndim != 2 or lat_arr.ndim != 2 or lon_arr.shape != lat_arr.shape:
        raise ValueError(
            "lon/lat must be both 1-D (lon (imt,), lat (jmt,)) or both 2-D "
            f"with matching shape; got {lon_arr.shape} and {lat_arr.shape}"
        )

    # Proper layout: lon varies down axis 0 (zonal), lat across axis 1
    # (meridional).  Median |diff| is ~grid spacing along the varying axis
    # and ~0 along the constant one, and shrugs off the ±180 seam jump.
    def _spread(a, axis):
        return np.nanmedian(np.abs(np.diff(a, axis=axis))) if a.shape[axis] > 1 else 0.0

    proper = _spread(lon_arr, 0) + _spread(lat_arr, 1)
    swapped = _spread(lon_arr, 1) + _spread(lat_arr, 0)
    if swapped > proper:
        warnings.warn(
            "lon/lat arrays look like (jmt, imt); transposing to TRACMASS "
            "(imt, jmt).  Pass arrays with the zonal axis first (e.g. "
            "np.meshgrid(lon, lat, indexing='ij')) to silence this.",
            stacklevel=3,
        )
        lon_arr, lat_arr = lon_arr.T, lat_arr.T
        if kmt_arr is not None and kmt_arr.ndim == 2:
            kmt_arr = kmt_arr.T
    elif (
        kmt_arr is not None
        and kmt_arr.ndim == 2
        and kmt_arr.shape == lon_arr.shape[::-1]
        and lon_arr.shape[0] != lon_arr.shape[1]
    ):
        kmt_arr = kmt_arr.T

    return lon_arr, lat_arr, kmt_arr


def _unpack_grid(grid):
    """Return ``(lon_arr, lat_arr, kmt_arr_or_None)`` from a *grid* argument.

    Accepted forms
    --------------
    - Object with ``.lon`` and ``.lat`` attributes (1-D or 2-D).  ``.kmt``
      is used when present.
    - 2-tuple ``(lon_arr, lat_arr)`` — kmt is ``None`` (all cells treated
      as ocean).
    - 3-tuple ``(lon_arr, lat_arr, kmt_arr)``.

    ``lon``/``lat`` may be 1-D (``lon`` ``(imt,)``, ``lat`` ``(jmt,)``) or
    2-D in either axis order; :func:`_orient_lonlat` normalises them to
    TRACMASS ``(imt, jmt)``.
    """
    if isinstance(grid, tuple):
        if len(grid) == 2:
            lon_arr, lat_arr = grid
            kmt_arr = None
        elif len(grid) == 3:
            lon_arr, lat_arr, kmt_arr = grid
        else:
            raise ValueError("grid tuple must have 2 or 3 elements")
        return _orient_lonlat(lon_arr, lat_arr, kmt_arr)

    if not (hasattr(grid, "lon") and hasattr(grid, "lat")):
        raise TypeError(
            "grid must be a tuple (lon, lat) or (lon, lat, kmt), or an object "
            "with .lon and .lat attributes"
        )
    kmt_arr = grid.kmt if hasattr(grid, "kmt") else None
    return _orient_lonlat(grid.lon, grid.lat, kmt_arr)


def _to_xyz(lon_deg, lat_deg):
    """Unit-sphere Cartesian ``(..., 3)`` array from lon/lat in degrees."""
    lo = np.radians(lon_deg)
    la = np.radians(lat_deg)
    return np.stack(
        [np.cos(la) * np.cos(lo), np.cos(la) * np.sin(lo), np.sin(la)], axis=-1
    )


def _is_zonally_periodic(xyz):
    """True when the last grid column wraps around onto the first.

    Compares the median chord between column ``imt - 1`` and column ``0``
    with the median chord between neighbouring columns: a global grid
    closes its seam with an ordinary-sized step (or less, for grids with
    duplicated halo columns), a regional one does not.
    """
    if xyz.shape[0] < 3:
        return False
    step = np.nanmedian(np.linalg.norm(np.diff(xyz, axis=0), axis=-1))
    gap = np.nanmedian(np.linalg.norm(xyz[0] - xyz[-1], axis=-1))
    return bool(gap < 1.5 * step)


def _xy_to_xyz(x, y, xyz, periodic):
    """Unit vectors at fractional positions *x*, *y*; see :func:`xy_to_lonlat`.

    Bilinear interpolation between the four surrounding cell centres, done
    in Cartesian space on the unit sphere.  Centre ``i`` sits at
    ``x = i + 0.5``.  With *periodic*, *x* wraps across the zonal seam;
    otherwise, and always in *y*, positions in the outer half cell (and
    beyond) are linearly extrapolated from the two edge centres.
    """
    imt, jmt = xyz.shape[:2]
    u = np.asarray(x, dtype=float) - 0.5
    v = np.asarray(y, dtype=float) - 0.5

    if periodic:
        i0 = np.floor(u).astype(int)
        fx = u - i0
        i0 = np.mod(i0, imt)
        i1 = np.mod(i0 + 1, imt)
    else:
        i0 = np.clip(np.floor(u).astype(int), 0, max(imt - 2, 0))
        fx = u - i0
        i1 = np.minimum(i0 + 1, imt - 1)
    j0 = np.clip(np.floor(v).astype(int), 0, max(jmt - 2, 0))
    fy = v - j0
    j1 = np.minimum(j0 + 1, jmt - 1)

    fx = fx[..., None]
    fy = fy[..., None]
    c = (
        (1.0 - fx) * (1.0 - fy) * xyz[i0, j0]
        + fx * (1.0 - fy) * xyz[i1, j0]
        + (1.0 - fx) * fy * xyz[i0, j1]
        + fx * fy * xyz[i1, j1]
    )
    r = np.linalg.norm(c, axis=-1, keepdims=True)
    return c / np.where(r == 0, 1.0, r)


[docs] def xy_to_lonlat( x, y, grid, periodic: bool | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Convert fractional TRACMASS (x, y) positions to longitude/latitude. Bilinearly interpolates the grid's cell-centre lon/lat at the fractional particle positions. Cell ``i`` spans ``i <= x < i + 1`` with its centre at ``x = i + 0.5`` (likewise *y*), so a particle at a cell centre maps exactly to that cell's ``lon``/``lat`` and one on a cell wall maps half-way between the two neighbouring centres. Interpolation is performed in Cartesian (x, y, z) space on the unit sphere to handle the ±180° longitude seam correctly on curvilinear grids. Parameters ---------- x, y : array-like Fractional 0-based grid positions as stored in the trajectory Parquet file (columns ``"x"`` and ``"y"``). ``floor(x)`` is the cell (matching ``"ia" - 1`` / ``"ja" - 1``). grid : object or tuple Provides the ``(imt, jmt)`` arrays of grid-cell centres in degrees: - **Object with ``.lon`` and ``.lat`` attributes** — e.g. a ``GridFields`` extended with coordinate arrays, or a :func:`types.SimpleNamespace`. A ``.kmt`` attribute is not required for this function and is ignored if present. - **2-tuple** ``(lon_arr, lat_arr)`` — lon/lat arrays directly. - **3-tuple** ``(lon_arr, lat_arr, kmt_arr)`` — kmt is ignored here. ``lon_arr``/``lat_arr`` may be 1-D (``lon`` ``(imt,)``, ``lat`` ``(jmt,)`` — a regular lon/lat grid) or 2-D. 2-D arrays are used as ``(imt, jmt)`` (first axis zonal *x*, second meridional *y*); a ``(jmt, imt)`` layout — e.g. ``np.meshgrid(lon, lat)`` with the default ``indexing="xy"`` — is detected and transposed, with a :class:`warnings.warn`. periodic : bool or None, optional Whether *x* wraps around (a global grid): positions between the last and first cell centres then interpolate across the seam. ``None`` (default) detects it from the grid. Otherwise positions in the outermost half cell are linearly extrapolated. Returns ------- lon, lat : np.ndarray Longitude and latitude in degrees, same shape as *x* and *y*. Examples -------- >>> import pandas as pd, types >>> from tracmass.trajs import xy_to_lonlat >>> grid = types.SimpleNamespace(lon=lon_arr, lat=lat_arr) >>> df = pd.read_parquet("traj.parquet", columns=["x", "y"]) >>> lon, lat = xy_to_lonlat(df["x"], df["y"], grid) """ lon_arr, lat_arr, _ = _unpack_grid(grid) xyz = _to_xyz(lon_arr, lat_arr) if periodic is None: periodic = _is_zonally_periodic(xyz) c = _xy_to_xyz(x, y, xyz, periodic) lon_out = np.degrees(np.arctan2(c[..., 1], c[..., 0])) lat_out = np.degrees(np.arcsin(np.clip(c[..., 2], -1.0, 1.0))) return lon_out, lat_out
[docs] def lonlat_to_xy( lon, lat, grid, periodic: bool | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Convert geographic coordinates to fractional TRACMASS (x, y) grid positions. The exact inverse of :func:`xy_to_lonlat`: the returned position maps back to *lon*/*lat*, so a point off-centre within a cell gets the matching fractional offset rather than being snapped to the centre. Finds the nearest grid-cell centre by great-circle distance (a :class:`scipy.spatial.cKDTree` on unit-sphere coordinates) as a starting guess, then refines it with Newton iterations on the bilinear map in the local tangent plane. When a ``.kmt`` array is available on *grid*, the search is restricted to ocean cells (``kmt > 0``), and a point that falls in a land cell (or outside the grid) is placed at the centre of the nearest ocean cell instead, so seeded particles always start in the ocean. The resulting ``(x, y)`` values can be passed directly to :func:`tracmass.seed.expand_sub` or used to initialise a :class:`~tracmass.ParticleState`, with ``ia = floor(x) + 1``, ``ja = floor(y) + 1`` (1-based cell indices). Parameters ---------- lon, lat : array-like Longitude and latitude in degrees. grid : object or tuple Provides the ``(imt, jmt)`` arrays of grid-cell centres in degrees, and optionally a land mask: - **Object with ``.lon`` and ``.lat`` attributes** — e.g. a ``GridFields`` extended with coordinate arrays, or a :func:`types.SimpleNamespace`. If the object also has a ``.kmt`` attribute, only ocean cells (``kmt > 0``) are included in the spatial index. - **2-tuple** ``(lon_arr, lat_arr)`` — all cells are included. - **3-tuple** ``(lon_arr, lat_arr, kmt_arr)`` — ocean-only index. As for :func:`xy_to_lonlat`, ``lon``/``lat`` may be 1-D or 2-D in either axis order; a ``(jmt, imt)`` 2-D layout is transposed to ``(imt, jmt)`` (along with ``kmt``) with a warning. periodic : bool or None, optional Whether *x* wraps around; see :func:`xy_to_lonlat`. With *periodic*, the returned *x* is wrapped into ``[0, imt)``. Returns ------- x, y : np.ndarray Fractional 0-based TRACMASS grid positions, same shape as *lon*/*lat*. Examples -------- >>> import numpy as np, types >>> from tracmass.trajs import lonlat_to_xy >>> grid = types.SimpleNamespace(lon=lon_arr, lat=lat_arr, kmt=kmt_arr) >>> x, y = lonlat_to_xy(np.array([-30.0, 10.0]), np.array([45.0, 55.0]), grid) """ lon_arr, lat_arr, kmt_arr = _unpack_grid(grid) lon = np.asarray(lon, dtype=float) lat = np.asarray(lat, dtype=float) scalar = lon.ndim == 0 lon = np.atleast_1d(lon) lat = np.atleast_1d(lat) original_shape = lon.shape lon = lon.ravel() lat = lat.ravel() imt, jmt = lon_arr.shape xyz = _to_xyz(lon_arr, lat_arr) if periodic is None: periodic = _is_zonally_periodic(xyz) if kmt_arr is not None: ii, jj = np.where(kmt_arr > 0) else: ii, jj = np.mgrid[0:imt, 0:jmt] ii = ii.ravel() jj = jj.ravel() # Chord length on the unit sphere is monotonic in great-circle # distance, so a Euclidean KD-tree query finds the nearest centre. target = _to_xyz(lon, lat) _, nearest = cKDTree(xyz[ii, jj]).query(target) xc = ii[nearest] + 0.5 yc = jj[nearest] + 0.5 # Newton iterations on the bilinear map, with the residual measured in # the target's local east/north tangent plane (unit-sphere radians). lo, la = np.radians(lon), np.radians(lat) east = np.stack([-np.sin(lo), np.cos(lo), np.zeros_like(lo)], axis=-1) north = np.stack( [-np.sin(la) * np.cos(lo), -np.sin(la) * np.sin(lo), np.cos(la)], axis=-1 ) def _resid(xx, yy): d = _xy_to_xyz(xx, yy, xyz, periodic) - target return np.stack([(d * east).sum(-1), (d * north).sum(-1)], axis=-1) x, y = xc.copy(), yc.copy() h = 1e-4 for _ in range(8): r = _resid(x, y) jac = np.stack( [(_resid(x + h, y) - r) / h, (_resid(x, y + h) - r) / h], axis=-1 ) det = jac[:, 0, 0] * jac[:, 1, 1] - jac[:, 0, 1] * jac[:, 1, 0] ok = np.abs(det) > 1e-30 det = np.where(ok, det, 1.0) dx = np.where(ok, (jac[:, 1, 1] * r[:, 0] - jac[:, 0, 1] * r[:, 1]) / det, 0) dy = np.where(ok, (jac[:, 0, 0] * r[:, 1] - jac[:, 1, 0] * r[:, 0]) / det, 0) # Cap the step so a poor starting guess cannot overshoot wildly. x -= np.clip(dx, -1.0, 1.0) y -= np.clip(dy, -1.0, 1.0) if np.all(np.abs(dx) < 1e-9) and np.all(np.abs(dy) < 1e-9): break if periodic: x = np.mod(x, imt) # Keep the refined position only where it converged onto the target # inside the grid (and in an ocean cell, when kmt is known); otherwise # fall back to the nearest (ocean) cell centre. ci = np.floor(x).astype(int) cj = np.floor(y).astype(int) good = ( np.isfinite(x) & np.isfinite(y) & (ci >= 0) & (ci < imt) & (cj >= 0) & (cj < jmt) & (np.abs(_resid(x, y)).max(axis=-1) < 1e-7) ) if kmt_arr is not None: good[good] = kmt_arr[ci[good], cj[good]] > 0 x = np.where(good, x, xc).reshape(original_shape) y = np.where(good, y, yc).reshape(original_shape) if scalar: return float(x.item()), float(y.item()) return x, y