Source code for tracmass.runsetup

"""Resolve settings-driven arguments of the integrators.

Covers ``monitor`` / ``recorder`` (lifecycle-managed here via
:func:`manage_run_io`) and ``no_wflux`` (a plain bool derived from the
``move_vertically`` setting via :func:`resolve_no_wflux`).

The multi-window integration routines
(:func:`~tracmass.integrate.time_dependent`,
:func:`~tracmass.integrate.time_dependent_patched`,
:func:`~tracmass.integrate.time_dependent_streaming`) each accept a
``monitor`` and a ``recorder`` argument.  Historically the caller had to
build a :class:`~tracmass.RunMonitor` / :class:`~tracmass.ParticleRecorder`
and drive its context manager by hand::

    with (RunMonitor(backend="rich") as mon,
          ParticleRecorder("run.parquet") as rec):
        tracmass.time_dependent_patched(..., monitor=mon, recorder=rec)

The :func:`manage_run_io` decorator removes that boilerplate.  Applied to an
integrator it accepts the same two arguments in any of four forms and owns
the lifecycle of whatever it constructs itself:

====================  ===================================================
value                 behaviour
====================  ===================================================
an instance           used as-is; the *caller* owns its lifecycle
a ``str``             ``RunMonitor(backend=...)`` / ``ParticleRecorder(...)``
                      built here and entered on an ``ExitStack``
``None``              feature disabled
omitted (``_UNSET``)  built from :mod:`tracmass.config` settings and
                      entered on the ``ExitStack``
====================  ===================================================

"Entered on the ``ExitStack``" means the object's ``__exit__`` runs
automatically when the integrator returns *or raises*, so a ``with`` block
around the call is no longer required.

Settings keys consulted for the omitted (``_UNSET``) case
---------------------------------------------------------
``runmonitor_backend``
    Backend for the auto-built :class:`RunMonitor` (``"print"``, ``"log"``,
    or ``"rich"``).  Default ``"print"``.
``output_name``
    Filename stem for the auto-built recorder.  Setting it also *enables*
    auto-recording.  Default stem when auto-recording via ``auto_record``:
    ``trm_{active-env}``.
``auto_record``
    Truthy value enables auto-recording even when ``output_name`` is unset.
``base_output_dir``
    Directory the auto-built recorder writes into.  Default: current
    directory.
``use_sub_folder``
    When truthy, the auto-built recorder writes into a sub-directory of
    ``base_output_dir`` named after ``output_name``, else the active
    environment.  Default ``False``.  Missing directories are created.

The auto-built filename is
``{stem}_{forward|reverse}_{dtm_start.date()}.parquet`` — the direction token
comes from ``grid.reverse`` and the date from ``fluxes.dtm``.

When neither ``output_name`` nor ``auto_record`` is set, an omitted
``recorder`` resolves to ``None`` (no trajectory file) — the historical
default.

Vertical advection
------------------
``move_vertically``
    When an integrator's ``no_wflux`` argument is not passed explicitly,
    :func:`resolve_no_wflux` sets ``no_wflux = not move_vertically`` from
    this setting (default ``True`` — particles do move vertically, matching
    the historical ``no_wflux=False`` default).  An explicit ``no_wflux=``
    always wins.  The ECCO2 adapter reads the same setting for its
    ``include_wflux`` argument (see :func:`tracmass.config.move_vertically`).

Particle batching
-----------------
``n_particle_batches``
    When an integrator's ``n_particle_batches`` argument is not passed
    explicitly, :func:`resolve_n_particle_batches` takes it from this
    setting (default ``1`` — no batching).  An explicit value always wins;
    both are clamped to ``>= 1``.  The full-grid integrators
    (:func:`~tracmass.integrate.steady_state`,
    :func:`~tracmass.integrate.time_dependent`) act on it with a
    particle-batched ``lax.while_loop``; the streaming integrators accept it
    for API symmetry only.

Ramp-up saves
-------------
``rampup_windows`` / ``rampup_substeps``
    When an integrator's ``rampup_windows`` / ``rampup_substeps`` arguments
    are not passed, :func:`resolve_rampup` takes them from these settings
    (defaults ``0`` — no ramp-up — and ``5``).  During the first
    ``rampup_windows`` windows positions are also saved
    ``rampup_substeps - 1`` times inside each window, as
    ``event_type="rampup"`` rows; :func:`rampup_metadata` describes the
    resulting save times in the trajectory file.
"""

from __future__ import annotations

import contextlib
import functools
import re
from pathlib import Path
from typing import Callable

from tracmass.monitoring import RunMonitor
from tracmass.particle import ParticleRecorder
from tracmass.verbose import verbose_output

__all__ = [
    "manage_run_io",
    "resolve_monitor",
    "resolve_n_particle_batches",
    "resolve_no_wflux",
    "resolve_rampup",
    "resolve_recorder",
    "resolve_seed_freq",
    "resolve_timax",
    "rampup_metadata",
    "reseed",
    "run_metadata",
    "seed_metadata",
    "seed_windows",
    "traj_max_seconds",
    "window_ends",
]

TIMAX_NONE = 1e20  # timax_seconds when there is no lifetime limit

# Sentinel: the argument was not supplied at all (distinct from explicit None).
_UNSET: object = object()


[docs] def resolve_no_wflux(no_wflux) -> bool: """Normalise an integrator ``no_wflux`` argument to a plain bool. Parameters ---------- no_wflux : bool or _UNSET An explicit bool is returned unchanged (the caller always wins). ``_UNSET`` (the argument was omitted) is resolved from settings as ``not move_vertically()`` — see :func:`tracmass.config.move_vertically`. Returns ------- bool """ if no_wflux is not _UNSET: return bool(no_wflux) try: from tracmass.config import move_vertically return not move_vertically() except Exception: # pragma: no cover - config is optional return False
[docs] def resolve_n_particle_batches(n_particle_batches) -> int: """Normalise an integrator ``n_particle_batches`` argument to a plain int. Parameters ---------- n_particle_batches : int or _UNSET An explicit value is returned as ``max(1, int(...))`` (the caller always wins). ``_UNSET`` (the argument was omitted) is resolved from the ``n_particle_batches`` setting — see :func:`tracmass.config.n_particle_batches` (default ``1``). Returns ------- int Batching factor, clamped to ``>= 1``. """ if n_particle_batches is not _UNSET: try: return max(1, int(n_particle_batches)) except (TypeError, ValueError): return 1 try: from tracmass.config import n_particle_batches as _setting return _setting() except Exception: # pragma: no cover - config is optional return 1
[docs] def resolve_rampup(rampup_windows, rampup_substeps) -> tuple[int, int]: """Normalise the integrators' ramp-up arguments to ``(windows, substeps)``. Parameters ---------- rampup_windows : int or _UNSET Number of leading windows saved more often. ``_UNSET`` → the ``rampup_windows`` setting, default ``0`` (no ramp-up). rampup_substeps : int or _UNSET Saves per window during ramp-up (``5`` → every ``timestep / 5``). ``_UNSET`` → the ``rampup_substeps`` setting, default ``5``. Returns ------- tuple[int, int] ``(rampup_windows, rampup_substeps)``; ``(0, 1)`` when ramp-up is off (either is ``0`` / ``1``). Raises ------ ValueError If either is negative / below 1, or not an integer. """ st = _settings() if rampup_windows is _UNSET: rampup_windows = st.get("rampup_windows", 0) if st is not None else 0 if rampup_substeps is _UNSET: rampup_substeps = st.get("rampup_substeps", 5) if st is not None else 5 windows = int(rampup_windows or 0) substeps = int(5 if rampup_substeps is None else rampup_substeps) if windows < 0 or substeps < 1: raise ValueError( f"rampup_windows must be >= 0 and rampup_substeps >= 1, got " f"{rampup_windows!r} / {rampup_substeps!r}" ) if windows == 0 or substeps == 1: return 0, 1 return windows, substeps
[docs] def window_ends(win: int, rampup_windows: int, rampup_substeps: int) -> list[float]: """Normalised times at which window *win* stops to save positions. ``[win + 1]`` normally; ``[win + 1/k, win + 2/k, ..., win + 1]`` with ``k = rampup_substeps`` during the first *rampup_windows* windows. All but the last are ramp-up saves; the last is the next window's snapshot. """ if win < rampup_windows: k = rampup_substeps return [win + m / k for m in range(1, k)] + [float(win + 1)] return [float(win + 1)]
[docs] def rampup_metadata( n_windows: int, timestep_seconds: float, rampup_windows: int, rampup_substeps: int, ) -> dict: """Run-metadata keys describing when positions are saved. Returns ------- dict ``rampup_windows``, ``rampup_substeps`` and ``save_dt_seconds``: the time between consecutive saved positions (``snapshot`` and ``rampup`` rows, in file order), one entry per interval, so the *n*-th save is at ``sum(save_dt_seconds[:n])`` after the start. Covers the planned *n_windows*; a run that stops early (no active particles left) saves fewer. """ dt = [] for win in range(n_windows): ends = window_ends(win, rampup_windows, rampup_substeps) dt += [timestep_seconds / len(ends)] * len(ends) return dict( rampup_windows=rampup_windows, rampup_substeps=rampup_substeps, save_dt_seconds=dt, )
[docs] def resolve_n_windows(n_windows, fluxes) -> int: """Normalise an integrator ``n_windows`` argument to a plain int. Parameters ---------- n_windows : int or None An explicit value is returned unchanged (the caller always wins). ``None`` is resolved from ``fluxes.n_windows`` — the number of windows from ``fluxes.dtm`` to the *dtm_end* given to :func:`tracmass.gcm.new_flux` — falling back to ``1`` when no *dtm_end* was given (or *fluxes* is not a ``LazyFluxFields``). fluxes : LazyFluxFields or FluxFields The flux buffer passed to the integrator. Returns ------- int """ if n_windows is not None: return int(n_windows) derived = getattr(fluxes, "n_windows", None) return 1 if derived is None else derived
[docs] def resolve_seed_freq(seed_freq) -> str | None: """Normalise an integrator ``seed_freq`` argument. Parameters ---------- seed_freq : str or None or _UNSET A pandas frequency (``"30D"``, ``"MS"``, ...) or ``None`` (seed once) is returned unchanged; the caller always wins. ``_UNSET`` (the argument was omitted) is resolved from ``seed_freq`` in the ``[env.seed]`` settings table, default ``None``. Returns ------- str or None The frequency, or ``None`` when particles are seeded only at the start (also for an empty string or ``false``). """ if seed_freq is _UNSET: st = _settings() cfg = st.get("seed", None) if st is not None else None seed_freq = None if cfg: kw = {str(k).lower(): v for k, v in dict(cfg).items()} seed_freq = kw.get("seed_freq") return seed_freq or None
[docs] def seed_windows( seed_freq: str | None, dtm_start, timestep, n_windows: int, reverse: bool = False, ) -> list[int]: """Windows at whose start a repeat seed is released. The seed times are *dtm_start* stepped by the pandas offset *seed_freq* — forward in time, or backward for a backward run — and each is placed on the window that starts at it. A seed time inside a window moves to the start of the next window. The initial seed (window 0) is not included. Parameters ---------- seed_freq : str or None Pandas frequency string, e.g. ``"30D"``, ``"7D"``, ``"MS"`` (month starts) or ``"QS"``. ``None`` → no repeat seeds. dtm_start : str or pd.Timestamp Start time of window 0. timestep : pd.Timedelta or datetime.timedelta Window length. n_windows : int Number of windows in the run; seeds at or after its end are dropped. reverse : bool, optional Backward run (windows step back in time). Default ``False``. Returns ------- list of int Sorted, unique window indices, all in ``1 .. n_windows - 1``. Raises ------ ValueError If *seed_freq* is not a valid pandas frequency. Examples -------- >>> seed_windows("10D", "2010-06-01", pd.Timedelta("1D"), 30) [10, 20] >>> seed_windows("MS", "2010-06-15", pd.Timedelta("1D"), 40) [16] """ import warnings import numpy as np import pandas as pd if not seed_freq: return [] try: offset = pd.tseries.frequencies.to_offset(seed_freq) except ValueError as err: raise ValueError( f"seed_freq {seed_freq!r} is not a pandas frequency " f"(e.g. '30D', '7D', 'MS')" ) from err start = pd.Timestamp(dtm_start) step = pd.Timedelta(timestep) end = start + step * n_windows if not reverse else start - step * n_windows windows: list[int] = [] snapped = False t = start while True: t_next = t - offset if reverse else t + offset if t_next == t: # a zero offset would loop forever raise ValueError(f"seed_freq {seed_freq!r} does not advance in time") t = t_next if (t >= end) if not reverse else (t <= end): break frac = abs(t - start) / step win = int(np.ceil(frac - 1e-9)) snapped |= win != frac if 0 < win < n_windows and (not windows or win != windows[-1]): windows.append(win) if snapped: warnings.warn( f"seed_freq {seed_freq!r} does not fall on window boundaries " f"(timestep {step}); seeds are released at the start of the next " f"window", stacklevel=2, ) return windows
[docs] def reseed(state, seed, win: int, tseas: float): """Append a copy of *seed* released at the start of window *win*. The copy's clock is moved forward by *win* windows (``tt`` and ``t0`` by ``win * tseas`` seconds, ``ts`` by *win*) and its iteration counter is reset, so it starts as a fresh set of particles at that time. Active flags and reasons come from *seed* (particles seeded inactive stay so). Parameters ---------- state : ParticleState Current state of the run. seed : ParticleState The run's initial state, as passed to the integrator. win : int Window index of the release. tseas : float Window length in seconds. Returns ------- ParticleState ``ParticleState.concat(state, shifted_seed)``: existing particles keep their indices (and output ``id``), the new ones follow. """ import jax.numpy as jnp import numpy as np from tracmass.particle import ParticleState xp = np if isinstance(state.x, np.ndarray) else jnp shifted = seed._replace( tt=seed.tt + xp.asarray(win * tseas, dtype=seed.tt.dtype), t0=seed.t0 + xp.asarray(win * tseas, dtype=seed.t0.dtype), ts=seed.ts + xp.asarray(win, dtype=seed.ts.dtype), niter=xp.zeros_like(seed.niter), ) if xp is np: shifted = type(seed)(*(np.asarray(a) for a in shifted)) return ParticleState.concat(state, shifted)
[docs] def seed_metadata(seed_freq: str | None, windows: list[int]) -> dict: """Run-metadata keys describing repeat seeding. Returns ------- dict ``seed_freq`` and ``seed_windows`` (the windows of the repeat releases, without the initial one at window 0); empty when *seed_freq* is ``None``. """ if not seed_freq: return {} return dict(seed_freq=str(seed_freq), seed_windows=list(windows))
_CALENDAR = re.compile(r"^\s*(\d*)\s*(M|Q|Y)\s*$") _CALENDAR_MONTHS = {"M": 1, "Q": 3, "Y": 12}
[docs] def traj_max_seconds(traj_max_time, dtm_start=None, reverse: bool = False) -> float: """Convert a trajectory lifetime to seconds. Parameters ---------- traj_max_time : str or pd.Timedelta or float or None The longest a particle may live, counted from its release. A ``pd.Timedelta`` string (``"30D"``, ``"12h"``, ``"2W"``), or calendar months / quarters / years as ``"M"``, ``"Q"``, ``"Y"`` with an optional count (``"1M"``, ``"6M"``, ``"2Y"``). A number is taken as seconds; ``None`` means no limit. dtm_start : str or pd.Timestamp, optional The run's start time. Calendar units have no fixed length, so they are measured from here (backward for a backward run): ``"1M"`` from 2010-02-01 is 28 days. Needed only for calendar units. reverse : bool, optional Backward run. Returns ------- float Lifetime in seconds (``1e20`` for no limit). Raises ------ ValueError If *traj_max_time* cannot be parsed, is not positive, or is in calendar units without *dtm_start*. """ import pandas as pd if traj_max_time is None or traj_max_time is False or traj_max_time == "": return TIMAX_NONE if isinstance(traj_max_time, (int, float)) and not isinstance( traj_max_time, bool ): seconds = float(traj_max_time) else: match = _CALENDAR.match(str(traj_max_time)) if match: if dtm_start is None: raise ValueError( f"traj_max_time {traj_max_time!r} is in calendar units and " "needs the run's start time" ) n = int(match[1] or 1) * _CALENDAR_MONTHS[match[2]] t0 = pd.Timestamp(dtm_start) offset = pd.DateOffset(months=n) seconds = abs(((t0 - offset) if reverse else (t0 + offset)) - t0) seconds = seconds.total_seconds() else: try: seconds = pd.Timedelta(traj_max_time).total_seconds() except ValueError as err: raise ValueError( f"traj_max_time {traj_max_time!r} is neither a pandas " "Timedelta (e.g. '30D', '12h') nor calendar units " "('1M', '1Q', '1Y')" ) from err if not seconds > 0: raise ValueError(f"traj_max_time {traj_max_time!r} must be positive") return seconds
[docs] def resolve_timax( timax_seconds, traj_max_time, dtm_start=None, reverse: bool = False ) -> tuple[float, dict]: """Resolve the trajectory lifetime of an integrator call. Parameters ---------- timax_seconds : float or _UNSET Lifetime in seconds. An explicit value wins. traj_max_time : str or None or _UNSET Lifetime as a pandas-style string, see :func:`traj_max_seconds`. ``_UNSET`` is resolved from the ``traj_max_time`` setting, default no limit. dtm_start : str or pd.Timestamp, optional The run's start time, for calendar units. reverse : bool, optional Backward run. Returns ------- tuple[float, dict] ``timax_seconds`` and run-metadata keys (``traj_max_time`` and ``timax_seconds``; empty without a limit). Raises ------ ValueError If both arguments are given explicitly. """ if timax_seconds is not _UNSET: if traj_max_time is not _UNSET and traj_max_time is not None: raise ValueError("pass either timax_seconds or traj_max_time, not both") seconds, label = float(timax_seconds), None else: if traj_max_time is _UNSET: st = _settings() traj_max_time = st.get("traj_max_time", None) if st is not None else None seconds = traj_max_seconds(traj_max_time, dtm_start, reverse) label = traj_max_time if seconds >= TIMAX_NONE: return TIMAX_NONE, {} meta = dict(timax_seconds=seconds) if label is not None: meta["traj_max_time"] = str(label) return seconds, meta
def _settings(): """Return the Dynaconf settings object, or ``None`` if it cannot load.""" try: from tracmass.config import settings return settings except Exception: # pragma: no cover - config is optional return None
[docs] def resolve_monitor(monitor, stack: contextlib.ExitStack) -> RunMonitor | None: """Normalise *monitor* to a :class:`RunMonitor` instance or ``None``. Parameters ---------- monitor : RunMonitor or str or None or _UNSET See the module docstring for the four accepted forms. stack : contextlib.ExitStack A monitor constructed here is registered on this stack so its ``__exit__`` runs when the integrator returns or raises. A monitor passed in by the caller is returned untouched. Returns ------- RunMonitor or None """ if isinstance(monitor, RunMonitor) or monitor is None: return monitor if isinstance(monitor, str): backend = monitor else: # _UNSET -> consult settings st = _settings() backend = (st.get("runmonitor_backend", "print") if st else "print") or "print" return stack.enter_context(RunMonitor(backend=backend))
[docs] def resolve_recorder( recorder, stack: contextlib.ExitStack, *, dtm_start=None, reverse: bool = False, ) -> ParticleRecorder | None: """Normalise *recorder* to a :class:`ParticleRecorder` instance or ``None``. Parameters ---------- recorder : ParticleRecorder or str or os.PathLike or None or _UNSET See the module docstring for the four accepted forms. stack : contextlib.ExitStack A recorder constructed here is registered on this stack so its Parquet writer is closed when the integrator returns or raises. A recorder passed in by the caller is returned untouched. dtm_start : pandas.Timestamp or datetime-like, optional Start timestamp of the run, used only for the auto-built filename. reverse : bool, optional Direction of the run (``grid.reverse``). Selects the ``forward`` / ``reverse`` token in the auto-built filename. Default ``False``. Returns ------- ParticleRecorder or None """ if isinstance(recorder, ParticleRecorder) or recorder is None: return recorder if recorder is _UNSET: path = _auto_recorder_path(dtm_start, reverse) if path is None: return None path.parent.mkdir(parents=True, exist_ok=True) else: # str / PathLike path = Path(recorder) return stack.enter_context(ParticleRecorder(str(path)))
def _auto_recorder_path(dtm_start, reverse: bool = False) -> Path | None: """Build the settings-driven Parquet path, or ``None`` if not enabled. Auto-recording is enabled when ``output_name`` is set or ``auto_record`` is truthy in the active settings environment. The filename is ``{stem}_{forward|reverse}_{dtm_start.date()}.parquet`` (the date is dropped when *dtm_start* is ``None``), where *stem* is ``output_name`` or ``trm_{active-env}``, written into ``base_output_dir`` (current directory when unset) — or, when ``use_sub_folder`` is truthy, into its sub-directory ``output_name`` (else ``{active-env}``). """ from tracmass.output import output_filename, output_folder, output_stem st = _settings() if st is None: return None if not (st.get("output_name", None) or st.get("auto_record", False)): return None return output_folder(st) / output_filename(output_stem(st), reverse, dtm_start)
[docs] def run_metadata(fn: Callable, grid, fluxes) -> dict: """Describe a run for the ``"tracmass.run"`` metadata of its trajectory file. Parameters ---------- fn : callable The integrator; its name is stored as ``integrator``. grid : GridFields Supplies the run direction and time step. fluxes : LazyFluxFields or FluxFields Supplies the start / end time and the GCM adapter (``fluxes_fn``). Returns ------- dict JSON-serialisable; keys whose value is unknown are left out: ``gcm`` Adapter name (``"glorys"``, ``"ecco2"``, ...) when ``fluxes_fn`` is a :mod:`tracmass.gcm` adapter, else ``fluxes_fn`` (``"module.qualname"``) for a custom flux function. ``gcm_product`` The adapter's data product (``tracmass.seagrid.<gcm>.settings ["product"]``), e.g. ``"glorys12v1"``. ``gcm_subset`` The adapter's ``subset`` setting, unset edges left out. ``env`` Active ``tracmass.toml`` environment. ``integrator`` Name of the integrator function. ``direction`` ``"forward"`` or ``"reverse"``. ``dtm_start``, ``dtm_end``, ``timestep_seconds`` Time span of the run and the GCM timestep. ``tracmass_version``, ``created`` Package version and creation time (UTC, ISO 8601). """ import datetime import importlib from importlib import metadata as importlib_metadata meta: dict = {} fluxes_fn = getattr(fluxes, "fluxes_fn", None) module = getattr(fluxes_fn, "__module__", "") or "" if module.startswith("tracmass.gcm."): name = module.rsplit(".", 1)[1] meta["gcm"] = name adapter = importlib.import_module(module) subset = getattr(adapter, "settings", {}).get("subset") or {} subset = {k: v for k, v in subset.items() if v is not None} if subset: meta["gcm_subset"] = subset try: source = importlib.import_module(f"tracmass.seagrid.{name}") product = getattr(source, "settings", {}).get("product") except ImportError: product = None if product: meta["gcm_product"] = str(product) elif fluxes_fn is not None: qualname = getattr(fluxes_fn, "__qualname__", type(fluxes_fn).__name__) meta["fluxes_fn"] = f"{module}.{qualname}" if module else qualname st = _settings() env = (getattr(st, "current_env", "") or "").lower() if st is not None else "" if env: meta["env"] = env meta["integrator"] = getattr(fn, "__name__", str(fn)) meta["direction"] = "reverse" if getattr(grid, "reverse", False) else "forward" for key, attr in (("dtm_start", "dtm"), ("dtm_end", "dtm_end")): value = getattr(fluxes, attr, None) if value is not None: meta[key] = str(value) timestep = getattr(grid, "timestep", None) if timestep is not None: try: import pandas as pd meta["timestep_seconds"] = float(pd.Timedelta(timestep).total_seconds()) except (TypeError, ValueError): pass try: meta["tracmass_version"] = importlib_metadata.version("tracmass") except importlib_metadata.PackageNotFoundError: # pragma: no cover pass meta["created"] = datetime.datetime.now(datetime.UTC).isoformat( timespec="seconds" ) return meta
[docs] def manage_run_io(fn: Callable) -> Callable: """Decorate an integrator to own its *monitor* / *recorder* lifecycle. The wrapped function is always called with ``monitor`` and ``recorder`` already normalised to an instance or ``None`` (via :func:`resolve_monitor` / :func:`resolve_recorder`), with verbose debug output on if the ``verbose`` setting is true (:mod:`tracmass.verbose`), wrapped in a :class:`contextlib.ExitStack` that closes any object constructed on the caller's behalf when the integrator returns or raises. The function body no longer interprets sentinels or constructs these objects itself. Parameters ---------- fn : callable An integrator called as ``fn(state, grid, fluxes, ...)`` — ``grid`` supplies the run direction (``grid.reverse``) and ``fluxes`` the run start timestamp (``fluxes.dtm``) for the auto-built recorder filename — and which accepts keyword-only ``monitor`` and ``recorder`` parameters. The recorder's ``grid_offset`` is set from ``grid.i_offset`` / ``grid.j_offset`` so it writes full-grid positions, and :func:`run_metadata` is added to its metadata (keys already there are kept). Returns ------- callable Wrapper carrying *fn*'s name, docstring and signature via :func:`functools.wraps`. """ @functools.wraps(fn) def wrapper(*args, monitor=_UNSET, recorder=_UNSET, **kwargs): grid = args[1] if len(args) >= 2 else kwargs.get("grid") fluxes = args[2] if len(args) >= 3 else kwargs.get("fluxes") dtm_start = getattr(fluxes, "dtm", None) reverse = bool(getattr(grid, "reverse", False)) with contextlib.ExitStack() as stack: stack.enter_context(verbose_output()) monitor = resolve_monitor(monitor, stack) recorder = resolve_recorder( recorder, stack, dtm_start=dtm_start, reverse=reverse ) if recorder is not None: # Saved positions are full-grid indices even on a subset grid. recorder.grid_offset = ( int(getattr(grid, "i_offset", 0)), int(getattr(grid, "j_offset", 0)), ) # Describe the run so tracmass.load can find the adapter for # lon/lat; values the caller already put there win. for key, value in run_metadata(fn, grid, fluxes).items(): recorder.metadata.setdefault(key, value) return fn(*args, monitor=monitor, recorder=recorder, **kwargs) return wrapper