|
| 1 | +"""Transparent rolling time-window cache for lazy (dask-backed) field data. |
| 2 | +
|
| 3 | +Assumptions / current limits: |
| 4 | + * ``time`` is the leading dimension of the field (true for both the SGRID and |
| 5 | + UGRID ingestion paths; the structured path transposes to ``(time, ...)``). |
| 6 | + * Valid while the requested time indices stay within the resident window |
| 7 | + (i.e. all particles share the clock). A sample that requests time indices |
| 8 | + spanning more than the retained levels would force reloads. |
| 9 | + * The clock is assumed monotonic but may run in either direction: forward |
| 10 | + (``dt > 0``) or backward (``dt < 0``). Eviction keeps only the levels each |
| 11 | + ``isel`` actually requests, which is symmetric in time -- so direction never |
| 12 | + enters the logic and no integration-direction flag is needed. |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import numpy as np |
| 18 | +import xarray as xr |
| 19 | +from dask import is_dask_collection |
| 20 | + |
| 21 | +# xarray / uxarray ``isel`` keyword arguments that are NOT dimension indexers. |
| 22 | +_NON_INDEXER_KWARGS = frozenset({"drop", "missing_dims", "ignore_grid"}) |
| 23 | + |
| 24 | + |
| 25 | +class WindowedArray: |
| 26 | + """Wrap a lazy DataArray so ``isel`` loads/caches/evicts time levels as NumPy.""" |
| 27 | + |
| 28 | + def __init__(self, data: xr.DataArray, time_dim: str = "time", max_levels: int | None = None): |
| 29 | + if data.dims[0] != time_dim: |
| 30 | + raise ValueError(f"WindowedArray expects {time_dim!r} as the leading dimension, got {data.dims}") |
| 31 | + self._data = data |
| 32 | + self._tdim = time_dim |
| 33 | + self._cache: dict[int, np.ndarray] = {} # time index -> NumPy slab (remaining dims) |
| 34 | + self._max = max_levels |
| 35 | + # diagnostics |
| 36 | + self.loads = 0 |
| 37 | + self.bytes_read = 0 |
| 38 | + self._slab_bytes = int(np.prod(data.isel({time_dim: 0}).shape)) * data.dtype.itemsize |
| 39 | + |
| 40 | + # -- transparency: forward everything we don't override ------------------- |
| 41 | + def __getattr__(self, name): |
| 42 | + # __getattr__ only fires for misses; reach _data without recursing. |
| 43 | + return getattr(object.__getattribute__(self, "_data"), name) |
| 44 | + |
| 45 | + def __repr__(self): |
| 46 | + return ( |
| 47 | + f"WindowedArray(time_dim={self._tdim!r}, cached_levels={sorted(self._cache)}, " |
| 48 | + f"loads={self.loads})\n{self._data!r}" |
| 49 | + ) |
| 50 | + |
| 51 | + # -- window management ---------------------------------------------------- |
| 52 | + def _read_level(self, lvl: int) -> np.ndarray: |
| 53 | + """Bulk, sequential read of one time level into NumPy (the dask->NumPy step).""" |
| 54 | + return np.asarray(self._data.isel({self._tdim: int(lvl)}).values) |
| 55 | + |
| 56 | + def _ensure(self, levels: np.ndarray) -> None: |
| 57 | + for lvl in levels: |
| 58 | + lvl = int(lvl) |
| 59 | + if lvl not in self._cache: |
| 60 | + self._cache[lvl] = self._read_level(lvl) |
| 61 | + self.loads += 1 |
| 62 | + self.bytes_read += self._slab_bytes |
| 63 | + # retire cached levels outside the span this call requested. Direction never |
| 64 | + # enters here: a forward (dt > 0) or backward (dt < 0) clock both shed their |
| 65 | + # trailing edge. Consecutive brackets overlap on one endpoint (inside [lo, hi]), |
| 66 | + # so it is retained and each level is still read at most once per pass. |
| 67 | + lo, hi = int(np.min(levels)), int(np.max(levels)) |
| 68 | + for old in [k for k in self._cache if k < lo or k > hi]: |
| 69 | + del self._cache[old] |
| 70 | + if self._max is not None and len(self._cache) > self._max: |
| 71 | + for old in sorted(self._cache)[: len(self._cache) - self._max]: |
| 72 | + del self._cache[old] |
| 73 | + |
| 74 | + # -- intercepted indexing ------------------------------------------------- |
| 75 | + def isel(self, indexers: dict | None = None, **kwargs): |
| 76 | + sel = dict(indexers) if indexers is not None else {} |
| 77 | + sel.update({k: v for k, v in kwargs.items() if k not in _NON_INDEXER_KWARGS}) |
| 78 | + |
| 79 | + # no time selection -> nothing to window; preserve control kwargs |
| 80 | + if self._tdim not in sel: |
| 81 | + return self._data.isel(indexers, **kwargs) |
| 82 | + |
| 83 | + t_ind = sel[self._tdim] |
| 84 | + t_vals = np.asarray(t_ind.values if isinstance(t_ind, xr.DataArray) else t_ind) |
| 85 | + levels = np.unique(t_vals) |
| 86 | + if levels.size == 0: |
| 87 | + # empty selection (e.g. a kernel evaluating an empty particle subset): |
| 88 | + # nothing to load or evict; gather from an empty NumPy block below |
| 89 | + block = np.empty((0, *self._data.shape[1:]), dtype=self._data.dtype) |
| 90 | + else: |
| 91 | + self._ensure(levels) |
| 92 | + # stack the resident levels into one small NumPy block; remap to local indices |
| 93 | + block = np.stack([self._cache[int(lvl)] for lvl in levels]) # (nlevels, *rest) |
| 94 | + nda = xr.DataArray(block, dims=self._data.dims) # NumPy-backed, original dim order |
| 95 | + local = np.searchsorted(levels, t_vals) |
| 96 | + sel[self._tdim] = xr.DataArray(local, dims=getattr(t_ind, "dims", ())) |
| 97 | + return nda.isel(sel) # plain vectorised gather in NumPy (no ignore_grid needed) |
| 98 | + |
| 99 | + |
| 100 | +def maybe_windowed(data: xr.DataArray, max_levels: int | None = None): |
| 101 | + """Wrap dask-backed, field data in a ``WindowedArray``; else pass through. |
| 102 | +
|
| 103 | + NumPy-backed fields (already resident) and fields without a leading ``time`` |
| 104 | + dimension are returned unchanged, so existing eager workflows are unaffected. |
| 105 | + Already-wrapped data is returned unchanged. |
| 106 | + """ |
| 107 | + if isinstance(data, WindowedArray): |
| 108 | + return data |
| 109 | + if data.dims and data.dims[0] == "time" and is_dask_collection(data.data): |
| 110 | + return WindowedArray(data, max_levels=max_levels) |
| 111 | + return data |
0 commit comments