"""Fabboni analytical test-case GCM for TRACMASS JAX.
Provides spatially-uniform, analytically-defined velocity fluxes and the
canonical single-particle initial state matching the Fabboni test case from
the TRACMASS Fortran distribution (``projects/Theoretical``).
The velocity field decays exponentially from initial conditions
``u = U0 = 0.5 m/s``, ``v = 0``, with inertial oscillation governed by
the Coriolis parameter at 45° N and two damping time-scales ``GAMMA`` and
``GAMMAG``. Because the field is spatially uniform, all particles experience
identical forcing — making it ideal for comparison with the Fortran reference
trajectory.
This module mirrors the interface of :mod:`tracmass.gcm.theoretical`:
- :func:`fluxes` — returns an ``xr.Dataset`` compatible with
:func:`~tracmass.gcm.new_flux` and :func:`~tracmass.gcm.update_flux`.
- :func:`initial_state` — returns a single-particle :class:`ParticleState`
that matches the Fortran seeding (``isec=1, idir=1``).
"""
from __future__ import annotations
import math
import jax.numpy as jnp
import numpy as np
import pandas as pd
import xarray as xr
from tracmass.particle import ParticleState
# ---------------------------------------------------------------------------
# Grid constants
# ---------------------------------------------------------------------------
IMT: int = 300 # number of x-cells
JMT: int = 300 # number of y-cells
KM: int = 1 # number of vertical levels
DXV: float = 250.0 # m — zonal cell width
DYU: float = 250.0 # m — meridional cell height
DZT: float = 10.0 # m — layer thickness
DXDY: float = DXV * DYU # m²
DTM0: pd.Timestamp = pd.Timestamp("2000-01-01")
TIMESTEP: pd.Timedelta = pd.Timedelta(1, "h")
# ---------------------------------------------------------------------------
# Fabboni velocity parameters
# ---------------------------------------------------------------------------
_PI = math.pi
_RADIAN = _PI / 180.0
UG: float = 0.04
U0: float = 0.5
FCOR: float = 2.0 * 2.0 * _PI / (24.0 * 3600.0) * math.cos(45.0 * _RADIAN)
GAMMA: float = 1.0 / (2.89 * 24.0 * 3600.0)
GAMMAG: float = 1.0 / (28.9 * 24.0 * 3600.0)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _velocity(ints: int) -> tuple[float, float]:
"""Compute Fabboni zonal and meridional velocities at GCM step *ints*.
Parameters
----------
ints : int
GCM step index (0 = ``DTM0``).
Returns
-------
tuple[float, float]
``(u_vel, v_vel)`` in m/s.
"""
omtime = float(ints) * TIMESTEP.total_seconds()
u_vel = (
UG * math.exp(-GAMMAG * omtime)
+ (U0 - UG) * math.exp(-GAMMA * omtime) * math.cos(FCOR * omtime)
)
v_vel = -(U0 - UG) * math.exp(-GAMMA * omtime) * math.sin(FCOR * omtime)
return u_vel, v_vel
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
[docs]
def fluxes(
dtm: pd.Timestamp | str = "2000-01-01",
) -> xr.Dataset:
"""Generate Fabboni spatially-uniform flux fields at time *dtm*.
The step index is derived from the offset of *dtm* from
:data:`DTM0 = 2000-01-01 <DTM0>`, counted in units of
:data:`TIMESTEP` (1 hour).
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 at the requested time.
Parameters
----------
dtm : pd.Timestamp or str, optional
Target time for the flux snapshot. Default ``"2000-01-01"``.
Returns
-------
xr.Dataset
Dataset containing ``uflux``, ``vflux``, ``wflux``, ``dzt``,
``zstot``, ``kmt``, ``dxdy``, ``timestep``, ``dtm``, ``reverse``,
``imt``, ``jmt``, ``km``, ``iperio``, ``jperio``.
"""
dtm = pd.to_datetime(dtm)
ints = int(round((dtm - DTM0) / TIMESTEP))
u_vel, v_vel = _velocity(ints)
uf = DYU * DZT * u_vel # volume flux (m³/s)
vf = DXV * DZT * v_vel
uflux = np.full((IMT, JMT, KM, 1), uf, dtype=np.float64)
vflux = np.full((IMT, JMT + 1, KM, 1), vf, dtype=np.float64)
vflux[:, 0, :, :] = 0.0 # no-flux at southern boundary
vflux[:, JMT, :, :] = 0.0 # no-flux at northern boundary
wflux = np.zeros((IMT, JMT, KM + 1, 1), dtype=np.float64)
dzt = np.full((IMT, JMT, KM, 1), DZT, dtype=np.float64)
dxdy = np.full((IMT, JMT), DXDY, dtype=np.float64)
kmt = np.ones((IMT, JMT), dtype=np.int32)
zstot = np.ones((IMT, JMT, 1), dtype=np.float64)
return xr.Dataset({
"uflux": (("x", "y", "z", "t"), uflux),
"vflux": (("x", "y_v", "z", "t"), vflux),
"wflux": (("x", "y", "z_w", "t"), wflux),
"dzt": (("x", "y", "z", "t"), dzt),
"zstot": (("x", "y", "t"), zstot),
"kmt": (("x", "y"), kmt),
"dxdy": (("x", "y"), dxdy),
"timestep": TIMESTEP,
"dtm": dtm,
"reverse": False,
"imt": IMT,
"jmt": JMT,
"km": KM,
"iperio": 1,
"jperio": 1,
})
[docs]
def initial_state() -> ParticleState:
"""Create the Fabboni reference initial particle state.
Seeds a single particle at the eastern wall of cell (150, 100, 1),
matching the Fortran seeding ``isec=1, idir=1``:
- ``x = 150.0`` — eastern wall of i-cell 150 → ``ia = 151``
- ``y = 99.5`` — centre of j-cell 100 → ``ja = 100``
- ``z = 0.5`` — centre of k-cell 1 → ``ka = 1``
Returns
-------
ParticleState
Single-particle state with all time counters at zero and
``subvol`` set to one :data:`TIMESTEP` in seconds.
"""
n = 1
tseas = TIMESTEP.total_seconds()
return ParticleState(
x=jnp.array([150.0], dtype=jnp.float64),
y=jnp.array([99.5], dtype=jnp.float64),
z=jnp.array([0.5], dtype=jnp.float64),
ia=jnp.array([151], dtype=jnp.int32),
ja=jnp.array([100], dtype=jnp.int32),
ka=jnp.array([1], dtype=jnp.int32),
tt=jnp.zeros(n, dtype=jnp.float64),
ts=jnp.zeros(n, dtype=jnp.float64),
t0=jnp.zeros(n, dtype=jnp.float64),
subvol=jnp.array([tseas], dtype=jnp.float64),
niter=jnp.zeros(n, dtype=jnp.int32),
active=jnp.ones(n, dtype=jnp.bool_),
reason=jnp.zeros(n, dtype=jnp.int16),
)