Source code for tracmass.advection_scheme.full_grid

"""Full-grid advection kernels for TRACMASS-JAX.

Implements the :func:`piecewise` and :func:`analytical` step-function
factories that operate on the entire flux grid.  Both close over a
:class:`~tracmass.gcm.GridFields` so that ``kmt`` and ``dxdy`` become XLA
constants in the compiled graph.

The returned step functions have the signature::

    step_fn(fluxes, state, intrpg, dsmin) -> ParticleState

where *fluxes* is a computed :class:`~tracmass.gcm.FluxFields` and
*intrpg* is a scalar float; *dsmin* (the step cap in seconds) is a scalar
or a per-particle ``(N,)`` array — the integrators pass the time left until
each particle's next sub-step boundary.
"""

import jax
import jax.numpy as jnp
import numpy as np
from jax import lax

from tracmass.particle import (
    ParticleState,
    REASON_LAND,
    REASON_BOUNDS_H,
    REASON_BOUNDS_V,
    REASON_DXYZ,
)

UNDEF = 1.0e20
EPS = 1.0e-10

# Module-level constants for the analytical scheme — built once at import time
# so they are not recreated on every JIT recompilation.
_DAWSON_H    = 0.4
_DAWSON_NMAX = 6
_DAWSON_C    = jnp.array(
    [jnp.exp(-((2.0*(i + 1) - 1.0)*_DAWSON_H)**2) for i in range(_DAWSON_NMAX)],
    dtype=jnp.float32,
)
_ASYM_H_IRANGE = jnp.arange(1.0, 11.0, dtype=jnp.float32)


# ===========================================================================
# Shared helpers
# ===========================================================================

def _flux_interp(field, i, j, k, intrpg):
    """Linearly interpolate a 4-D flux field between time levels."""
    i0, j0, k0 = i - 1, j - 1, k - 1
    intrpr = 1.0 - intrpg
    return intrpg * field[i0, j0, k0, 1] + intrpr * field[i0, j0, k0, 0]


def _cross_time_1d(uu, um, ii, r0):
    """Return (sp, sn) — crossing times for the positive and negative walls."""
    def _sp_divergent(uu, um, ii, r0):
        ba = (r0 + (-ii + 1.0)) * (uu - um) + um
        return jnp.where(ba > 0.0, (jnp.log(ba) - jnp.log(uu)) / (um - uu), UNDEF)

    sp = jnp.where(
        (uu > 0.0) & (r0 != ii),
        jnp.where(um != uu, _sp_divergent(uu, um, ii, r0), (ii - r0) / uu),
        UNDEF,
    )
    sp = jnp.where(sp <= 0.0, UNDEF, sp)

    def _sn_divergent(uu, um, ii, r0):
        ba = -((r0 - ii) * (uu - um) + uu)
        return jnp.where(ba > 0.0, (jnp.log(ba) - jnp.log(-um)) / (um - uu), UNDEF)

    sn = jnp.where(
        (um < 0.0) & (r0 != (ii - 1.0)),
        jnp.where(um != uu, _sn_divergent(uu, um, ii, r0), (ii - 1.0 - r0) / uu),
        UNDEF,
    )
    sn = jnp.where(sn <= 0.0, UNDEF, sn)
    return sp, sn


# ===========================================================================
# Time-analytical scheme (mod_pos_tanalytical.F90)
# ===========================================================================

def _dawson(x):
    """Dawson's integral F(x) = exp(-x²) ∫₀ˣ exp(t²) dt.

    Port of daw() from mod_pos_tanalytical.F90.
    Uses series expansion for |x| < 0.2, Rybicki sampling theorem otherwise.
    JIT-compatible: no Python control flow on traced values.
    """
    H = _DAWSON_H
    A1, A2, A3 = 2.0/3.0, 0.4, 2.0/7.0
    c = _DAWSON_C
    x2 = x * x
    series = x * (1.0 - A1*x2*(1.0 - A2*x2*(1.0 - A3*x2)))

    xx   = jnp.abs(x)
    n0   = 2.0 * jnp.round(0.5 * xx / H)   # even integer as float
    xp   = xx - n0 * H
    e1_0 = jnp.exp(2.0 * xp * H)
    e2   = e1_0 * e1_0

    def _step(carry, ci):
        e1, dd1, dd2, s = carry
        s = s + ci * (e1/dd1 + 1.0/(dd2*e1))
        return (e2*e1, dd1 + 2.0, dd2 - 2.0, s), None

    (_, _, _, summ), _ = lax.scan(
        _step,
        (e1_0, n0 + 1.0, n0 - 1.0, jnp.zeros_like(x)),
        c,
    )
    sampling = jnp.sign(x) / jnp.sqrt(jnp.pi) * jnp.exp(-xp**2) * summ
    return jnp.where(jnp.abs(x) < 0.2, series, sampling)


def _erfexp_val(const, xi0, xi):
    """const * exp(xi²) * (erf(xi₀) − erf(xi)), numerically stable.

    Port of erfexp() from mod_pos_tanalytical.F90.
    Falls back to asymptotic expansion when |xi| > 25.
    """
    standard = const * jnp.exp(xi**2) * (
        jax.scipy.special.erf(xi0) - jax.scipy.special.erf(xi)
    )

    def _asym_h(z):
        def _step(carry, i):
            h0, h = carry
            h0 = -0.5 * h0 * (2.0*i - 1.0) / (z*z)
            return (h0, h + h0), None
        (_, h), _ = lax.scan(_step,
                              (jnp.ones_like(z), jnp.ones_like(z)),
                              _ASYM_H_IRANGE)
        return h

    h0 = _asym_h(xi0)
    h  = _asym_h(xi)
    asymp = const / jnp.sqrt(jnp.pi) * (
        h/xi - jnp.exp(xi**2 - xi0**2) * h0/xi0
    )
    return jnp.where(jnp.abs(xi) > 25.0, asymp, standard)


def _r_apos(r0, ga, xi0, xic, c, daw_xi0):
    """Trajectory position r(xic) for the alpha > 0 (Dawson) regime."""
    ef = jnp.exp(xi0**2 - xic**2)
    return (r0 + ga)*ef - ga + c*(_dawson(xic) - ef*daw_xi0)


def _r_aneg(r0, ga, xi0, xic, c):
    """Trajectory position r(xic) for the alpha < 0 (erf) regime."""
    return (r0 + ga)*jnp.exp(xic**2 - xi0**2) + _erfexp_val(c, xi0, xic) - ga


# ---------------------------------------------------------------------------
# Wall-crossing time for alpha > 0
# ---------------------------------------------------------------------------
def _apos_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1):
    """Normalized crossing time (rw, sw) for the Dawson regime (alpha > 0).

    Returns (rw, sw) where rw is the wall coordinate (-99 = no crossing)
    and sw is the absolute normalized time of crossing.
    """
    _EPS = 1.0e-10
    denom   = vv - vm - uu + um           # = −alfa·dstep  (<0 when alfa>0)
    safe_d  = jnp.where(jnp.abs(denom) < _EPS, _EPS, denom)
    # Guard sqrt: only real when alfa > 0, but we compute safely
    alfa_g  = jnp.maximum(-(denom/dstep), _EPS)   # guarded alfa
    sqrt2a  = jnp.sqrt(2.0 * alfa_g)

    beta    = um - uu - alfa_g * sm
    ga      = -iim + (f1*vm - f0*um) / safe_d
    c       = jnp.sqrt(2.0/alfa_g) * (f0*um*(vv - vm) + f1*vm*(um - uu)) / safe_d

    xi0     = (beta + alfa_g*s0) / sqrt2a
    daw_xi0 = _dawson(xi0)

    # ssii / ssiim: normalized flux-zero times
    den_ii  = uu - vv + um*(f0 - 1.0) + vm*(1.0 - f1)
    num_ii  = uu + um*(f0 - 1.0)
    ssii    = jnp.where(jnp.abs(den_ii) < _EPS, num_ii/_EPS, num_ii/den_ii)

    den_im  = f0*um - f1*vm
    ssiim   = jnp.where(jnp.abs(den_im) < _EPS, f0*um/_EPS, f0*um/den_im)

    xin_end   = (vm - vv) / sqrt2a                              # xi at window end
    xin_z_ii  = (beta + alfa_g*(ssii*dstep + sm)) / sqrt2a     # xi at flux-zero for ii
    xin_z_im  = (beta + alfa_g*(ssiim*dstep + sm)) / sqrt2a    # xi at flux-zero for iim

    def rval(xic):
        return _r_apos(r0, ga, xi0, xic, c, daw_xi0)

    # iiselect: determine initial wall target
    no_z_ii  = (ssii <= 0.0) | (ssii >= 1.0)
    cfg_13   = (no_z_ii & ((uu > 0.0) | (vv > 0.0))) | (~no_z_ii & (vv > 0.0))
    cfg_4ii  = no_z_ii & ~((uu > 0.0) | (vv > 0.0))
    cfg_2e   = ~no_z_ii & ~(vv > 0.0) & (s0 <= sm + ssii*dstep)
    cfg_2w   = ~no_z_ii & ~(vv > 0.0) & (s0 >  sm + ssii*dstep)
    try_east = cfg_13 | cfg_2e
    go_west_d = cfg_4ii | cfg_2w

    xin_e  = jnp.where(cfg_2e, xin_z_ii, xin_end)
    dr_e   = rval(xin_e) - ii

    # Secondary check: drds <= 0 and flux-zero at iim side
    drds   = jnp.sqrt(alfa_g/2.0)*c - sqrt2a*xi0*(r0 + ga)
    sec    = (drds <= 0.0) & (ssiim > 0.0) & (ssiim < 1.0) & (vm > 0.0)
    dra_im = rval(xin_z_im) - iim

    east_ok = try_east & (dr_e >= 0.0) & ~(sec & (dra_im < 0.0))
    go_west = go_west_d | (try_east & ~east_ok)

    # iimselect
    no_z_im  = (ssiim <= 0.0) | (ssiim >= 1.0)
    cfg_3b   = no_z_im  & ~((um > 0.0) | (vm > 0.0))
    cfg_3c   = ~no_z_im & (vm > 0.0) & (s0 < sm + ssiim*dstep)
    cfg_3d   = ~no_z_im & ~(vm > 0.0)
    west_case = cfg_3b | cfg_3c | cfg_3d
    xin_w    = jnp.where(cfg_3c, xin_z_im, xin_end)
    dr_w     = rval(xin_w) - iim
    west_ok  = go_west & west_case & (dr_w <= 0.0)

    do_east = east_ok
    do_west = west_ok & ~east_ok

    rw_sel = jnp.where(do_east, ii*1.0, jnp.where(do_west, iim*1.0, -99.0))
    xin_s  = jnp.where(do_east, xin_e, jnp.where(do_west, xin_w, xi0))

    # Bisection in xi-space (100 fixed iterations)
    def _bisect(_, carry):
        xa, xb, xc = carry
        dra = rval(xa) - rw_sel
        drc = rval(xc) - rw_sel
        new_xa = jnp.where(dra*drc > 0.0, xc, xa)
        new_xb = jnp.where(dra*drc > 0.0, xb, xc)
        return (new_xa, new_xb, 0.5*(new_xa + new_xb))

    _, _, xc = lax.fori_loop(0, 100, _bisect, (xi0, xin_s, 0.5*(xi0 + xin_s)))
    sw = jnp.where(do_east | do_west, (sqrt2a*xc - beta)/alfa_g, UNDEF)
    return rw_sel, sw


# ---------------------------------------------------------------------------
# Wall-crossing time for alpha < 0
# ---------------------------------------------------------------------------
def _aneg_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1):
    """Normalized crossing time (rw, sw) for the erf regime (alpha < 0)."""
    _EPS = 1.0e-10
    denom   = vv - vm - uu + um          # >0 when alfa<0
    safe_d  = jnp.where(jnp.abs(denom) < _EPS, _EPS, denom)
    alfa_g  = jnp.minimum(-(denom/dstep), -_EPS)   # guarded alfa (<0)
    sqrt_n2 = jnp.sqrt(-2.0 * alfa_g)

    beta   = um - uu - alfa_g * sm
    ga     = -iim + (f1*vm - f0*um) / safe_d
    c      = jnp.sqrt(jnp.pi / (-2.0*alfa_g)) * (
                 f0*um*(vv - vm) + f1*vm*(um - uu)) / safe_d

    xi0 = (beta + alfa_g*s0) / sqrt_n2

    den_ii  = uu - vv + um*(f0 - 1.0) + vm*(1.0 - f1)
    num_ii  = uu + um*(f0 - 1.0)
    ssii    = jnp.where(jnp.abs(den_ii) < _EPS, num_ii/_EPS, num_ii/den_ii)
    den_im  = f0*um - f1*vm
    ssiim   = jnp.where(jnp.abs(den_im) < _EPS, f0*um/_EPS, f0*um/den_im)

    xin_end  = (vm - vv) / sqrt_n2
    xin_z_ii = (beta + alfa_g*(ssii*dstep + sm)) / sqrt_n2
    xin_z_im = (beta + alfa_g*(ssiim*dstep + sm)) / sqrt_n2

    def rval(xic):
        return _r_aneg(r0, ga, xi0, xic, c)

    no_z_ii  = (ssii <= 0.0) | (ssii >= 1.0)
    cfg_13   = (no_z_ii & ((uu > 0.0) | (vv > 0.0))) | (~no_z_ii & (vv > 0.0))
    cfg_4ii  = no_z_ii & ~((uu > 0.0) | (vv > 0.0))
    cfg_2e   = ~no_z_ii & ~(vv > 0.0) & (s0 <= sm + ssii*dstep)
    cfg_2w   = ~no_z_ii & ~(vv > 0.0) & (s0 >  sm + ssii*dstep)
    try_east = cfg_13 | cfg_2e
    go_west_d = cfg_4ii | cfg_2w

    xin_e  = jnp.where(cfg_2e, xin_z_ii, xin_end)
    dr_e   = rval(xin_e) - ii
    drds   = c * jnp.sqrt(-2.0*alfa_g/jnp.pi) - sqrt_n2*xi0*(r0 + ga)
    sec    = (drds <= 0.0) & (ssiim > 0.0) & (ssiim < 1.0) & (vm > 0.0)
    dra_im = rval(xin_z_im) - iim

    east_ok  = try_east & (dr_e >= 0.0) & ~(sec & (dra_im < 0.0))
    go_west  = go_west_d | (try_east & ~east_ok)

    no_z_im   = (ssiim <= 0.0) | (ssiim >= 1.0)
    cfg_3b    = no_z_im  & ~((um > 0.0) | (vm > 0.0))
    cfg_3c    = ~no_z_im & (vm > 0.0) & (s0 < sm + ssiim*dstep)
    cfg_3d    = ~no_z_im & ~(vm > 0.0)
    west_case = cfg_3b | cfg_3c | cfg_3d
    xin_w     = jnp.where(cfg_3c, xin_z_im, xin_end)
    dr_w      = rval(xin_w) - iim
    west_ok   = go_west & west_case & (dr_w <= 0.0)

    do_east = east_ok
    do_west = west_ok & ~east_ok

    rw_sel = jnp.where(do_east, ii*1.0, jnp.where(do_west, iim*1.0, -99.0))
    xin_s  = jnp.where(do_east, xin_e, jnp.where(do_west, xin_w, xi0))

    def _bisect(_, carry):
        xa, xb, xc = carry
        dra = rval(xa) - rw_sel
        drc = rval(xc) - rw_sel
        new_xa = jnp.where(dra*drc > 0.0, xc, xa)
        new_xb = jnp.where(dra*drc > 0.0, xb, xc)
        return (new_xa, new_xb, 0.5*(new_xa + new_xb))

    _, _, xc = lax.fori_loop(0, 100, _bisect, (xi0, xin_s, 0.5*(xi0 + xin_s)))
    sw = jnp.where(do_east | do_west, (sqrt_n2*xc - beta)/alfa_g, UNDEF)
    return rw_sel, sw


# ---------------------------------------------------------------------------
# Wall-crossing time for alpha = 0
# ---------------------------------------------------------------------------
def _azer_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1):
    """Normalized crossing time (rw, sw) for the alpha = 0 regime."""
    _EPS = 1.0e-10
    beta  = um - uu
    gamma = (f0*um - f1*vm) / dstep

    # ------------------------------------------------------------------
    # Case A: beta = 0, gamma = 0  (uniform constant flow)
    # ------------------------------------------------------------------
    delta = -f0 * um
    rw_A  = jnp.where(delta < 0.0, ii*1.0,
             jnp.where(delta > 0.0, iim*1.0, -99.0))
    sw_A  = jnp.where(jnp.abs(delta) > _EPS,
                       s0 - (rw_A - r0)/delta, UNDEF)

    # ------------------------------------------------------------------
    # Case B: beta = 0, gamma ≠ 0  (uniform time-varying, quadratic)
    # r(s) = r0 − ds·(0.5·gamma·(s + s0 − 2·sm) − f0·um)
    # ------------------------------------------------------------------
    g_safe   = jnp.where(jnp.abs(gamma) > _EPS, gamma, _EPS)
    del_ga   = -(f0*um) / g_safe - sm        # position of parabola vertex − sm

    s0d      = s0 + del_ga                   # s0 + del_ga
    pos_reg  = s0d >= 0.0                    # which branch of Fortran logic

    # Wall choice when s0+del_ga >= 0
    rw_B1   = jnp.where(gamma > 0.0, iim*1.0, ii*1.0)
    disc1   = s0d**2 - 2.0*(rw_B1 - r0) / g_safe
    sw_B1   = -del_ga + jnp.sqrt(jnp.maximum(disc1, 0.0))  # pos region

    # Wall choice when s0+del_ga < 0, first try
    rw_B2a  = jnp.where(gamma > 0.0, ii*1.0, iim*1.0)
    disc2a  = s0d**2 - 2.0*(rw_B2a - r0) / g_safe
    sw_B2a  = -del_ga - jnp.sqrt(jnp.maximum(disc2a, 0.0))  # neg region, first try

    # Fallback if disc2a < 0: swap wall
    rw_B2b  = jnp.where(gamma > 0.0, iim*1.0, ii*1.0)
    disc2b  = s0d**2 - 2.0*(rw_B2b - r0) / g_safe
    sw_B2b  = -del_ga + jnp.sqrt(jnp.maximum(disc2b, 0.0))

    has_disc2a = disc2a >= 0.0
    rw_B    = jnp.where(pos_reg, rw_B1,
               jnp.where(has_disc2a, rw_B2a, rw_B2b))
    sw_B    = jnp.where(pos_reg, sw_B1,
               jnp.where(has_disc2a, sw_B2a, sw_B2b))
    disc_B  = jnp.where(pos_reg, disc1,
               jnp.where(has_disc2a, disc2a, disc2b))
    sw_B    = jnp.where(disc_B >= 0.0, sw_B, UNDEF)

    # ------------------------------------------------------------------
    # Case C: beta ≠ 0, gamma = 0  (exponential, space-varying)
    # ------------------------------------------------------------------
    b_safe   = jnp.where(jnp.abs(beta) > _EPS, beta, _EPS)
    denom_e  = uu - (1.0 - f0)*um
    de_safe  = jnp.where(jnp.abs(denom_e) > _EPS, denom_e, _EPS)
    xi_e     = 1.0 + (ii - r0)*beta / de_safe
    sw_eC    = jnp.where((uu > 0.0) & (xi_e > 0.0),
                          s0 + jnp.log(jnp.maximum(xi_e, _EPS))/b_safe,
                          UNDEF)
    fu_safe  = jnp.where(jnp.abs(f0*um) > _EPS, f0*um, _EPS)
    xi_w     = 1.0 - (r0 - iim)*beta / fu_safe
    sw_wC    = jnp.where((um < 0.0) & (xi_w > 0.0),
                          s0 + jnp.log(jnp.maximum(xi_w, _EPS))/b_safe,
                          UNDEF)
    rw_C     = jnp.where(sw_eC <= sw_wC,
                          jnp.where(sw_eC < UNDEF/2, ii*1.0, -99.0),
                          jnp.where(sw_wC < UNDEF/2, iim*1.0, -99.0))
    sw_C     = jnp.minimum(sw_eC, sw_wC)

    # ------------------------------------------------------------------
    # Case D: beta ≠ 0, gamma ≠ 0  (bisection in s-space)
    # ------------------------------------------------------------------
    ssii_D   = (f0*uu - beta) / g_safe
    ssiim_D  = f0*um / g_safe
    exp_term = (r0 - iim) + (gamma*(s0 - sm - 1.0/b_safe) - f0*um)/b_safe
    drds_D   = f0*um - gamma*(s0 - sm) - beta*(r0 - iim)

    def r_s(s):
        ds = s - s0
        return r0 + exp_term*(jnp.exp(-beta*ds) - 1.0) - gamma*ds/b_safe

    no_z_iiD  = (ssii_D <= 0.0) | (ssii_D >= dstep)
    cfg1D     = (no_z_iiD & ((uu > 0.0) | (vv > 0.0))) | \
                (~no_z_iiD & (vv > 0.0))
    cfg4D     = no_z_iiD & ~((uu > 0.0) | (vv > 0.0))
    cfg2eD    = ~no_z_iiD & ~(vv > 0.0) & (s0 <= sm + ssii_D)
    cfg2wD    = ~no_z_iiD & ~(vv > 0.0) & (s0 >  sm + ssii_D)
    try_e_D   = cfg1D | cfg2eD
    gw_d_D    = cfg4D | cfg2wD

    si00_end  = sm + dstep - s0
    si00_z_ii = sm + ssii_D - s0
    si00_z_im = sm + ssiim_D - s0

    si00_e_D  = jnp.where(cfg2eD, si00_z_ii, si00_end)
    dr_e_D    = r_s(s0 + si00_e_D) - ii
    sec_D     = (drds_D <= 0.0) & (ssiim_D > 0.0) & \
                (ssiim_D < dstep) & (vm > 0.0)
    dra_D     = r_s(s0 + si00_z_im) - iim

    east_D    = try_e_D & (dr_e_D >= 0.0) & ~(sec_D & (dra_D < 0.0))
    gw_D      = gw_d_D | (try_e_D & ~east_D)

    no_z_imD  = (ssiim_D <= 0.0) | (ssiim_D >= dstep)
    cfg3bD    = no_z_imD  & ~((um > 0.0) | (vm > 0.0))
    cfg3cD    = ~no_z_imD & (vm > 0.0) & (s0 < sm + ssiim_D)
    cfg3dD    = ~no_z_imD & ~(vm > 0.0)
    wcase_D   = cfg3bD | cfg3cD | cfg3dD
    si00_w_D  = jnp.where(cfg3cD, si00_z_im, si00_end)
    dr_w_D    = r_s(s0 + si00_w_D) - iim
    west_D    = gw_D & wcase_D & (dr_w_D <= 0.0)

    de_D   = east_D
    dw_D   = west_D & ~east_D
    rw_D   = jnp.where(de_D, ii*1.0, jnp.where(dw_D, iim*1.0, -99.0))
    si00_D = jnp.where(de_D, si00_e_D, jnp.where(dw_D, si00_w_D, 0.0))

    def _bisect_s(_, carry):
        xa, xb, xc = carry
        dra = r_s(xa) - rw_D
        drc = r_s(xc) - rw_D
        new_xa = jnp.where(dra*drc > 0.0, xc, xa)
        new_xb = jnp.where(dra*drc > 0.0, xb, xc)
        return (new_xa, new_xb, 0.5*(new_xa + new_xb))

    _, _, xc_D = lax.fori_loop(
        0, 100, _bisect_s, (s0, s0 + si00_D, s0 + 0.5*si00_D)
    )
    sw_D = jnp.where(de_D | dw_D, xc_D, UNDEF)

    # ------------------------------------------------------------------
    # Select among A / B / C / D
    # ------------------------------------------------------------------
    bz = jnp.abs(beta)  < _EPS
    gz = jnp.abs(gamma) < _EPS
    rw = jnp.where(bz & gz,   rw_A,
         jnp.where(bz & ~gz,  rw_B,
         jnp.where(~bz & gz,  rw_C, rw_D)))
    sw = jnp.where(bz & gz,   sw_A,
         jnp.where(bz & ~gz,  sw_B,
         jnp.where(~bz & gz,  sw_C, sw_D)))
    return rw, sw


# ---------------------------------------------------------------------------
# 1-D analytical crossing-time dispatcher
# ---------------------------------------------------------------------------
def _cross_time_analytical_1d(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1):
    """Return (sp, sn) crossing times for the time-analytical scheme.

    sp = time to cross positive wall (ii), sn = time to cross negative wall (iim).
    Both in normalized (dxyz-divided) units.  UNDEF means no crossing.
    """
    alfa_sign = -(vv - vm - uu + um)

    rw_p, sw_p = _apos_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1)
    rw_n, sw_n = _aneg_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1)
    rw_z, sw_z = _azer_crossing(uu, um, vv, vm, ii, iim, r0, sm, s0, dstep, f0, f1)

    rw = jnp.where(alfa_sign > 0.0, rw_p,
         jnp.where(alfa_sign < 0.0, rw_n, rw_z))
    sw = jnp.where(alfa_sign > 0.0, sw_p,
         jnp.where(alfa_sign < 0.0, sw_n, sw_z))

    sp = jnp.where(rw == ii*1.0,  sw - s0, UNDEF)
    sn = jnp.where(rw == iim*1.0, sw - s0, UNDEF)
    sp = jnp.where(sp <= 0.0, UNDEF, sp)
    sn = jnp.where(sn <= 0.0, UNDEF, sn)
    return sp, sn


# ---------------------------------------------------------------------------
# 1-D analytical position update
# ---------------------------------------------------------------------------
def _calc_pos_analytical_1d(uu, um, vv, vm, ii, iim, r0, ds, sm, s0, dstep, f0, f1):
    """New position after normalized time ds (analytical scheme).

    Fluxes uu/um/vv/vm should already be adjusted by f0/f1 for vertical dir.
    """
    _EPS = 1.0e-10
    alfa_sign = -(vv - vm - uu + um)
    denom  = vv - vm - uu + um
    safe_d = jnp.where(jnp.abs(denom) < _EPS, _EPS, denom)

    # --- alpha > 0 ---
    alfa_p   = jnp.maximum(-denom/dstep, _EPS)
    sqrt2a_p = jnp.sqrt(2.0 * alfa_p)
    beta_p   = um - uu - alfa_p*sm
    xi0_p    = (beta_p + alfa_p*s0) / sqrt2a_p
    xis_p    = (beta_p + alfa_p*(s0 + ds)) / sqrt2a_p
    ga_p     = -iim + (f1*vm - f0*um) / safe_d
    daw_t    = jnp.sqrt(2.0/alfa_p) * (f0*um*(vv-vm) + f1*vm*(um-uu)) / safe_d
    r1_pos   = (r0 + ga_p)*jnp.exp(xi0_p**2 - xis_p**2) - ga_p + daw_t*(
                   _dawson(xis_p) - jnp.exp(xi0_p**2 - xis_p**2)*_dawson(xi0_p))

    # --- alpha < 0 ---
    alfa_n   = jnp.minimum(-denom/dstep, -_EPS)
    sqrt_n2  = jnp.sqrt(-2.0 * alfa_n)
    beta_n   = um - uu - alfa_n*sm
    xi0_n    = (beta_n + alfa_n*s0) / sqrt_n2
    xis_n    = (beta_n + alfa_n*(s0 + ds)) / sqrt_n2
    ga_n     = -iim + (f1*vm - f0*um) / safe_d
    erf_t    = jnp.sqrt(jnp.pi/(-2.0*alfa_n)) * (
                   f0*um*(vv-vm) + f1*vm*(um-uu)) / safe_d
    r1_neg   = (r0 + ga_n)*jnp.exp(xis_n**2 - xi0_n**2) + \
                _erfexp_val(erf_t, xi0_n, xis_n) - ga_n

    # --- alpha = 0, beta = 0 ---
    gamma_z  = (f0*um - f1*vm) / dstep
    r1_zbz   = r0 - ds*(0.5*gamma_z*(2.0*s0 + ds - 2.0*sm) - f0*um)

    # --- alpha = 0, beta ≠ 0 ---
    beta_z   = um - uu
    b_safe   = jnp.where(jnp.abs(beta_z) > _EPS, beta_z, _EPS)
    exp_term = (r0 - iim) + (gamma_z*(s0 - sm - 1.0/b_safe) - f0*um)/b_safe
    r1_zbg   = r0 + exp_term*(jnp.exp(-beta_z*ds) - 1.0) - gamma_z*ds/b_safe

    bz    = jnp.abs(beta_z) < _EPS
    r1_z  = jnp.where(bz, r1_zbz, r1_zbg)

    r1 = jnp.where(alfa_sign > 0.0, r1_pos,
         jnp.where(alfa_sign < 0.0, r1_neg, r1_z))

    # Clamp small overshoots (< 1e-7) back inside
    r1 = jnp.where((r1 - ii  > 0.0) & (r1 - ii  < 1.0e-7), ii  - 1.0e-7, r1)
    r1 = jnp.where((iim - r1 > 0.0) & (iim - r1 < 1.0e-7), iim + 1.0e-7, r1)
    return r1


# ---------------------------------------------------------------------------
# Analytical-scheme particle kernel
# ---------------------------------------------------------------------------
def _subcycle_one_particle_analytical(
    x0, y0, z0, ia, ja, ka, tt, ts, dxyz, tseas, grid, fluxes, no_wflux=False
):
    """Advance one particle by one sub-cycle using the time-analytical scheme.

    Reads fluxes from both time slots of *fluxes* directly (no intrpg
    interpolation).  Static grid data (kmt, dimensions, periodicity) is
    read from *grid*.

    Returns (x1, y1, z1, ib, jb, kb, ds, still_active).

    When *no_wflux* is True (a Python bool, resolved at JIT compile time) the
    vertical flux is ignored: particles never cross depth-layer walls and their
    z-coordinate and ka index are held fixed.
    """
    imt, jmt, km, iperio = grid.imt, grid.jmt, grid.km, grid.iperio

    dstep = tseas / dxyz                       # window duration (normalized)
    sm    = jnp.floor(ts) * dstep             # lower window boundary
    s0    = tt / dxyz                          # current normalized time
    dsmin = dstep - (s0 - sm)                 # remaining window time

    iam = jnp.where((ia - 1 == 0) & (iperio == 1), imt, ia - 1)

    # --- Zonal (x) ---
    uu_x     = fluxes.uflux[ia-1, ja-1, ka-1, 0]
    um_x_raw = fluxes.uflux[iam-1, ja-1, ka-1, 0]
    um_x     = jnp.where((ia - 1 == 0) & (iperio == 0), 0.0, um_x_raw)
    vv_x     = fluxes.uflux[ia-1, ja-1, ka-1, 1]
    vm_x_raw = fluxes.uflux[iam-1, ja-1, ka-1, 1]
    vm_x     = jnp.where((ia - 1 == 0) & (iperio == 0), 0.0, vm_x_raw)
    # Enforce no-flow BC: zero flux toward any land neighbor (catches residuals
    # not removed by the fill-value threshold in flux_grid.compute()).
    _e_land = grid.kmt[jnp.clip(ia,    0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0
    _w_land = grid.kmt[jnp.clip(iam-1, 0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0
    uu_x = jnp.where(_e_land, 0.0, uu_x)
    um_x = jnp.where(_w_land, 0.0, um_x)
    vv_x = jnp.where(_e_land, 0.0, vv_x)
    vm_x = jnp.where(_w_land, 0.0, vm_x)

    dse, dsw = _cross_time_analytical_1d(
        uu_x, um_x, vv_x, vm_x,
        ia*1.0, iam*1.0, x0, sm, s0, dstep, 1.0, 1.0,
    )

    # --- Meridional (y) ---
    uu_y = fluxes.vflux[ia-1, ja-1, ka-1, 0]
    um_y = fluxes.vflux[ia-1, ja-2, ka-1, 0]
    vv_y = fluxes.vflux[ia-1, ja-1, ka-1, 1]
    vm_y = fluxes.vflux[ia-1, ja-2, ka-1, 1]
    _n_land = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja,   0, jmt-1)] == 0
    _s_land = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja-2, 0, jmt-1)] == 0
    uu_y = jnp.where(_n_land, 0.0, uu_y)
    um_y = jnp.where(_s_land, 0.0, um_y)
    vv_y = jnp.where(_n_land, 0.0, vv_y)
    vm_y = jnp.where(_s_land, 0.0, vm_y)

    dsn, dss = _cross_time_analytical_1d(
        uu_y, um_y, vv_y, vm_y,
        ja*1.0, (ja-1)*1.0, y0, sm, s0, dstep, 1.0, 1.0,
    )

    # --- Vertical (z), with thickness correction ---
    if no_wflux:
        dsu = UNDEF
        dsd = UNDEF
    else:
        uu_z_r  = fluxes.wflux[ia-1, ja-1, ka-1, 0]
        km_idx  = jnp.clip(ka - 2, 0, km)          # 0-based lower-wall index
        um_z_r  = fluxes.wflux[ia-1, ja-1, km_idx, 0]
        vv_z_r  = fluxes.wflux[ia-1, ja-1, ka-1, 1]
        vm_z_r  = fluxes.wflux[ia-1, ja-1, km_idx, 1]

        dzt1   = fluxes.dzt[ia-1, ja-1, ka-1, 0]
        dzt2   = fluxes.dzt[ia-1, ja-1, ka-1, 1]
        intrpg_z = jnp.mod(ts, 1.0)
        dzs    = (1.0 - intrpg_z)*dzt1 + intrpg_z*dzt2
        f0_z   = dzt1 / jnp.maximum(dzs, EPS)
        f1_z   = dzt2 / jnp.maximum(dzs, EPS)
        uu_z   = uu_z_r / f0_z;  um_z = um_z_r / f0_z
        vv_z   = vv_z_r / f1_z;  vm_z = vm_z_r / f1_z
        # Sea surface (ka==1): no flux above the surface layer.  km_idx clips
        # ka-2=-1 to 0, making um_z == uu_z and giving a spurious finite dsd
        # that pushes particles out through kb=0.
        um_z = jnp.where(ka == 1, 0.0, um_z)
        vm_z = jnp.where(ka == 1, 0.0, vm_z)

        dsu, dsd = _cross_time_analytical_1d(
            uu_z, um_z, vv_z, vm_z,
            ka*1.0, (ka-1)*1.0, z0, sm, s0, dstep, f0_z, f1_z,
        )

    # Minimum crossing time
    ds = jnp.minimum(
        jnp.minimum(jnp.minimum(dse, dsw), jnp.minimum(dsn, dss)),
        jnp.minimum(jnp.minimum(dsu, dsd), dsmin),
    )

    hit_e = (ds == dse); hit_w = (ds == dsw)
    hit_n = (ds == dsn); hit_s = (ds == dss)
    # When no_wflux, vertical walls are never the limiting step
    if no_wflux:
        hit_u = jnp.zeros_like(hit_e)
        hit_d = jnp.zeros_like(hit_e)
    else:
        hit_u = (ds == dsu); hit_d = (ds == dsd)
    hit_none = ~(hit_e | hit_w | hit_n | hit_s | hit_u | hit_d)

    # Position updates
    x1c = _calc_pos_analytical_1d(
        uu_x, um_x, vv_x, vm_x, ia*1.0, iam*1.0, x0, ds, sm, s0, dstep, 1.0, 1.0)
    x1 = jnp.where(hit_e, ia*1.0, jnp.where(hit_w, iam*1.0, x1c))

    y1c = _calc_pos_analytical_1d(
        uu_y, um_y, vv_y, vm_y, ja*1.0, (ja-1)*1.0, y0, ds, sm, s0, dstep, 1.0, 1.0)
    y1 = jnp.where(hit_n, ja*1.0, jnp.where(hit_s, (ja-1)*1.0, y1c))

    if no_wflux:
        z1 = z0
    else:
        z1c = _calc_pos_analytical_1d(
            uu_z, um_z, vv_z, vm_z, ka*1.0, (ka-1)*1.0, z0, ds, sm, s0, dstep, f0_z, f1_z)
        z1 = jnp.where(hit_u, ka*1.0, jnp.where(hit_d, (ka-1)*1.0, z1c))
        z1 = jnp.where(z1 > km, km*1.0, z1)

    # Box indices
    ib, jb, kb = ia, ja, ka
    ib = jnp.where(hit_e & (uu_x > 0.0), ia + 1, ib)
    ib = jnp.where((ib > imt) & (iperio == 1), ib - imt, ib)
    ib = jnp.where(hit_w & (um_x < 0.0), iam, ib)
    jb = jnp.where(hit_n & (uu_y > 0.0), ja + 1, jb)
    jb = jnp.where(hit_s & (um_y < 0.0), ja - 1, jb)
    if not no_wflux:
        kb = jnp.where(hit_u & (uu_z > 0.0), ka + 1, kb)
        kb = jnp.where(kb > km, km, kb)
        kb = jnp.where(hit_d & (um_z < 0.0), ka - 1, kb)

    # Corners — only step to the diagonal cell if it is ocean; otherwise the
    # cardinal crossing already recorded in ib/jb is kept.
    _ne_ocean = grid.kmt[jnp.clip(ia,    0, imt-1), jnp.clip(ja,   0, jmt-1)] > 0
    _nw_ocean = grid.kmt[jnp.clip(iam-1, 0, imt-1), jnp.clip(ja,   0, jmt-1)] > 0
    _se_ocean = grid.kmt[jnp.clip(ia,    0, imt-1), jnp.clip(ja-2, 0, jmt-1)] > 0
    ib = jnp.where(hit_n & (ds == dse) & _ne_ocean, ia + 1, ib)
    ib = jnp.where(hit_n & (ds == dsw) & _nw_ocean, iam,    ib)
    jb = jnp.where(hit_e & (ds == dsn) & _ne_ocean, ja + 1, jb)
    jb = jnp.where(hit_e & (ds == dss) & _se_ocean, ja - 1, jb)
    if not no_wflux:
        kb = jnp.where(hit_e & (ds == dsu), ka + 1, kb)
        kb = jnp.where(hit_e & (ds == dsd), ka - 1, kb)

    all_undef = ((dse >= UNDEF) & (dsw >= UNDEF) & (dsn >= UNDEF) &
                 (dss >= UNDEF) & (dsu >= UNDEF) & (dsd >= UNDEF))
    ib = jnp.where(hit_none & all_undef, ia, ib)
    jb = jnp.where(hit_none & all_undef, ja, jb)
    kb = jnp.where(hit_none & all_undef, ka, kb)

    # Cyclic wrap — strict x1 > imt matches Fortran mod_pos_tstep.F90.
    # x1 == imt is left unwrapped intentionally: both a westward crossing into
    # cell imt (ib=imt, x1=imt) and an eastward crossing out of cell imt
    # (ib=1, x1=imt) leave x1=imt; _calc_pos_analytical_1d handles r0=imt
    # with ii=1 via the r0_adj special case on the next sub-step.
    x1 = jnp.where((iperio != 0) & (x1 <= 0.0), x1 + imt, x1)
    x1 = jnp.where((iperio != 0) & (x1 > imt),  x1 - imt, x1)

    x1f = jnp.floor(x1); y1f = jnp.floor(y1); z1f = jnp.floor(z1)
    ib = jnp.where(x1 != x1f, x1f.astype(jnp.int32) + 1, ib)
    jb = jnp.where(y1 != y1f, y1f.astype(jnp.int32) + 1, jb)
    kb = jnp.where(z1 != z1f, z1f.astype(jnp.int32) + 1, kb)

    on_land      = grid.kmt[jnp.clip(ib-1, 0, imt-1), jnp.clip(jb-1, 0, jmt-1)] == 0
    out_of_bounds = (jb < 1) | (jb > jmt) | (kb < 1) | (kb > km)
    still_active  = ~on_land & ~out_of_bounds

    return x1, y1, z1, ib, jb, kb, ds, still_active


# ===========================================================================
# Vectorised step — analytical scheme
# ===========================================================================
[docs] def analytical(grid, tseas: float, no_wflux: bool = False): """Return a JIT-compiled step function that advances all particles analytically. *grid* (a ``GridFields``) is closed over as XLA constants. The returned function takes a ``FluxFields`` as its first dynamic argument, enabling lazy loading: flux data need not be computed until the step function is actually called. Parameters ---------- grid : GridFields Static grid geometry. Closed over by the compiled kernel. tseas : float GCM time-window duration in seconds. no_wflux : bool When True, vertical (w) fluxes are ignored. Automatically True when ``fluxes.wflux is None`` at call time, but must be set here to select the compiled variant. """ from tracmass.gcm.flux_grid import edge_mask # Outer ring of cells on non-periodic axes: entering it leaves the domain. edge = jnp.asarray(edge_mask(grid)) # (imt, jmt) bool @jax.jit def step_all(fluxes, state: ParticleState, intrpg: float, dsmin: float) -> ParticleState: ib, jb, kb = state.ia, state.ja, state.ka i0 = jnp.clip(ib - 1, 0, grid.imt - 1) j0 = jnp.clip(jb - 1, 0, grid.jmt - 1) k0 = jnp.clip(kb - 1, 0, grid.km - 1) dxyz_raw = grid.dxdy[i0, j0] * ( intrpg * fluxes.dzt[i0, j0, k0, 1] * fluxes.zstot[i0, j0, 1] + (1.0 - intrpg) * fluxes.dzt[i0, j0, k0, 0] * fluxes.zstot[i0, j0, 0] ) zero_volume = state.active & (dxyz_raw <= 0.0) dxyz = jnp.maximum(dxyz_raw, EPS) x1, y1, z1, ib_new, jb_new, kb_new, ds, still_ok = jax.vmap( lambda x, y, z, ia, ja, ka, dv, tt, ts: _subcycle_one_particle_analytical( x, y, z, ia, ja, ka, tt, ts, dv, tseas, grid, fluxes, no_wflux ) )(state.x, state.y, state.z, state.ia, state.ja, state.ka, dxyz, state.tt, state.ts) dt_particles = ds * dxyz new_tt = state.tt + dt_particles i0_new = jnp.clip(ib_new - 1, 0, grid.imt - 1) j0_new = jnp.clip(jb_new - 1, 0, grid.jmt - 1) on_land = grid.kmt[i0_new, j0_new] == 0 horiz_oob = (jb_new < 1) | (jb_new > grid.jmt) | edge[i0_new, j0_new] vert_oob = (kb_new < 1) | (kb_new > grid.km) new_reason = jnp.where( zero_volume, REASON_DXYZ, jnp.where( state.active & on_land, REASON_LAND, jnp.where( state.active & ~on_land & horiz_oob, REASON_BOUNDS_H, jnp.where(state.active & ~on_land & vert_oob, REASON_BOUNDS_V, state.reason), ), ), ) new_active = state.active & still_ok & ~zero_volume & ~horiz_oob return ParticleState( x=jnp.where(state.active, x1, state.x), y=jnp.where(state.active, y1, state.y), z=jnp.where(state.active, z1, state.z), ia=jnp.where(state.active, ib_new, state.ia), ja=jnp.where(state.active, jb_new, state.ja), ka=jnp.where(state.active, kb_new, state.ka), tt=jnp.where(state.active, new_tt, state.tt), ts=jnp.where(state.active, state.ts + dt_particles / state.subvol, state.ts), t0=state.t0, subvol=state.subvol, niter=jnp.where(state.active, state.niter + 1, state.niter), active=new_active, reason=new_reason, ) return step_all
# =========================================================================== # Piecewise-linear scheme # =========================================================================== def _calc_pos_1d(uu, um, ii, r0, ds, iperio, imt): """New position after advection for time ds.""" ratio = jnp.where(jnp.abs(uu - um) > 1e-30, um / (uu - um), 1.0e15) use_exp = jnp.abs(ratio) < 1.0e14 r0_adj = jnp.where( (iperio == 1) & (r0 == imt) & (ii == 1), 0.0, r0, ) r1_exp = (r0_adj + (-ii + 1.0 + ratio)) * jnp.exp((uu - um) * ds) + (ii - 1.0) - ratio r1_lin = r0_adj + uu * ds return jnp.where(use_exp, r1_exp, r1_lin) def _subcycle_one_particle( x0, y0, z0, ia, ja, ka, intrpg, dsmin, dxyz, grid, fluxes, no_wflux=False ): """Advance one particle by one sub-cycle. Returns (x1,y1,z1,ib,jb,kb,ds,active). Static grid data (kmt, dimensions, periodicity) is read from *grid*. Time-varying flux data is read from *fluxes*. When *no_wflux* is True (a Python bool, resolved at JIT compile time) the vertical flux is ignored: particles never cross depth-layer walls and their z-coordinate and ka index are held fixed. """ imt, jmt, km, iperio = grid.imt, grid.jmt, grid.km, grid.iperio intrpr = 1.0 - intrpg iam = jnp.where((ia - 1 == 0) & (iperio == 1), imt, ia - 1) # ----------------------------------------------------------------------- # update_bounce (Fortran mod_pos_tstep.F90): when a particle sits exactly # on a cell wall and the time-interpolated flux pushes it further into the # adjacent cell, advance the cell index before computing crossing times. # This prevents indefinite oscillation (crossing time = 0) at walls. # # Phase 1 — read fluxes at the PRE-BOUNCE indices to determine direction. # Phase 2 — after updating ia/iam/ja/ka, re-read fluxes at the # POST-BOUNCE indices for the crossing-time calculation. # The re-read is essential: using pre-bounce fluxes with post-bounce # indices gives incorrect (often huge) crossing times that cause step-size # errors and tt-overshoot past timax_seconds. # ----------------------------------------------------------------------- # --- Phase 1: bounce direction check --- # Precompute land-neighbor flags (kmt lookups on the static grid). # These prevent the bounce from firing toward a land cell even when the # stored velocity has a small residual value that is not a fill-value. _e_land = grid.kmt[jnp.clip(ia, 0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0 _w_land = grid.kmt[jnp.clip(iam-1, 0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0 _n_land = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja, 0, jmt-1)] == 0 _s_land = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja-2, 0, jmt-1)] == 0 # X: east/west _bx_uu = _flux_interp(fluxes.uflux, ia, ja, ka, intrpg) _bx_um_raw = _flux_interp(fluxes.uflux, iam, ja, ka, intrpg) _bx_um = jnp.where((ia - 1 == 0) & (iperio == 0), 0.0, _bx_um_raw) _bx_e = (x0 == ia.astype(jnp.float32)) & (_bx_uu > 0.0) & ((ia < imt) | (iperio == 1)) & ~_e_land _bx_w = (x0 == iam.astype(jnp.float32)) & (_bx_um < 0.0) & ~_w_land # Y: north/south _by_uu = _flux_interp(fluxes.vflux, ia, ja, ka, intrpg) _km_y = jnp.maximum(ja - 2, 0) # safe 0-based south-wall index _by_um = intrpg * fluxes.vflux[ia-1, _km_y, ka-1, 1] + intrpr * fluxes.vflux[ia-1, _km_y, ka-1, 0] _by_um = jnp.where(ja == 1, 0.0, _by_um) # no south flux at southern boundary _by_n = (y0 == ja.astype(jnp.float32)) & (_by_uu > 0.0) & (ja < jmt) & ~_n_land _by_s = (y0 == (ja - 1).astype(jnp.float32)) & (_by_um < 0.0) & (ja > 1) & ~_s_land if not no_wflux: # Z: up/down _bz_uu = _flux_interp(fluxes.wflux, ia, ja, ka, intrpg) _km_idx = jnp.maximum(ka - 2, 0) _bz_um = intrpg * fluxes.wflux[ia-1, ja-1, _km_idx, 1] + intrpr * fluxes.wflux[ia-1, ja-1, _km_idx, 0] _bz_u = (z0 == ka.astype(jnp.float32)) & (_bz_uu > 0.0) & (ka < km) _bz_d = (z0 == (ka - 1).astype(jnp.float32)) & (_bz_um < 0.0) & (ka > 1) # --- Apply bounces: update ia/iam, ja, ka --- _old_ia, _old_iam = ia, iam ia = jnp.where(_bx_e, _old_ia + 1, jnp.where(_bx_w, _old_iam, _old_ia)) iam = jnp.where(_bx_e, _old_ia, jnp.where(_bx_w, _old_iam - 1, _old_iam)) ia = jnp.where((ia > imt) & (iperio == 1), ia - imt, ia) iam = jnp.where((iam == 0) & (iperio == 1), imt, iam) ja = jnp.where(_by_n, ja + 1, jnp.where(_by_s, ja - 1, ja)) if not no_wflux: ka = jnp.where(_bz_u, ka + 1, jnp.where(_bz_d, ka - 1, ka)) ka = jnp.clip(ka, 1, km) # --- Phase 2: re-read fluxes at post-bounce indices for crossing times --- # X crossing times uu_x = _flux_interp(fluxes.uflux, ia, ja, ka, intrpg) um_x_raw = _flux_interp(fluxes.uflux, iam, ja, ka, intrpg) um_x = jnp.where((ia - 1 == 0) & (iperio == 0), 0.0, um_x_raw) # Enforce no-flow BC: zero flux toward any land neighbor (catches residuals # not removed by the fill-value threshold in flux_grid.compute()). _e_land2 = grid.kmt[jnp.clip(ia, 0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0 _w_land2 = grid.kmt[jnp.clip(iam-1, 0, imt-1), jnp.clip(ja-1, 0, jmt-1)] == 0 uu_x = jnp.where(_e_land2, 0.0, uu_x) um_x = jnp.where(_w_land2, 0.0, um_x) dse, dsw = _cross_time_1d(uu_x, um_x, ia * 1.0, x0) # Y crossing times uu_y = _flux_interp(fluxes.vflux, ia, ja, ka, intrpg) _km_y2 = jnp.maximum(ja - 2, 0) um_y = intrpg * fluxes.vflux[ia-1, _km_y2, ka-1, 1] + intrpr * fluxes.vflux[ia-1, _km_y2, ka-1, 0] um_y = jnp.where(ja == 1, 0.0, um_y) # no south flux at southern boundary _n_land2 = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja, 0, jmt-1)] == 0 _s_land2 = grid.kmt[jnp.clip(ia-1, 0, imt-1), jnp.clip(ja-2, 0, jmt-1)] == 0 uu_y = jnp.where(_n_land2, 0.0, uu_y) um_y = jnp.where(_s_land2, 0.0, um_y) dsn, dss = _cross_time_1d(uu_y, um_y, ja * 1.0, y0) # Z crossing times if no_wflux: dsu = UNDEF dsd = UNDEF else: uu_z = _flux_interp(fluxes.wflux, ia, ja, ka, intrpg) km_idx_pw = jnp.maximum(ka - 2, 0) um_z = intrpg * fluxes.wflux[ia-1, ja-1, km_idx_pw, 1] + intrpr * fluxes.wflux[ia-1, ja-1, km_idx_pw, 0] # Sea surface (ka==1): km_idx_pw clips to 0, making um_z == uu_z and # giving a spurious finite dsd that exits particles through kb=0. um_z = jnp.where(ka == 1, 0.0, um_z) dsu, dsd = _cross_time_1d(uu_z, um_z, ka * 1.0, z0) ds = jnp.minimum(jnp.minimum(jnp.minimum(dse, dsw), jnp.minimum(dsn, dss)), jnp.minimum(jnp.minimum(dsu, dsd), dsmin)) # Which wall? hit_e = (ds == dse); hit_w = (ds == dsw) hit_n = (ds == dsn); hit_s = (ds == dss) # When no_wflux, vertical walls are never the limiting step if no_wflux: hit_u = jnp.zeros_like(hit_e) hit_d = jnp.zeros_like(hit_e) else: hit_u = (ds == dsu); hit_d = (ds == dsd) hit_none = ~(hit_e | hit_w | hit_n | hit_s | hit_u | hit_d) # Positions # EPS_WALL: nudge continuous positions off exact cell-wall values. # When ds is tiny (float32 ULP of tt), _calc_pos_1d can return exactly # the wall coordinate, causing a bounce to fire on every subsequent step # and trapping particles in a corner oscillation cycle. Mirroring the # clamping already present in _calc_pos_analytical_1d breaks the cycle. _EPS_WALL = jnp.float32(1e-5) x1c = _calc_pos_1d(uu_x, um_x, ia*1.0, x0, ds, iperio, imt*1.0) x1c = jnp.where(x1c == iam.astype(jnp.float32), x1c + _EPS_WALL, x1c) x1c = jnp.where(x1c == ia.astype(jnp.float32), x1c - _EPS_WALL, x1c) x1 = jnp.where(hit_e, ia*1.0, jnp.where(hit_w, iam*1.0, x1c)) y1c = _calc_pos_1d(uu_y, um_y, ja*1.0, y0, ds, 0, 0.0) y1c = jnp.where(y1c == (ja-1).astype(jnp.float32), y1c + _EPS_WALL, y1c) y1c = jnp.where(y1c == ja.astype(jnp.float32), y1c - _EPS_WALL, y1c) y1 = jnp.where(hit_n, ja*1.0, jnp.where(hit_s, (ja-1)*1.0, y1c)) if no_wflux: z1 = z0 else: z1c = _calc_pos_1d(uu_z, um_z, ka*1.0, z0, ds, 0, 0.0) z1c = jnp.where(z1c == (ka-1).astype(jnp.float32), z1c + _EPS_WALL, z1c) z1c = jnp.where(z1c == ka.astype(jnp.float32), z1c - _EPS_WALL, z1c) z1 = jnp.where(hit_u, ka*1.0, jnp.where(hit_d, (ka-1)*1.0, z1c)) z1 = jnp.where(z1 > km, km*1.0, z1) # Box indices ib, jb, kb = ia, ja, ka ib = jnp.where(hit_e & (uu_x > 0.0), ia + 1, ib) ib = jnp.where((ib > imt) & (iperio == 1), ib - imt, ib) ib = jnp.where(hit_w & (um_x < 0.0), iam, ib) jb = jnp.where(hit_n & (uu_y > 0.0), ja + 1, jb) jb = jnp.where(hit_s & (um_y < 0.0), ja - 1, jb) if not no_wflux: kb = jnp.where(hit_u & (uu_z > 0.0), ka + 1, kb) kb = jnp.where(kb > km, km, kb) kb = jnp.where(hit_d & (um_z < 0.0), ka - 1, kb) # Corners — only step to the diagonal cell if it is ocean; otherwise the # cardinal crossing already recorded in ib/jb is kept. _ne_ocean = grid.kmt[jnp.clip(ia, 0, imt-1), jnp.clip(ja, 0, jmt-1)] > 0 _nw_ocean = grid.kmt[jnp.clip(iam-1, 0, imt-1), jnp.clip(ja, 0, jmt-1)] > 0 _se_ocean = grid.kmt[jnp.clip(ia, 0, imt-1), jnp.clip(ja-2, 0, jmt-1)] > 0 ib = jnp.where(hit_n & (ds == dse) & _ne_ocean, ia+1, ib) ib = jnp.where(hit_n & (ds == dsw) & _nw_ocean, iam, ib) jb = jnp.where(hit_e & (ds == dsn) & _ne_ocean, ja+1, jb) jb = jnp.where(hit_e & (ds == dss) & _se_ocean, ja-1, jb) if not no_wflux: kb = jnp.where(hit_e & (ds == dsu), ka+1, kb) kb = jnp.where(hit_e & (ds == dsd), ka-1, kb) all_undef = (dse >= UNDEF) & (dsw >= UNDEF) & (dsn >= UNDEF) & \ (dss >= UNDEF) & (dsu >= UNDEF) & (dsd >= UNDEF) ib = jnp.where(hit_none & all_undef, ia, ib) jb = jnp.where(hit_none & all_undef, ja, jb) kb = jnp.where(hit_none & all_undef, ka, kb) # Cyclic wrap — strict x1 > imt matches Fortran mod_pos_tstep.F90. # x1 == imt is left unwrapped intentionally: both a westward crossing into # cell imt (ib=imt, x1=imt) and an eastward crossing out of cell imt # (ib=1, x1=imt) leave x1=imt; _calc_pos_1d handles r0=imt with ii=1 # via the r0_adj special case on the next sub-step. x1 = jnp.where((iperio != 0) & (x1 <= 0.0), x1 + imt, x1) x1 = jnp.where((iperio != 0) & (x1 > imt), x1 - imt, x1) x1f = jnp.floor(x1); y1f = jnp.floor(y1); z1f = jnp.floor(z1) ib = jnp.where(x1 != x1f, x1f.astype(jnp.int32) + 1, ib) jb = jnp.where(y1 != y1f, y1f.astype(jnp.int32) + 1, jb) kb = jnp.where(z1 != z1f, z1f.astype(jnp.int32) + 1, kb) # Termination checks on_land = grid.kmt[jnp.clip(ib-1, 0, imt-1), jnp.clip(jb-1, 0, jmt-1)] == 0 out_of_bounds = (jb < 1) | (jb > jmt) | (kb < 1) | (kb > km) still_active = ~on_land & ~out_of_bounds return x1, y1, z1, ib, jb, kb, ds, still_active # =========================================================================== # Vectorised step over all particles — piecewise-linear scheme # ===========================================================================
[docs] def piecewise(grid, no_wflux: bool = False): """Return a JIT-compiled step function that advances all particles by one sub-cycle. *grid* (a ``GridFields``) is closed over as XLA constants. The returned function takes a ``FluxFields`` as its first dynamic argument, enabling lazy loading: flux data need not be computed until the step function is actually called. Parameters ---------- grid : GridFields Static grid geometry. Closed over by the compiled kernel. no_wflux : bool When True, vertical (w) fluxes are ignored. Automatically True when ``fluxes.wflux is None`` at call time, but must be set here to select the compiled variant. """ from tracmass.gcm.flux_grid import edge_mask # Outer ring of cells on non-periodic axes: entering it leaves the domain. edge = jnp.asarray(edge_mask(grid)) # (imt, jmt) bool @jax.jit def step_all(fluxes, state: ParticleState, intrpg: float, dsmin: float) -> ParticleState: ib, jb, kb = state.ia, state.ja, state.ka i0 = jnp.clip(ib - 1, 0, grid.imt - 1) j0 = jnp.clip(jb - 1, 0, grid.jmt - 1) k0 = jnp.clip(kb - 1, 0, grid.km - 1) # Use the global time-interpolation weight (mean ts across all active # particles, computed in _integrate_window.body_fn and passed in as # `intrpg`) for all flux reads inside the particle kernel. # # A per-particle weight (mod(ts_i, 1.0)) changes by Δintrpg = Δts # between sub-cycles. In GLORYS (and similar reanalysis data), the # monthly flux can swing by orders of magnitude between slot 0 and # slot 1, so even Δintrpg ≈ 1e-4 (one fast cell crossing) is enough # to flip the interpolated flux sign at a wall. This causes the # update_bounce logic to fire in alternating directions on consecutive # sub-cycles, producing a "bounce-chain" oscillation with thousands # of sub-cycles per window. # # The Fortran TRACMASS reference uses global (mean) intrpg; reproducing # that here keeps the bounce stable while accepting a slight physical # approximation for particles spread across the window. # # Per-particle intrpg_pp is kept for `dxyz` (layer-thickness # interpolation) only, because that affects the step-size cap # (dsmin_arr) rather than the crossing direction. intrpg_pp = jnp.mod(state.ts, 1.0) # per-particle, for dxyz only dxyz_raw = grid.dxdy[i0, j0] * ( intrpg_pp * fluxes.dzt[i0, j0, k0, 1] * fluxes.zstot[i0, j0, 1] + (1.0 - intrpg_pp) * fluxes.dzt[i0, j0, k0, 0] * fluxes.zstot[i0, j0, 0] ) zero_volume = state.active & (dxyz_raw <= 0.0) dxyz = jnp.maximum(dxyz_raw, EPS) dsmin_arr = dsmin / dxyz x1, y1, z1, ib_new, jb_new, kb_new, ds, still_ok = jax.vmap( lambda x, y, z, ia, ja, ka, dsm, dv: _subcycle_one_particle( x, y, z, ia, ja, ka, intrpg, dsm, dv, grid, fluxes, no_wflux ) )(state.x, state.y, state.z, state.ia, state.ja, state.ka, dsmin_arr, dxyz) dt_particles = ds * dxyz new_tt = state.tt + dt_particles i0_new = jnp.clip(ib_new - 1, 0, grid.imt - 1) j0_new = jnp.clip(jb_new - 1, 0, grid.jmt - 1) on_land = grid.kmt[i0_new, j0_new] == 0 horiz_oob = (jb_new < 1) | (jb_new > grid.jmt) | edge[i0_new, j0_new] vert_oob = (kb_new < 1) | (kb_new > grid.km) new_reason = jnp.where( zero_volume, REASON_DXYZ, jnp.where( state.active & on_land, REASON_LAND, jnp.where( state.active & ~on_land & horiz_oob, REASON_BOUNDS_H, jnp.where(state.active & ~on_land & vert_oob, REASON_BOUNDS_V, state.reason), ), ), ) new_active = state.active & still_ok & ~zero_volume & ~horiz_oob return ParticleState( x=jnp.where(state.active, x1, state.x), y=jnp.where(state.active, y1, state.y), z=jnp.where(state.active, z1, state.z), ia=jnp.where(state.active, ib_new, state.ia), ja=jnp.where(state.active, jb_new, state.ja), ka=jnp.where(state.active, kb_new, state.ka), tt=jnp.where(state.active, new_tt, state.tt), ts=jnp.where(state.active, state.ts + dt_particles / state.subvol, state.ts), t0=state.t0, subvol=state.subvol, niter=jnp.where(state.active, state.niter + 1, state.niter), active=new_active, reason=new_reason, ) return step_all