Source code for tracmass.particle

from __future__ import annotations

from typing import NamedTuple

import jax.numpy as jnp
import numpy as np

# ---------------------------------------------------------------------------
# Deactivation reason codes  (stored in ParticleState.reason, dtype int16)
# ---------------------------------------------------------------------------
REASON_ACTIVE    = jnp.int16(0)  # still running / window-paused
REASON_TIME      = jnp.int16(1)  # time limit exceeded (tt − t0 > timax)
REASON_LAND      = jnp.int16(2)  # hit land cell (kmt == 0)
REASON_BOUNDS_H  = jnp.int16(3)  # left model domain horizontally (j out of range)
REASON_PROGRESS  = jnp.int16(4)  # zero float32 progress (ds ≈ 0 / UNDEF)
REASON_SLOW      = jnp.int16(5)  # progressive deactivation (too slow)
REASON_ITERLIMIT = jnp.int16(6)  # sub-cycle iteration limit reached
REASON_DXYZ      = jnp.int16(7)  # zero/negative cell volume (dxyz ≤ 0)
REASON_BOUNDS_V  = jnp.int16(8)  # left model domain vertically (k out of range)

_REASON_NAMES: dict[int, str] = {
    0: "active",
    1: "time_limit",
    2: "land",
    3: "out_of_bounds_h",
    4: "no_progress",
    5: "too_slow",
    6: "iter_limit",
    7: "zero_volume",
    8: "out_of_bounds_v",
}


# ---------------------------------------------------------------------------
# Particle state  (Structure-of-Arrays via a NamedTuple)
# ---------------------------------------------------------------------------
[docs] class ParticleState(NamedTuple): """State vector for all particles — each field is shape (n,).""" x: jnp.ndarray # zonal position (1-based dimensionless) y: jnp.ndarray # meridional position z: jnp.ndarray # vertical position ia: jnp.ndarray # zonal box index (1-based int) ja: jnp.ndarray # meridional box index ka: jnp.ndarray # vertical box index tt: jnp.ndarray # elapsed time [s] ts: jnp.ndarray # normalised time (tt / tseas) t0: jnp.ndarray # seeding time [s] subvol: jnp.ndarray # transported volume niter: jnp.ndarray # iteration counter active: jnp.ndarray # bool mask — still advecting? reason: jnp.ndarray # int16 — why this particle stopped (0 = still active)
[docs] @classmethod def concat(cls, *states: ParticleState) -> ParticleState: """Join particle states end to end. Particles keep their order, so the particles of the first state keep their indices (the ``id`` written by :class:`ParticleRecorder`) and those of later states get new ones after them. Parameters ---------- *states : ParticleState States to join, in order. Returns ------- ParticleState NumPy arrays if every input field is a NumPy array (e.g. the host-side state of a batched run), JAX arrays otherwise. Examples -------- >>> both = ParticleState.concat(state, new_particles) >>> len(both.x) == len(state.x) + len(new_particles.x) True """ if not states: raise ValueError("ParticleState.concat needs at least one state") xp = np if all(isinstance(a, np.ndarray) for s in states for a in s) else jnp return cls(*(xp.concatenate(fields) for fields in zip(*states)))
def __repr__(self): lines = ["ParticleState("] lines.append(f"Total particles: {len(self.x)}") n_active = int(np.sum(self.active)) lines.append(f"Active particles: {n_active}") n_inactive = len(self.x) - n_active if n_inactive > 0: reason_arr = np.asarray(self.reason) inactive_mask = ~np.asarray(self.active) parts = [] for code, name in _REASON_NAMES.items(): if code == 0: continue cnt = int(np.sum(inactive_mask & (reason_arr == code))) if cnt: parts.append(f"{name}:{cnt}") if parts: lines.append(f"Stopped ({n_inactive}): " + ", ".join(parts)) for name, value in zip(self._fields, self): if hasattr(value, "shape"): lines.append(f" {name}: shape={value.shape}, dtype={value.dtype},") else: lines.append(f" {name}: {value},") lines.append(")") return "\n".join(lines)
[docs] def write_trajectories_parquet( filepath: str, state: ParticleState, nff: int = 1, ) -> str: """Write a single particle state snapshot to a Parquet file. Parameters ---------- filepath : str Output ``.parquet`` file path. state : ParticleState Final (or snapshot) particle state. nff : int Time direction (1=forward, -1=backward). ``tt`` and ``ts`` are stored as ``nff * value`` so backward trajectories have negative times, matching the Fortran TRACMASS convention. Returns ------- str The path that was written. """ import pandas as pd n = len(state.x) df = pd.DataFrame({ "ntrac": np.arange(n, dtype=np.int32), "x": np.asarray(state.x), "y": np.asarray(state.y), "z": np.asarray(state.z), "ia": np.asarray(state.ia), "ja": np.asarray(state.ja), "ka": np.asarray(state.ka), "tt": np.asarray(state.tt) * nff, "ts": np.asarray(state.ts) * nff, "t0": np.asarray(state.t0), "subvol": np.asarray(state.subvol), "niter": np.asarray(state.niter), "active": np.asarray(state.active), "reason": np.asarray(state.reason), }) df.to_parquet(filepath, index=False) return filepath
[docs] class ParticleRecorder: """Streams trajectory snapshots and deactivation events to a Parquet file. Each call to :meth:`record` appends active-particle positions as a new row group with ``event_type="snapshot"`` (at a window boundary); each call to :meth:`record_rampup` the same with ``event_type="rampup"`` (extra saves inside the first windows of a run). Each call to :meth:`record_deactivations` appends newly-stopped particles with ``event_type="deactivated"``. Call :meth:`close` (or use the recorder as a context manager) to finalise the file. The unified schema contains 13 columns: ``event_type``, ``window``, ``dtm``, ``id``, ``x``, ``y``, ``z``, ``ia``, ``ja``, ``ka``, ``tt``, ``t0``, ``reason``. Filter on ``event_type`` when analysing a single event class. ``x`` / ``ia`` and ``y`` / ``ja`` are written in full-grid (absolute) indices: :attr:`grid_offset` — set from ``grid.i_offset`` / ``grid.j_offset`` by the integrators — is added to the in-memory, subset-relative positions. A non-zero offset is also stored in the run metadata as ``i_offset`` / ``j_offset``. Run metadata (GCM adapter name, integration strategy, etc.) is stored as a JSON object under the Parquet schema metadata key ``"tracmass.run"``. Read it back without loading row data:: import json, pyarrow.parquet as pq meta = json.loads(pq.read_schema("traj.parquet").metadata[b"tracmass.run"]) Parameters ---------- filepath : str Output ``.parquet`` file path. The file is created on the first write. metadata : dict, optional Arbitrary JSON-serialisable key/value pairs stored in the Parquet schema metadata under the key ``"tracmass.run"``. Typical fields: * ``"gcm"`` — GCM adapter name, e.g. ``"glorys"`` or ``"theoretical"`` * ``"integration"`` — function used, e.g. ``"time_dependent"`` * ``"n_windows"`` — number of GCM windows * ``"no_wflux"`` — whether vertical fluxes were disabled Attributes ---------- grid_offset : tuple[int, int] ``(i_offset, j_offset)`` added to ``x``/``ia`` and ``y``/``ja`` on write. Default ``(0, 0)``. metadata : dict Run metadata written with the file (a copy of *metadata*); may be extended until the first write. Examples -------- >>> meta = dict(gcm="glorys", integration="time_dependent", ... n_windows=50, no_wflux=True) >>> with ParticleRecorder("traj.parquet", metadata=meta) as rec: ... result = tracmass.time_dependent( ... state, grid, lazy, fluxes_fn, n_windows=50, recorder=rec, ... no_wflux=True, ... ) """ _SCHEMA = None # class-level schema cache, populated lazily on first use
[docs] def __init__(self, filepath: str, metadata: dict | None = None): self._filepath = filepath self._writer = None self.metadata: dict = dict(metadata or {}) self.grid_offset: tuple[int, int] = (0, 0)
def __enter__(self): return self def __exit__(self, *_): self.close() @classmethod def _schema(cls): import pyarrow as pa if cls._SCHEMA is None: cls._SCHEMA = pa.schema([ pa.field("event_type", pa.string()), pa.field("window", pa.int32()), pa.field("dtm", pa.timestamp("us")), pa.field("id", pa.int32()), pa.field("x", pa.float64()), pa.field("y", pa.float64()), pa.field("z", pa.float64()), pa.field("ia", pa.int32()), pa.field("ja", pa.int32()), pa.field("ka", pa.int32()), pa.field("tt", pa.float64()), pa.field("t0", pa.float64()), pa.field("reason", pa.int16()), ]) return cls._SCHEMA def _write( self, event_type: str, idx: np.ndarray, state: ParticleState, dtm, window: int, ) -> None: import pandas as pd import pyarrow as pa import pyarrow.parquet as pq n = len(idx) if n == 0: return ts = pd.Timestamp(dtm) if dtm is not None else pd.NaT schema = self._schema() di, dj = self.grid_offset table = pa.table( { "event_type": [event_type] * n, "window": np.full(n, window, dtype=np.int32), "dtm": [ts] * n, "id": idx.astype(np.int32), "x": np.asarray(state.x)[idx].astype(np.float64) + di, "y": np.asarray(state.y)[idx].astype(np.float64) + dj, "z": np.asarray(state.z)[idx].astype(np.float64), "ia": np.asarray(state.ia)[idx].astype(np.int32) + di, "ja": np.asarray(state.ja)[idx].astype(np.int32) + dj, "ka": np.asarray(state.ka)[idx].astype(np.int32), "tt": np.asarray(state.tt)[idx].astype(np.float64), "t0": np.asarray(state.t0)[idx].astype(np.float64), "reason": np.asarray(state.reason)[idx].astype(np.int16), }, schema=schema, ) if self._writer is None: metadata = dict(self.metadata) if (di, dj) != (0, 0): metadata.update(i_offset=int(di), j_offset=int(dj)) if metadata: import json schema = schema.with_metadata( {"tracmass.run": json.dumps(metadata)} ) self._writer = pq.ParquetWriter(self._filepath, schema) self._writer.write_table(table)
[docs] def record( self, state: ParticleState, dtm=None, *, window: int = 0, ) -> None: """Append active-particle positions as a new row group. Parameters ---------- state : ParticleState Current particle state; only active particles are written. dtm : pd.Timestamp or datetime-like, optional Timestamp stored in the ``dtm`` column. ``None`` → ``NaT``. window : int, optional Window index (0-based). Default ``0``. """ mask = np.asarray(state.active) idx = np.where(mask)[0] self._write("snapshot", idx, state, dtm, window)
[docs] def record_rampup( self, state: ParticleState, dtm=None, *, window: int = 0, ) -> None: """Append active-particle positions saved inside a window. Like :meth:`record`, but with ``event_type="rampup"``: the extra saves of the ramp-up period (see ``rampup_windows`` in :func:`tracmass.time_dependent`). The run metadata's ``save_dt_seconds`` gives the time between consecutive saves. Parameters ---------- state : ParticleState Current particle state; only active particles are written. dtm : pd.Timestamp or datetime-like, optional Time of the save (inside the window). window : int, optional Index of the window the save falls in. """ idx = np.where(np.asarray(state.active))[0] self._write("rampup", idx, state, dtm, window)
[docs] def record_deactivations( self, state: ParticleState, mask: np.ndarray, dtm=None, *, window: int = 0, ) -> None: """Append deactivated particles identified by *mask*. Parameters ---------- state : ParticleState Particle state after the integration window. mask : array-like of bool, shape (N,) Boolean mask selecting the newly-deactivated particles. Typically ``active_before & (reason != 0)``. dtm : pd.Timestamp or datetime-like, optional Timestamp stored in the ``dtm`` column. ``None`` → ``NaT``. window : int, optional Window index (0-based). Default ``0``. """ idx = np.where(mask)[0] self._write("deactivated", idx, state, dtm, window)
[docs] def close(self) -> None: """Finalise and close the Parquet file. Safe to call even if no events were recorded. """ if self._writer is not None: self._writer.close() self._writer = None