"""Memory-efficient integration variants for TRACMASS-JAX.
Provides two integration functions that avoid loading the full global flux
grid into device memory at once:
- :func:`time_dependent_patched` — loads only the rectangular bounding box
of the active particles (plus a configurable buffer) at each time window.
The patch shape is fixed at initialisation so the JAX kernel is compiled
once and reused.
- :func:`time_dependent_streaming` — loads only the flux values at each
particle's current cell (a per-particle stencil) and advances particles
with a Python ``for``-loop instead of a JAX ``while_loop``. Suitable for
widely scattered particles or very long runs.
Both functions use :func:`~tracmass.integrate.full_grid._integrate_window`
internally (patched variant) or replace it with an explicit Python loop
(streaming variant).
"""
from __future__ import annotations
import gc
import time
import jax
from typing import Callable
import jax.numpy as jnp
import numpy as np
from tracmass import advection_scheme, gcm
from tracmass.gcm.flux_grid import FluxStencil, extract_stencil
from tracmass.integrate.full_grid import (
_integrate_window,
_resume_paused,
_save_time,
_seed_windows,
_timax,
_snap_to_substep,
_substep_cap,
)
from tracmass.monitoring import RunMonitor
from tracmass.particle import (
_REASON_NAMES,
REASON_TIME,
ParticleRecorder,
ParticleState,
)
from tracmass.runsetup import (
_UNSET,
manage_run_io,
resolve_n_particle_batches,
resolve_n_windows,
resolve_no_wflux,
resolve_rampup,
resolve_seed_freq,
rampup_metadata,
reseed,
seed_metadata,
window_ends,
)
# ---------------------------------------------------------------------------
# Stencil-cache helper
# ---------------------------------------------------------------------------
def _update_stencil(
cached: FluxStencil,
delta: FluxStencil,
moved: jnp.ndarray,
) -> FluxStencil:
"""Return *cached* with *delta* values substituted for *moved* particles.
Parameters
----------
cached : FluxStencil
Stencil values from the previous sub-cycle.
delta : FluxStencil
Freshly extracted stencil. Only positions where *moved* is ``True``
carry valid data; all other positions are zeros (as produced by
:func:`~tracmass.gcm.extract_stencil` with a mask argument).
moved : jnp.ndarray
Boolean mask, shape ``(N,)``. ``True`` for particles that crossed
a cell wall since the last sub-cycle.
Returns
-------
FluxStencil
Merged stencil: *delta* values where *moved*, *cached* elsewhere.
"""
sel2 = moved[:, None] # (N, 1) — broadcast over the 2-slot axis
def _m2(c: jnp.ndarray, d: jnp.ndarray) -> jnp.ndarray:
return jnp.where(sel2, d, c)
def _m1(c: jnp.ndarray, d: jnp.ndarray) -> jnp.ndarray:
return jnp.where(moved, d, c)
w_top = None if cached.w_top is None else _m2(cached.w_top, delta.w_top)
w_bot = None if cached.w_bot is None else _m2(cached.w_bot, delta.w_bot)
return FluxStencil(
u_east = _m2(cached.u_east, delta.u_east),
u_west = _m2(cached.u_west, delta.u_west),
v_north = _m2(cached.v_north, delta.v_north),
v_south = _m2(cached.v_south, delta.v_south),
w_top = w_top,
w_bot = w_bot,
dzt = _m2(cached.dzt, delta.dzt),
zstot = _m2(cached.zstot, delta.zstot),
kmt = _m1(cached.kmt, delta.kmt),
dxdy = _m1(cached.dxdy, delta.dxdy),
)
# ---------------------------------------------------------------------------
# Patch helpers
# ---------------------------------------------------------------------------
def _round_up(x: int, granularity: int) -> int:
"""Round *x* up to the nearest multiple of *granularity*."""
return ((x + granularity - 1) // granularity) * granularity
def _zarr_chunk_xy(fluxes) -> tuple[int, int]:
"""Return ``(chunk_x, chunk_y)`` of the underlying Zarr store, or ``(1, 1)``.
Reads from :attr:`LazyFluxFields.zarr_chunk_xy`, which is set by GCM
adapters (e.g. ``gcm.glorys``) from the raw velocity DataArray *before*
arithmetic operations that would corrupt the chunk layout. Returns
``(1, 1)`` for in-memory adapters (theoretical, fabboni) where
chunk alignment has no effect.
"""
return getattr(fluxes, "zarr_chunk_xy", (1, 1)) or (1, 1)
def _chunk_bbox(
ia_arr: np.ndarray,
ja_arr: np.ndarray,
buffer: int,
zarr_cx: int,
zarr_cy: int,
imt_grid: int,
jmt_grid: int,
) -> tuple[int, int, int, int]:
"""Return a chunk-aligned ``(pi0, pj0, imt_p, jmt_p)`` covering particles.
Snaps the lower-left corner of the bounding box (including *buffer*)
down to the nearest Zarr chunk boundary and rounds the size up to cover
the upper-right corner. The resulting patch always starts **and ends**
on a chunk boundary, so ``compute_patch`` reads exactly
``(imt_p / zarr_cx) × (jmt_p / zarr_cy)`` complete chunks — no partial
chunk reads, no wasted decompression.
``ia_arr`` / ``ja_arr`` are **1-based** TRACMASS cell indices.
Parameters
----------
ia_arr, ja_arr : np.ndarray
1-based cell indices of the active particle population.
buffer : int
Extra cells added on each side before snapping to chunk boundaries.
zarr_cx, zarr_cy : int
Zarr chunk sizes in x and y. Pass ``(1, 1)`` to get the same
result as the old unaligned logic.
imt_grid, jmt_grid : int
Full-grid dimensions used for clamping.
Returns
-------
tuple[int, int, int, int]
``(pi0, pj0, imt_p, jmt_p)`` — all 0-based, sizes exclusive-end.
"""
# 0-based lower and upper bounds including buffer
i_lo = max(0, int(ia_arr.min()) - 1 - buffer)
i_hi = min(imt_grid - 1, int(ia_arr.max()) - 1 + buffer)
j_lo = max(0, int(ja_arr.min()) - 1 - buffer)
j_hi = min(jmt_grid - 1, int(ja_arr.max()) - 1 + buffer)
# Snap to chunk boundaries
pi0 = (i_lo // zarr_cx) * zarr_cx
pj0 = (j_lo // zarr_cy) * zarr_cy
imt_p = min(_round_up(i_hi - pi0 + 1, zarr_cx), imt_grid - pi0)
jmt_p = min(_round_up(j_hi - pj0 + 1, zarr_cy), jmt_grid - pj0)
return pi0, pj0, imt_p, jmt_p
def _shift_state(
state: ParticleState, di: int, dj: int
) -> ParticleState:
"""Shift particle grid indices and fractional positions by ``(di, dj)``.
Used to convert between global coordinates and patch-local coordinates.
``di`` and ``dj`` are added to ``ia``, ``ja``, ``x``, ``y``.
Parameters
----------
state : ParticleState
di, dj : int
Offset to add (use negative values to convert global → local).
"""
return state._replace(
ia=state.ia + di,
ja=state.ja + dj,
x=state.x + di,
y=state.y + dj,
)
# ===========================================================================
# Patch-based multi-window integration
# ===========================================================================
[docs]
@manage_run_io
def time_dependent_patched(
state: ParticleState,
grid: gcm.GridFields,
fluxes: gcm.LazyFluxFields,
fluxes_fn=None,
*,
iter_sub: int = 1,
max_iter: int = 100_000,
n_windows: int | None = None,
max_sub_iter_per_window: int = 10_000,
timax_seconds: float = _UNSET,
traj_max_time: str | None = _UNSET,
recorder: ParticleRecorder | str | None = _UNSET,
monitor: RunMonitor | str | None = _UNSET,
no_wflux: bool = _UNSET,
patch_buffer: int = 50,
patch_granularity: int = 100,
pace_factor: int = 5,
n_particle_batches: int = _UNSET,
rampup_windows: int = _UNSET,
rampup_substeps: int = _UNSET,
seed_freq: str | None = _UNSET,
) -> ParticleState:
"""Multi-window integration using local flux patches around the particles.
Instead of computing the full global grid, this function computes
the bounding box of the active particles at the start of each window,
loads only the Zarr chunks that intersect that region plus *patch_buffer*
cells of margin, and runs the advection kernel on the resulting sub-patch.
The patch **shape** is fixed at initialisation (initial bbox +
``patch_buffer`` on each side) so the JAX kernel is compiled exactly
once and reused for all windows. Between windows the patch **origin**
is recentred on the current particle centroid (zero-cost re-slicing of
the lazy xarray DataArrays) and the patch content is re-computed
from the appropriate Zarr chunks.
If particles drift far enough that the patch would need to grow beyond
its initial size, those particles are allowed to reach the patch boundary
and are treated as leaving the domain (deactivated). Use a generous
*patch_buffer* to prevent this.
The GCM window duration is taken from ``grid.timestep``; it does not
need to be supplied separately.
Parameters
----------
state : ParticleState
Initial particle state.
grid : gcm.GridFields
Full-grid static geometry. ``grid.timestep`` sets the window duration.
fluxes : gcm.LazyFluxFields
Lazy flux data for the first two time slots.
fluxes_fn : callable or None
Called as ``fluxes_fn(dtm)`` to load the next window. If ``None``,
inferred from ``fluxes.fluxes_fn`` (set automatically by
:func:`~tracmass.gcm.new_flux`).
iter_sub : int
Sub-cycles per GCM step.
max_iter : int
Maximum sub-cycles.
n_windows : int or None, optional
Number of GCM time windows to integrate over. Default ``None`` —
derived from the flux buffer as ``fluxes.n_windows``, i.e. enough
windows to reach the *dtm_end* given to
:func:`tracmass.gcm.new_flux`; ``1`` if no *dtm_end* was given.
max_sub_iter_per_window : int
Safety cap on sub-cycles per window. Default 10 000.
timax_seconds : float, optional
Maximum trajectory lifetime in seconds, counted from each particle's
release (``tt - t0``). Pass this or *traj_max_time*, not both.
traj_max_time : str or None, optional
The same lifetime as a ``pd.Timedelta`` string (``"30D"``,
``"12h"``) or in calendar units (``"1M"``, ``"6M"``, ``"1Q"``,
``"1Y"``, measured from the run's start, ``fluxes.dtm``). Particles
past it stop with reason ``time``. When neither is passed, from the
``traj_max_time`` setting (default: no limit). See
:func:`tracmass.runsetup.traj_max_seconds`.
recorder : ParticleRecorder or str or None, optional
Where to stream active-particle snapshots (each window boundary) and
deactivation events, all to one Parquet file (``event_type`` column).
Pass a :class:`~tracmass.ParticleRecorder` to manage the file
yourself, or a path string to have one built and closed for you.
``None`` disables recording. When omitted, a recorder is built from
settings (``output_name`` / ``auto_record`` / ``base_output_dir``);
if neither ``output_name`` nor ``auto_record`` is set, nothing is
written. See :mod:`tracmass.runsetup`.
no_wflux : bool, optional
Ignore vertical fluxes. When not passed, resolved from the
``move_vertically`` setting as ``no_wflux = not move_vertically``
(default ``move_vertically=True``, i.e. ``no_wflux=False``). See
:func:`tracmass.config.move_vertically`.
patch_buffer : int
Extra cells added on every side of the particle bounding box.
patch_granularity : int, optional
Patch dimensions are rounded up to the nearest multiple of this value.
Limits the number of distinct patch shapes and therefore XLA
recompilations as the particle cloud spreads. Default ``100``.
monitor : RunMonitor or str or None, optional
Controls progress output. Pass a :class:`~tracmass.RunMonitor`
instance, or a backend name (``"print"``, ``"log"``, ``"rich"``) to
have one built for you. ``None`` suppresses all output. When
omitted, the backend is taken from the ``runmonitor_backend`` setting
(default ``"print"``). A monitor / recorder built here is entered and
exited automatically, so no ``with`` block is needed.
n_particle_batches : int, optional
Accepted for API compatibility with the full-grid integrators
(:func:`~tracmass.integrate.steady_state` /
:func:`~tracmass.integrate.time_dependent`) — resolved from the
``n_particle_batches`` setting when omitted — but has no effect here.
Default ``1``.
rampup_windows, rampup_substeps : int, optional
Extra ``event_type="rampup"`` saves in the first windows, as in
:func:`~tracmass.integrate.time_dependent`.
seed_freq : str or None, optional
Release a copy of *state* again every *seed_freq* (pandas
frequency, e.g. ``"30D"`` or ``"MS"``), as in
:func:`~tracmass.integrate.time_dependent`. When omitted, from
``seed_freq`` in the ``[env.seed]`` settings table.
Returns
-------
ParticleState with final positions in **global** grid coordinates.
"""
tseas = grid.timestep.total_seconds()
no_wflux = resolve_no_wflux(no_wflux)
n_windows = resolve_n_windows(n_windows, fluxes)
# Resolved for API symmetry; the streaming loop does not batch particles.
n_particle_batches = resolve_n_particle_batches(n_particle_batches)
rampup_windows, rampup_substeps = resolve_rampup(rampup_windows, rampup_substeps)
seed_freq = resolve_seed_freq(seed_freq)
reseed_wins = _seed_windows(seed_freq, grid, fluxes, n_windows)
timax_seconds, timax_meta = _timax(timax_seconds, traj_max_time, grid, fluxes)
initial_state = state
if recorder is not None:
recorder.metadata.update(
rampup_metadata(n_windows, tseas, rampup_windows, rampup_substeps)
)
recorder.metadata.update(seed_metadata(seed_freq, reseed_wins))
recorder.metadata.update(timax_meta)
if fluxes_fn is None:
fluxes_fn = getattr(fluxes, "fluxes_fn", None)
# ------------------------------------------------------------------
# 1. Initial patch shape — grows as the particle cloud spreads
# ------------------------------------------------------------------
# Detect Zarr chunk sizes so patches are chunk-aligned: each compute_patch
# call reads complete chunks with no cross-boundary waste. Falls back to
# (1, 1) for in-memory adapters (theoretical, fabboni) where alignment
# has no effect.
zarr_cx, zarr_cy = _zarr_chunk_xy(fluxes)
active_mask_init = np.asarray(state.active)
ia_init = np.asarray(state.ia)[active_mask_init]
ja_init = np.asarray(state.ja)[active_mask_init]
pi0, pj0, imt_p, jmt_p = _chunk_bbox(
ia_init, ja_init, patch_buffer, zarr_cx, zarr_cy, grid.imt, grid.jmt
)
# Compile the local kernel for the initial patch shape.
# piecewise_local is LRU-cached, so later calls with the same
# (imt_p, jmt_p) return the already-compiled kernel without retracing.
_step_local = advection_scheme.piecewise_local(
imt_p, jmt_p, grid.km, grid.iperio, no_wflux=no_wflux
)
if monitor is not None:
monitor.on_run_start(
n_windows=n_windows,
n_particles=int(np.asarray(state.active).shape[0]),
)
_last_win = -1
for win in range(n_windows):
_t_win_start = time.perf_counter()
if win in reseed_wins:
state = reseed(state, initial_state, win, tseas)
if not np.any(np.asarray(state.active)):
if not any(w > win for w in reseed_wins):
break
# Nothing to advect until the next release: just step the fluxes.
if fluxes_fn is not None and win < n_windows - 1:
fluxes = gcm.update_flux(grid, fluxes, fluxes_fn)
_last_win = win
continue
ts_window_end = float(win + 1)
active_before = np.asarray(state.active).copy()
n_active_before = int(active_before.sum())
if recorder is not None:
recorder.record(state, dtm=fluxes.dtm, window=win)
# ------------------------------------------------------------------
# 2. Resize (if needed) and re-centre patch on particle bounding box
# ------------------------------------------------------------------
active_mask = np.asarray(state.active)
ia_arr = np.asarray(state.ia)[active_mask]
ja_arr = np.asarray(state.ja)[active_mask]
# Recompute chunk-aligned origin and minimum size for this window.
# Never shrink imt_p/jmt_p: avoids oscillating recompilations.
pi0_new, pj0_new, imt_p_new, jmt_p_new = _chunk_bbox(
ia_arr, ja_arr, patch_buffer, zarr_cx, zarr_cy, grid.imt, grid.jmt
)
new_imt_p = min(max(imt_p, imt_p_new), grid.imt)
new_jmt_p = min(max(jmt_p, jmt_p_new), grid.jmt)
if new_imt_p != imt_p or new_jmt_p != jmt_p:
imt_p, jmt_p = new_imt_p, new_jmt_p
_step_local = advection_scheme.piecewise_local(
imt_p, jmt_p, grid.km, grid.iperio, no_wflux=no_wflux
)
# Use the chunk-aligned origin; clamp so the (possibly larger)
# patch doesn't exceed the grid boundary.
pi0 = int(np.clip(pi0_new, 0, grid.imt - imt_p))
pj0 = int(np.clip(pj0_new, 0, grid.jmt - jmt_p))
if monitor is not None:
monitor.on_window_start(win, n_active_before, fluxes.dtm)
# ------------------------------------------------------------------
# 3. Compute only the patch (Dask → JAX)
# ------------------------------------------------------------------
_t0 = time.perf_counter()
local_flux = fluxes.compute_patch(
pi0, pi0 + imt_p, pj0, pj0 + jmt_p, grid,
include_wflux=not no_wflux,
)
_t_io = time.perf_counter() - _t0
# ------------------------------------------------------------------
# 4. Convert particle coords to patch-local and run the window
# ------------------------------------------------------------------
# One piece per save time (several during ramp-up); positions are
# saved and paused particles resumed in global coords between them.
_t0 = time.perf_counter()
ends = window_ends(win, rampup_windows, rampup_substeps)
n_sub = 0
ts_start = float(win)
for i_end, ts_end in enumerate(ends):
state_local, n_piece = _integrate_window(
_shift_state(state, -pi0, -pj0),
_step_local,
local_flux,
tseas=tseas,
iter_sub=iter_sub,
max_sub_iter=max_sub_iter_per_window,
timax_seconds=timax_seconds,
ts_window_end=ts_end,
pace_factor=pace_factor,
ts_window_start=ts_start,
)
n_sub += n_piece
# Convert back to global coords.
state = _shift_state(state_local, pi0, pj0)
if i_end < len(ends) - 1:
state = _resume_paused(state, grid, ts_end, timax_seconds)
if recorder is not None:
recorder.record_rampup(
state, dtm=_save_time(fluxes.dtm, grid, (i_end + 1) / len(ends)),
window=win,
)
ts_start = ts_end
_t_jax = time.perf_counter() - _t0
del local_flux
gc.collect()
reasons_arr = np.asarray(state.reason)
newly_stopped = active_before & (reasons_arr != 0)
n_stopped = int(newly_stopped.sum())
reason_tally: dict[str, int] = {}
if n_stopped > 0:
for code in sorted(_REASON_NAMES):
if code == 0:
continue
cnt = int(np.sum(newly_stopped & (reasons_arr == code)))
if cnt:
reason_tally[_REASON_NAMES[code]] = cnt
if recorder is not None and newly_stopped.any():
recorder.record_deactivations(
state, newly_stopped, dtm=fluxes.dtm, window=win
)
# Reactivate particles paused at the window boundary.
# Require ts >= ts_window_end to exclude particles deactivated
# mid-window (stuck, on-land, or out-of-bounds).
tt_np = np.asarray(state.tt)
t0_np = np.asarray(state.t0)
ia_p = np.clip(np.asarray(state.ia) - 1, 0, grid.imt - 1)
ja_p = np.clip(np.asarray(state.ja) - 1, 0, grid.jmt - 1)
still_valid = (
(np.asarray(state.reason) == 0)
& (np.asarray(grid.kmt)[ia_p, ja_p] > 0)
& (np.asarray(state.ja) >= 1)
& (np.asarray(state.ja) <= grid.jmt)
& (np.asarray(state.ka) >= 1)
& (np.asarray(state.ka) <= grid.km)
)
state = state._replace(
active=jnp.asarray(
np.asarray(state.active)
| (
~np.asarray(state.active)
& ((tt_np - t0_np) <= timax_seconds)
& (np.asarray(state.ts) >= ts_window_end)
& still_valid
)
)
)
# Advance lazy flux buffer to next window (zero-cost reference swap).
if fluxes_fn is not None and win < n_windows - 1:
fluxes = gcm.update_flux(grid, fluxes, fluxes_fn)
_t_win = time.perf_counter() - _t_win_start
_t_py = max(0.0, _t_win - _t_io - _t_jax)
n_active_after = int(np.sum(np.asarray(state.active)))
if monitor is not None:
monitor.on_window_end(
win=win,
dtm=fluxes.dtm,
n_active_before=n_active_before,
n_active_after=n_active_after,
n_stopped=n_stopped,
reason_tally=reason_tally,
n_sub=n_sub,
limit_hit=n_sub >= max_sub_iter_per_window,
t_io=_t_io,
t_jax=_t_jax,
t_py=_t_py,
)
_last_win = win
if monitor is not None:
monitor.on_run_end()
if recorder is not None:
recorder.record(state, dtm=fluxes.dtm, window=_last_win + 1)
return state
# ===========================================================================
# Streaming (per-particle stencil) multi-window integration
# ===========================================================================
[docs]
@manage_run_io
def time_dependent_streaming(
state: ParticleState,
grid: gcm.GridFields,
fluxes: gcm.LazyFluxFields,
fluxes_fn: Callable | None = None,
*,
iter_sub: int = 1,
max_iter: int = 100_000,
n_windows: int | None = None,
max_sub_iter_per_window: int = 10_000,
timax_seconds: float = _UNSET,
traj_max_time: str | None = _UNSET,
recorder: ParticleRecorder | str | None = _UNSET,
monitor: RunMonitor | str | None = _UNSET,
no_wflux: bool = _UNSET,
pace_factor: int = 5,
n_particle_batches: int = _UNSET,
rampup_windows: int = _UNSET,
rampup_substeps: int = _UNSET,
seed_freq: str | None = _UNSET,
) -> ParticleState:
"""Multi-window integration using per-particle flux stencil extraction.
At each sub-cycle, only the flux values needed for each particle's current
cell are loaded via vectorised xarray point indexing — no rectangular patch
is pre-allocated. This makes the approach viable for **widely scattered
particles** and **very long runs** where a bounding-box patch would cover
most of the global grid.
The JAX ``while_loop`` used by :func:`~tracmass.integrate.full_grid.time_dependent`
and :func:`time_dependent_patched` is replaced by a Python ``for``-loop, with
one Dask compute and one JAX JIT call per sub-cycle. The total number of
Python iterations is ``n_windows × (subcycles per window)``, typically
on the order of ``n_windows × 10–20`` for monthly GLORYS windows.
The GCM window duration is taken from ``grid.timestep``; it does not
need to be supplied separately.
Parameters
----------
state : ParticleState
Initial particle state.
grid : gcm.GridFields
Static grid geometry. ``grid.timestep`` sets the window duration.
fluxes : gcm.LazyFluxFields
Lazy flux data for the first two time slots.
fluxes_fn : callable or None
Called as ``fluxes_fn(dtm)`` to load the next window. If ``None``,
inferred from ``fluxes.fluxes_fn`` (set automatically by
:func:`~tracmass.gcm.new_flux`).
iter_sub : int
Sub-cycles per GCM step; sets minimum sub-cycle duration
``grid.timestep / iter_sub``.
max_iter : int
Maximum number of sub-cycles over the entire run.
n_windows : int or None, optional
Number of GCM time windows to integrate over. Default ``None`` —
derived from the flux buffer as ``fluxes.n_windows``, i.e. enough
windows to reach the *dtm_end* given to
:func:`tracmass.gcm.new_flux`; ``1`` if no *dtm_end* was given.
max_sub_iter_per_window : int
Safety cap on sub-cycles within each window. Default 10 000.
timax_seconds : float, optional
Maximum trajectory lifetime in seconds, counted from each particle's
release (``tt - t0``). Pass this or *traj_max_time*, not both.
traj_max_time : str or None, optional
The same lifetime as a ``pd.Timedelta`` string (``"30D"``,
``"12h"``) or in calendar units (``"1M"``, ``"6M"``, ``"1Q"``,
``"1Y"``, measured from the run's start, ``fluxes.dtm``). Particles
past it stop with reason ``time``. When neither is passed, from the
``traj_max_time`` setting (default: no limit). See
:func:`tracmass.runsetup.traj_max_seconds`.
recorder : ParticleRecorder or str or None, optional
Where to stream active-particle snapshots (each window boundary) and
deactivation events, all to one Parquet file (``event_type`` column).
Pass a :class:`~tracmass.ParticleRecorder` to manage the file
yourself, or a path string to have one built and closed for you.
``None`` disables recording. When omitted, a recorder is built from
settings (``output_name`` / ``auto_record`` / ``base_output_dir``);
if neither ``output_name`` nor ``auto_record`` is set, nothing is
written. See :mod:`tracmass.runsetup`.
monitor : RunMonitor or str or None, optional
Controls progress output. Pass a :class:`~tracmass.RunMonitor`
instance, or a backend name (``"print"``, ``"log"``, ``"rich"``) to
have one built for you. ``None`` suppresses all output. When
omitted, the backend is taken from the ``runmonitor_backend`` setting
(default ``"print"``). A monitor / recorder built here is entered and
exited automatically, so no ``with`` block is needed.
no_wflux : bool, optional
When ``True``, vertical (w) fluxes are ignored and particles are
confined to their initial depth layer. When not passed, resolved
from the ``move_vertically`` setting as
``no_wflux = not move_vertically`` (default ``move_vertically=True``,
i.e. ``no_wflux=False``). See :func:`tracmass.config.move_vertically`.
n_particle_batches : int, optional
Accepted for API compatibility with the full-grid integrators
(:func:`~tracmass.integrate.steady_state` /
:func:`~tracmass.integrate.time_dependent`) — resolved from the
``n_particle_batches`` setting when omitted — but has no effect here.
The streaming variant already processes all particles in a single
Python loop without a JAX ``while_loop``, so there is no per-call GPU
scratch allocation to reduce. Default ``1``.
rampup_windows, rampup_substeps : int, optional
Not supported here: a ramp-up (from the arguments or the
``rampup_windows`` setting) raises ``NotImplementedError``; use
:func:`~tracmass.integrate.time_dependent` or
:func:`time_dependent_patched`.
seed_freq : str or None, optional
Release a copy of *state* again every *seed_freq* (pandas
frequency, e.g. ``"30D"`` or ``"MS"``), as in
:func:`~tracmass.integrate.time_dependent`. When omitted, from
``seed_freq`` in the ``[env.seed]`` settings table.
Returns
-------
ParticleState
Final particle state in global grid coordinates.
"""
tseas = grid.timestep.total_seconds()
no_wflux = resolve_no_wflux(no_wflux)
n_windows = resolve_n_windows(n_windows, fluxes)
# Resolved for API symmetry; the streaming loop does not batch particles.
n_particle_batches = resolve_n_particle_batches(n_particle_batches)
seed_freq = resolve_seed_freq(seed_freq)
reseed_wins = _seed_windows(seed_freq, grid, fluxes, n_windows)
timax_seconds, timax_meta = _timax(timax_seconds, traj_max_time, grid, fluxes)
initial_state = state
if recorder is not None:
recorder.metadata.update(seed_metadata(seed_freq, reseed_wins))
recorder.metadata.update(timax_meta)
if fluxes_fn is None:
fluxes_fn = getattr(fluxes, "fluxes_fn", None)
if resolve_rampup(rampup_windows, rampup_substeps)[0]:
raise NotImplementedError(
"time_dependent_streaming does not support ramp-up saves "
"(rampup_windows); use time_dependent or time_dependent_patched, "
"or set rampup_windows = 0"
)
has_w = (
fluxes._past.get("wflux") is not None
and fluxes._present.get("wflux") is not None
)
no_wflux = no_wflux or not has_w
# Compile kernel once — closes over grid (kmt for land checks)
_step = advection_scheme.piecewise_stencil(grid, no_wflux=no_wflux)
if monitor is not None:
monitor.on_run_start(
n_windows=n_windows,
n_particles=int(np.asarray(state.active).shape[0]),
)
total_iters = 0
_last_win = -1
for win in range(n_windows):
_t_win_start = time.perf_counter()
ts_window_start = float(win)
ts_window_end = float(win + 1)
if win in reseed_wins:
state = reseed(state, initial_state, win, tseas)
# t0 is the per-particle trajectory start time; it never changes during
# the inner sub-cycle loop, so we fetch it once per window.
t0_np = np.asarray(state.t0)
if not np.any(np.asarray(state.active)):
if not any(w > win for w in reseed_wins):
break
# Nothing to advect until the next release: just step the fluxes.
if fluxes_fn is not None and win < n_windows - 1:
fluxes = gcm.update_flux(grid, fluxes, fluxes_fn)
_last_win = win
continue
active_before = np.asarray(state.active).copy()
n_active_before = int(active_before.sum())
if recorder is not None:
recorder.record(state, dtm=fluxes.dtm, window=win)
if monitor is not None:
monitor.on_window_start(win, n_active_before, fluxes.dtm)
_t_io_win = 0.0
_t_jax_win = 0.0
win_sub = 0
# Preload all Dask arrays for this window into numpy in one batch
# compute, so that extract_stencil calls inside the sub-cycle loop
# use pure numpy indexing with no further Zarr I/O.
_t0 = time.perf_counter()
_fluxes = fluxes.preload(include_wflux=not no_wflux)
_t_io_win += time.perf_counter() - _t0
# Progressive deactivation denominator: same pace_factor=5 as the
# while_loop variant. Particles that would need more than
# max_sub_iter_per_window / 5 steps are detected as stuck and killed.
_N_WARMUP = 2
_pace_denom = max((max_sub_iter_per_window - _N_WARMUP) // pace_factor, 1)
# Stencil cache: reset each window because the flux data changed.
# Within a window, the stencil is only reloaded for particles that
# cross a cell wall — particles staying in the same cell reuse the
# previously extracted values, saving Dask I/O.
stencil_cache: FluxStencil | None = None
prev_ia = prev_ja = prev_ka = None
for it in range(max_sub_iter_per_window):
if total_iters >= max_iter:
break
# One batched device→host transfer per sub-cycle iteration.
# ia/ja/ka are fetched here to detect cell crossings for caching.
active_np, tt_np, ia_np, ja_np, ka_np = jax.device_get(
(state.active, state.tt, state.ia, state.ja, state.ka)
)
if not np.any(active_np):
break
ts_np = tt_np / tseas
# Deactivate particles that exceeded lifetime or passed window end
# t0_np was fetched once before the inner loop (t0 is constant)
exceeded = (tt_np - t0_np) > timax_seconds
past_window = ts_np >= ts_window_end
new_active = active_np & ~exceeded & ~past_window
if not np.array_equal(active_np, new_active):
state = state._replace(
active=jnp.asarray(new_active),
reason=jnp.where(
jnp.asarray(active_np & exceeded), REASON_TIME, state.reason
),
)
active_np = new_active
# Progressive deactivation: by step n a particle must have covered
# at least (n - _N_WARMUP) / _pace_denom of the window. Kills
# convergence-zone oscillators before they inflate the loop count.
if it >= _N_WARMUP:
pace_threshold = (ts_window_start
+ (it - _N_WARMUP) / _pace_denom)
too_slow = active_np & (ts_np < pace_threshold)
if np.any(too_slow):
new_active = active_np & ~too_slow
state = state._replace(active=jnp.asarray(new_active))
active_np = new_active
if not np.any(active_np):
break
# Check if all active particles have exited this window
if np.all(past_window[active_np]):
break
# Time-interpolation weight from mean active-particle ts
intrpg = float(np.mean(ts_np[active_np]) % 1.0)
# Stencil cache: extract only for active particles that are in a
# new cell since the previous sub-cycle. On the first sub-cycle
# of each window (stencil_cache is None) extract for all active
# particles; thereafter only re-read for those that crossed a wall.
if stencil_cache is None:
_t0 = time.perf_counter()
stencil_cache = extract_stencil(
_fluxes, grid, state, no_wflux=no_wflux
)
_t_io_win += time.perf_counter() - _t0
else:
moved = active_np & (
(ia_np != prev_ia)
| (ja_np != prev_ja)
| (ka_np != prev_ka)
)
if np.any(moved):
_t0 = time.perf_counter()
delta = extract_stencil(
_fluxes, grid, state, no_wflux=no_wflux, mask=moved
)
_t_io_win += time.perf_counter() - _t0
stencil_cache = _update_stencil(
stencil_cache, delta, jnp.asarray(moved)
)
# else: no cell crossings — reuse stencil_cache unchanged
prev_ia = ia_np.copy()
prev_ja = ja_np.copy()
prev_ka = ka_np.copy()
# One sub-cycle for all particles
_t0 = time.perf_counter()
tt_before = state.tt
ts_next, cap_s = _substep_cap(state.ts, tseas, iter_sub)
state = _step(stencil_cache, state, intrpg, cap_s)
_t_jax_win += time.perf_counter() - _t0
# Keep ts in sync with tt — pure JAX, no device round-trip
state = _snap_to_substep(
state, tt_before, ts_next, cap_s, tseas, iter_sub
)
total_iters += 1
win_sub += 1
reasons_arr = np.asarray(state.reason)
newly_stopped = active_before & (reasons_arr != 0)
n_stopped = int(newly_stopped.sum())
reason_tally: dict[str, int] = {}
if n_stopped > 0:
for code in sorted(_REASON_NAMES):
if code == 0:
continue
cnt = int(np.sum(newly_stopped & (reasons_arr == code)))
if cnt:
reason_tally[_REASON_NAMES[code]] = cnt
if recorder is not None and newly_stopped.any():
recorder.record_deactivations(
state, newly_stopped, dtm=fluxes.dtm, window=win
)
# Advance lazy flux buffer to next window (zero-cost reference swap)
if fluxes_fn is not None and win < n_windows - 1:
fluxes = gcm.update_flux(grid, fluxes, fluxes_fn)
# Reactivate particles paused at the window boundary.
# Require ts >= ts_window_end to exclude particles deactivated
# mid-window (stuck, on-land, or out-of-bounds).
tt_np = np.asarray(state.tt)
i0_p = np.clip(np.asarray(state.ia) - 1, 0, grid.imt - 1)
j0_p = np.clip(np.asarray(state.ja) - 1, 0, grid.jmt - 1)
still_valid = (
(np.asarray(state.reason) == 0)
& (np.asarray(grid.kmt)[i0_p, j0_p] > 0)
& (np.asarray(state.ja) >= 1)
& (np.asarray(state.ja) <= grid.jmt)
& (np.asarray(state.ka) >= 1)
& (np.asarray(state.ka) <= grid.km)
)
state = state._replace(
active=jnp.asarray(
np.asarray(state.active)
| (
~np.asarray(state.active)
& ((tt_np - t0_np) <= timax_seconds)
& (np.asarray(state.ts) >= ts_window_end)
& still_valid
)
)
)
_t_win = time.perf_counter() - _t_win_start
_t_py = max(0.0, _t_win - _t_io_win - _t_jax_win)
n_active_after = int(np.sum(np.asarray(state.active)))
if monitor is not None:
monitor.on_window_end(
win=win,
dtm=fluxes.dtm,
n_active_before=n_active_before,
n_active_after=n_active_after,
n_stopped=n_stopped,
reason_tally=reason_tally,
n_sub=win_sub,
limit_hit=win_sub >= max_sub_iter_per_window,
t_io=_t_io_win,
t_jax=_t_jax_win,
t_py=_t_py,
)
_last_win = win
if monitor is not None:
monitor.on_run_end()
if recorder is not None:
recorder.record(state, dtm=fluxes.dtm, window=_last_win + 1)
return state