Source code for tracmass.monitoring

"""Run monitoring for TRACMASS-JAX integration loops.

Provides :class:`RunMonitor`, a context-manager that receives structured
events from :func:`~tracmass.integrate.time_dependent` and renders them via
one of three backends:

- ``"print"`` — simple ``print()`` output, reproducing the original behaviour.
- ``"log"``   — structured :mod:`logging` records (one per window + summary).
- ``"rich"``  — live terminal dashboard powered by :mod:`rich`.
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import pandas as pd


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------


[docs] def sec_str(s: float) -> str: """Format a duration in seconds as a compact human-readable string. Parameters ---------- s : float Duration in seconds. Returns ------- str E.g. ``"42ms"``, ``"3.7s"``, ``"2m05s"``. """ if s < 1.0: return f"{s * 1000:.0f}ms" if s < 60.0: return f"{s:.1f}s" m = int(s // 60) return f"{m}m{s - m * 60:.0f}s"
[docs] def dt_str(dt) -> str: """Format a :class:`pandas.Timedelta` as a compact string. Parameters ---------- dt : pandas.Timedelta Returns ------- str """ total = dt.total_seconds() dy = int(total / 86400) hr = int(total / 3600 - dy * 24) mn = int(total / 60 - 60 * (dy * 24 + hr)) sk = int(total - 60 * (dy * 24 * 60 + hr * 60 + mn)) out = "" out += "" if dy == 0 else f"{dy:02}d " out += "" if hr == 0 else f"{hr:02}:" out += "" if mn == 0 else f"{mn:02}:" out += f"{sk}" return out
def _reason_str(reason_tally: dict[str, int]) -> str: """Format a reason-code tally as a compact string.""" return ", ".join(f"{k}:{v}" for k, v in reason_tally.items() if v) # --------------------------------------------------------------------------- # RunMonitor # ---------------------------------------------------------------------------
[docs] class RunMonitor: """Monitor for :func:`~tracmass.integrate.time_dependent` runs. Receives structured events from the integration loop and renders them via the chosen backend. Use as a context manager so the ``"rich"`` backend can properly start and stop its live display. Parameters ---------- backend : {"print", "log", "rich"} Output backend. ``"print"`` reproduces the original stdout output. ``"log"`` emits :mod:`logging` records. ``"rich"`` shows a live terminal dashboard. logger : logging.Logger, optional Logger used by the ``"log"`` backend. Defaults to the ``"tracmass"`` logger. max_table_rows : int, optional Maximum number of window rows shown in the ``"rich"`` table at any one time. Older rows scroll out of view. Default ``10``. Examples -------- >>> with tracmass.RunMonitor(backend="rich") as mon: ... result = tracmass.time_dependent(state, grid, fluxes, ... n_windows=50, monitor=mon) >>> # Plain print (default when monitor is not supplied): >>> result = tracmass.time_dependent(state, grid, fluxes, n_windows=50) """
[docs] def __init__( self, backend: str = "print", logger: logging.Logger | None = None, max_table_rows: int = 10, ) -> None: if backend not in ("print", "log", "rich"): raise ValueError( f"backend must be 'print', 'log', or 'rich'; got {backend!r}" ) self._backend = backend self._logger = logger or logging.getLogger("tracmass") self._max_table_rows = max_table_rows # Timing accumulators self._t_io: float = 0.0 self._t_jax: float = 0.0 self._t_py: float = 0.0 self._wins_done: int = 0 # Cumulative stopped-particle accumulators self._cumul_stopped: int = 0 self._cumul_reason_tally: dict[str, int] = {} # Set by on_run_start self._n_windows: int | None = None self._n_particles: int | None = None # Rich state self._live = None self._progress = None self._task_id = None self._rows: list[tuple] = [] # all window rows (unbounded) self._summary: tuple | None = None # totals row added on run_end
# ------------------------------------------------------------------ # Context manager # ------------------------------------------------------------------ def __enter__(self) -> RunMonitor: return self def __exit__(self, *args) -> None: if self._backend == "rich" and self._live is not None: self._live.stop() self._live = None # ------------------------------------------------------------------ # Public event API # ------------------------------------------------------------------
[docs] def on_run_start(self, n_windows: int, n_particles: int) -> None: """Called once before the integration loop begins. Parameters ---------- n_windows : int Total number of GCM windows to integrate. n_particles : int Initial number of particles. """ self._n_windows = n_windows self._n_particles = n_particles if self._backend == "log": self._logger.info( "Run started: n_windows=%d, n_particles=%d", n_windows, n_particles, ) elif self._backend == "rich" and self._live is None: self._start_rich()
[docs] def on_window_start(self, win: int, n_active: int, dtm: pd.Timestamp) -> None: """Called at the start of each window, before I/O and advection. Parameters ---------- win : int Zero-based window index. n_active : int Number of active particles entering this window. dtm : pandas.Timestamp Timestamp of the current GCM time window. """ if self._backend == "log": self._logger.debug( "Window %d/%d starting: %d active, dtm=%s", win + 1, self._n_windows, n_active, dtm.date() if dtm is not None else "?", ) elif self._backend == "rich" and self._progress is not None: total = self._n_windows if self._n_windows is not None else win + 1 n_left = total - win self._progress.update( self._task_id, description=f"[cyan]{win} done, {n_left} left[/] …", ) self._live.refresh()
[docs] def on_window_end( self, win: int, dtm: pd.Timestamp, n_active_before: int, n_active_after: int, n_stopped: int, reason_tally: dict[str, int], n_sub: int, limit_hit: bool, t_io: float, t_jax: float, t_py: float, ) -> None: """Called at the end of each window after advection and reactivation. Parameters ---------- win : int Zero-based window index. dtm : pandas.Timestamp Timestamp of the window just completed. n_active_before : int Active count at window entry. n_active_after : int Active count after advection + reactivation. n_stopped : int Particles permanently deactivated this window. reason_tally : dict[str, int] Counts per deactivation reason name. n_sub : int Number of sub-cycles executed. limit_hit : bool Whether the sub-cycle limit was reached. t_io : float Wall time spent in I/O (Dask/Zarr reads), seconds. t_jax : float Wall time spent in JAX advection (device_put + while_loop), seconds. t_py : float Wall time spent in Python overhead (reactivation, GC, flux swap), seconds. """ self._t_io += t_io self._t_jax += t_jax self._t_py += t_py self._wins_done += 1 self._cumul_stopped += n_stopped for k, v in reason_tally.items(): self._cumul_reason_tally[k] = self._cumul_reason_tally.get(k, 0) + v t_win = t_io + t_jax + t_py if self._backend == "print": self._print_window( win, dtm, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, t_win, ) elif self._backend == "log": self._log_window( win, dtm, n_active_before, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, ) elif self._backend == "rich": self._rich_window( win, dtm, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, t_win, )
[docs] def on_run_end(self) -> None: """Called once after the integration loop finishes.""" t_total = self._t_io + self._t_jax + self._t_py if self._backend == "print": self._print_summary(t_total) elif self._backend == "log": self._log_summary(t_total) elif self._backend == "rich": self._rich_summary(t_total) if self._live is not None: self._live.stop() self._live = None
# ------------------------------------------------------------------ # Print backend # ------------------------------------------------------------------ def _print_window( self, win, dtm, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, t_win, ): n_windows = self._n_windows if self._n_windows is not None else "?" limit_str = " [LIMIT]" if limit_hit else "" stop_str = ( f" stopped:{n_stopped} ({_reason_str(reason_tally)})" if n_stopped else "" ) date_str = dtm.date() if dtm is not None else "?" print( f"{win + 1:03}/{n_windows:03} {date_str} " f"{n_active_after} active" f"{stop_str}, " f"#sub:{n_sub}{limit_str}, " f"wall:{sec_str(t_win)} " f"[io:{sec_str(t_io)} " f"jax:{sec_str(t_jax)} " f"py:{sec_str(t_py)}]" ) def _print_summary(self, t_total: float): if self._wins_done <= 1 or t_total <= 0: return pct = lambda t: f"{100 * t / t_total:.0f}%" # noqa: E731 print( f"--- total wall:{sec_str(t_total)} " f"[io:{sec_str(self._t_io)} {pct(self._t_io)} " f"jax:{sec_str(self._t_jax)} {pct(self._t_jax)} " f"py:{sec_str(self._t_py)} {pct(self._t_py)}]" ) if self._cumul_stopped: reason_str = _reason_str(self._cumul_reason_tally) print(f"--- stopped:{self._cumul_stopped} ({reason_str})") # ------------------------------------------------------------------ # Log backend # ------------------------------------------------------------------ def _log_window( self, win, dtm, n_active_before, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, ): t_win = t_io + t_jax + t_py date_str = str(dtm.date()) if dtm is not None else "?" extra = { "win": win + 1, "n_windows": self._n_windows, "dtm": date_str, "n_active_before": n_active_before, "n_active_after": n_active_after, "n_stopped": n_stopped, "n_sub": n_sub, "limit_hit": limit_hit, "t_io": round(t_io, 4), "t_jax": round(t_jax, 4), "t_py": round(t_py, 4), "t_win": round(t_win, 4), **{f"stopped_{k}": v for k, v in reason_tally.items()}, } self._logger.info( "window %d/%s %s: %d→%d active, stopped:%d, #sub:%d%s, " "wall:%.2fs [io:%.2fs jax:%.2fs py:%.2fs]", win + 1, self._n_windows if self._n_windows is not None else "?", date_str, n_active_before, n_active_after, n_stopped, n_sub, " LIMIT" if limit_hit else "", t_win, t_io, t_jax, t_py, extra=extra, ) def _log_summary(self, t_total: float): pct = lambda t: 100 * t / max(t_total, 1e-9) # noqa: E731 extra = { "cumul_stopped": self._cumul_stopped, **{f"stopped_{k}": v for k, v in self._cumul_reason_tally.items()}, } self._logger.info( "Run finished: %d windows, stopped:%d (%s), total %.2fs " "[io:%.2fs %.0f%% jax:%.2fs %.0f%% py:%.2fs %.0f%%]", self._wins_done, self._cumul_stopped, _reason_str(self._cumul_reason_tally), t_total, self._t_io, pct(self._t_io), self._t_jax, pct(self._t_jax), self._t_py, pct(self._t_py), extra=extra, ) # ------------------------------------------------------------------ # Rich backend # ------------------------------------------------------------------ def _make_table(self): """Build a fresh Table from the last ``max_table_rows`` stored rows.""" from rich.table import Table from tracmass.config import settings # Read on every rebuild so an env switched mid-run (``setenv``) shows up. env = str(settings.current_env).lower() table = Table( "Date", "Active", "Stopped ∑", "#sub", "wall", "io", "jax", "py", title=f"tracmass · time_dependent · env: {env}", show_lines=False, header_style="bold cyan", border_style="dim", ) for row in self._rows[-self._max_table_rows :]: table.add_row(*row) if self._summary is not None: table.add_section() table.add_row(*self._summary) return table def _start_rich(self): from rich.console import Group from rich.live import Live from rich.progress import ( BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn, ) # `or 1` would wrongly fall back to 1 for a genuine n_windows=0 (e.g. # a caller's off-by-typo date range yielding zero windows), leaving # the bar frozen at a misleading "0/1" forever since nothing will # ever advance it. Only an unset (None) total falls back to 1. n_total = self._n_windows if self._n_windows is not None else 1 self._progress = Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), MofNCompleteColumn(), TimeElapsedColumn(), ) self._task_id = self._progress.add_task("", total=n_total) self._live = Live( Group(self._make_table(), self._progress), refresh_per_second=4, ) self._live.start() def _rich_window( self, win, dtm, n_active_after, n_stopped, reason_tally, n_sub, limit_hit, t_io, t_jax, t_py, t_win, ): if self._live is None: return from rich.console import Group limit_str = " !" if limit_hit else "" cumul = self._cumul_stopped date_str = str(dtm.date()) if dtm is not None else "?" self._rows.append( ( date_str, str(n_active_after), f"[red]{cumul}[/]" if cumul else "", f"{n_sub}{limit_str}", sec_str(t_win), f"[dim]{sec_str(t_io)}[/]", sec_str(t_jax), f"[dim]{sec_str(t_py)}[/]", ) ) self._progress.advance(self._task_id) n_done = win + 1 total = self._n_windows if self._n_windows is not None else n_done n_left = total - n_done self._progress.update( self._task_id, description=f"[cyan]{n_done} done, {n_left} left[/]", ) self._live.update(Group(self._make_table(), self._progress)) def _rich_summary(self, t_total: float): if self._live is None or t_total <= 0: return from rich.console import Group pct = lambda t: f"{100 * t / t_total:.0f}%" # noqa: E731 cumul = self._cumul_stopped reason_str = _reason_str(self._cumul_reason_tally) if cumul: stop_cell = f"[red]{cumul}[/]\n[dim]{reason_str}[/]" else: stop_cell = "" self._summary = ( "∑", "", stop_cell, "", f"[bold]{sec_str(t_total)}[/]", f"[dim]{sec_str(self._t_io)} {pct(self._t_io)}[/]", f"[bold]{sec_str(self._t_jax)}[/] {pct(self._t_jax)}", f"[dim]{sec_str(self._t_py)} {pct(self._t_py)}[/]", ) self._progress.update( self._task_id, description=f"[green]{self._wins_done} done, 0 left[/]", ) self._live.update(Group(self._make_table(), self._progress))