Source code for tracmass.gcm.theoretical

"""Synthetic (theoretical) GCM interface for TRACMASS JAX.

Provides functions that generate idealised, analytically-defined
velocity flux fields and initial particle states for testing and
validation of the TRACMASS particle-tracking framework without
requiring real ocean reanalysis data.
"""

from typing import NewType

import jax
import jax.numpy as jnp
import numpy as np
import pandas as pd
import xarray as xr

from tracmass.gcm.flux_grid import FluxFields, GridFields
from tracmass.particle import ParticleState

FluxDataset = NewType("FluxDataset", xr.Dataset)

# ---------------------------------------------------------------------------
# Grid constants
# ---------------------------------------------------------------------------

DXV: float = 250.0  # m — zonal cell width
DYU: float = 250.0  # m — meridional cell height
DZT: float = 10.0  # m — nominal layer thickness
DXDY: float = DXV * DYU  # m²

# Target max horizontal speed ~0.1 m/s; flux = speed × face_area
_FLUX_AMP: float = 0.1 * DYU * DZT  # 250.0  m³/s


# ===========================================================================
# Convenience: create test data and run
# ===========================================================================
#
def _make_perturbed_field(grid, fluxes, seed_offset):
    """Create a slightly perturbed velocity field for multi-window testing.

    Adds a small Gaussian random perturbation to the present-time
    (slot 1) velocity fluxes stored in *fluxes*.

    Parameters
    ----------
    grid : GridFields
        Static grid with dimension metadata.
    fluxes : FluxFields
        Source flux data from which the baseline velocity field (slot 1) is taken.
    seed_offset : int
        Offset applied to the JAX random seed to ensure independent
        perturbations across different calls.

    Returns
    -------
    dict
        Dictionary with keys ``"uflux"`` and ``"vflux"`` containing
        JAX arrays of the perturbed fluxes.
    """
    key = jax.random.PRNGKey(seed_offset)
    imt, jmt, km = grid.imt, grid.jmt, grid.km
    return {
        "uflux": fluxes.uflux[:, :, :, 1]
        + 0.002 * jax.random.normal(key, (imt, jmt, km)),
        "vflux": fluxes.vflux[:, :, :, 1]
        + 0.002
        * jax.random.normal(jax.random.PRNGKey(seed_offset + 1), (imt, jmt + 1, km)),
    }


[docs] def generate_keys(random_seed: int = 42): """Generate a set of JAX random keys from a single seed. Splits a PRNGKey derived from *random_seed* into 10 independent sub-keys for use in stochastic field generation. Parameters ---------- random_seed : int, optional Integer seed used to initialise the JAX PRNG. Default is ``42``. Returns ------- jnp.ndarray Array of shape ``(10, 2)`` containing 10 independent JAX random keys. """ key = jax.random.PRNGKey(random_seed) keys = jax.random.split(key, 10) return keys
[docs] def fluxes( dtm="2007-06-01", imt: int = 100, jmt: int = 80, km: int = 30, random_seed: int = 42, ) -> FluxDataset: """Generate synthetic volume-flux fields on an idealised rectangular grid. Constructs analytically-defined zonal and meridional volume fluxes following a double-gyre pattern with small random perturbations, and random vertical fluxes. When *dtm* is later than the reference date ``"2007-06-01"``, the fluxes are time-evolved by accumulating day-by-day random increments. Backward-in-time tracking is handled at the :func:`~tracmass.gcm.new_grid` / :func:`~tracmass.gcm.new_flux` level by passing ``dtm_end < dtm_start``; this function always returns forward (positive) fluxes. Parameters ---------- dtm : pd.Timestamp or str, optional Target date for the flux snapshot. Default is ``"2007-06-01"``. imt : int, optional Number of grid cells in the x-direction. Default is ``100``. jmt : int, optional Number of grid cells in the y-direction. Default is ``80``. km : int, optional Number of vertical levels. Default is ``30``. random_seed : int, optional Seed for JAX random number generation. Default is ``42``. Returns ------- FluxDataset xarray Dataset containing the variables required by :class:`GridFields`: ``uflux``, ``vflux``, ``wflux``, ``kmt``, ``dzt``, ``dxdy``, ``timestep``, ``dtm``, ``reverse``, ``imt``, ``jmt``, ``km``, ``iperio``, ``jperio``, and ``zstot``. """ dtm = pd.to_datetime(dtm) dtm0 = pd.to_datetime("2007-06-01") timestep = pd.Timedelta(1, "h") keys = generate_keys(random_seed=random_seed) xi = jnp.arange(imt)[:, None, None, None] / imt yj = jnp.arange(jmt)[None, :, None, None] / jmt uflux = ( -_FLUX_AMP * jnp.cos(jnp.pi * yj) * jnp.sin(2 * jnp.pi * xi) * jnp.ones((1, 1, km, 1)) ) uflux = uflux + 0.1 * _FLUX_AMP * jax.random.normal(keys[0], (imt, jmt, km, 1)) yj_v = jnp.arange(jmt + 1)[None, :, None, None] / jmt xi_v = jnp.arange(imt)[:, None, None, None] / imt vflux = ( _FLUX_AMP * jnp.sin(jnp.pi * yj_v) * jnp.cos(2 * jnp.pi * xi_v) * jnp.ones((1, 1, km, 1)) ) vflux = vflux + 0.1 * _FLUX_AMP * jax.random.normal(keys[1], (imt, jmt + 1, km, 1)) n_days = (dtm - dtm0).days if n_days > 0: # Use fold_in to derive a fixed-shape field keyed by n_days. # Scaling by sqrt(n_days) preserves random-walk variance growth. # This avoids vmapping over jnp.arange(n_days) which changes shape # each window and triggers JAX recompilation. amp = 0.04 * _FLUX_AMP * float(np.sqrt(n_days)) uflux = uflux + amp * jax.random.normal( jax.random.fold_in(jax.random.PRNGKey(0), n_days), (imt, jmt, km, 1) ) vflux = vflux + amp * jax.random.normal( jax.random.fold_in(jax.random.PRNGKey(1), n_days), (imt, jmt + 1, km, 1) ) # Vertical flux: w ~ 1e-4 m/s → wflux = w × DXDY wflux = 1e-4 * DXDY * jax.random.normal(keys[2], (imt, jmt, km + 1, 1)) kmt = jnp.full((imt, jmt), km, dtype=jnp.int32) kmt = kmt.at[:, 0].set(0) kmt = kmt.at[:, -1].set(0) dsdict = { "uflux": (("x", "y", "z", "t"), uflux), "vflux": (("x", "y_v", "z", "t"), vflux), "wflux": (("x", "y", "z_w", "t"), wflux), "kmt": (("x", "y"), kmt), "dzt": (("x", "y", "z", "t"), jnp.ones((imt, jmt, km, 1)) * DZT), "dxdy": (("x", "y"), jnp.ones((imt, jmt)) * DXDY), "timestep": timestep, "dtm": dtm, "reverse": False, } dsdict["km"], dsdict["jmt"], dsdict["imt"] = km, jmt, imt dsdict["iperio"], dsdict["jperio"] = 1, 0 dsdict["zstot"] = (("x", "y", "t"), jnp.ones((imt, jmt, 1))) return xr.Dataset(dsdict)
[docs] def initial_state( n_particles: int = 10_000, imt: int = 100, jmt: int = 80, km: int = 30, random_seed: int = 42, ) -> ParticleState: """Generate a random initial particle state on the theoretical grid. Draws particle positions uniformly at random within the interior of the grid (avoiding boundary cells) and computes the corresponding integer cell indices. Parameters ---------- n_particles : int, optional Number of particles to initialise. Default is ``10_000``. imt : int, optional Number of grid cells in the x-direction. Default is ``100``. jmt : int, optional Number of grid cells in the y-direction. Default is ``80``. km : int, optional Number of vertical levels. Default is ``30``. random_seed : int, optional Seed for JAX random number generation. Default is ``42``. Returns ------- ParticleState Named tuple containing particle positions (``x``, ``y``, ``z``), integer cell indices (``ia``, ``ja``, ``ka``), time trackers (``tt``, ``ts``, ``t0``), sub-volume (``subvol``), iteration counter (``niter``), and active mask (``active``). """ keys = generate_keys(random_seed=random_seed) x0 = jax.random.uniform(keys[3], (n_particles,), minval=1.0, maxval=imt) y0 = jax.random.uniform(keys[4], (n_particles,), minval=2.0, maxval=jmt - 1.0) z0 = jax.random.uniform(keys[5], (n_particles,), minval=1.0, maxval=km - 1.0) ia = jnp.clip(jnp.floor(x0).astype(jnp.int32) + 1, 1, imt) ja = jnp.clip(jnp.floor(y0).astype(jnp.int32) + 1, 1, jmt) ka = jnp.clip(jnp.floor(z0).astype(jnp.int32) + 1, 1, km) state = ParticleState( x=x0, y=y0, z=z0, ia=ia, ja=ja, ka=ka, tt=jnp.zeros(n_particles), ts=jnp.zeros(n_particles), t0=jnp.zeros(n_particles), subvol=jnp.ones(n_particles) * 86400.0, # tseas as subvol for ts tracking niter=jnp.zeros(n_particles, dtype=jnp.int32), active=jnp.ones(n_particles, dtype=jnp.bool_), reason=jnp.zeros(n_particles, dtype=jnp.int16), ) return state