Skip to content

Commit be3d74f

Browse files
Carry over windowed_array implementation to new main branch with "model" concept
1 parent 32a7f2a commit be3d74f

5 files changed

Lines changed: 268 additions & 1 deletion

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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+
"""
10+
11+
from __future__ import annotations
12+
13+
import numpy as np
14+
import xarray as xr
15+
from dask import is_dask_collection
16+
17+
# xarray / uxarray ``isel`` keyword arguments that are NOT dimension indexers.
18+
_NON_INDEXER_KWARGS = frozenset({"drop", "missing_dims", "ignore_grid"})
19+
20+
21+
class WindowedArray:
22+
"""Wrap a lazy DataArray so ``isel`` loads/caches/evicts time levels as NumPy."""
23+
24+
def __init__(self, data: xr.DataArray, time_dim: str = "time", max_levels: int | None = None):
25+
if data.dims[0] != time_dim:
26+
raise ValueError(f"WindowedArray expects {time_dim!r} as the leading dimension, got {data.dims}")
27+
self._data = data
28+
self._tdim = time_dim
29+
self._cache: dict[int, np.ndarray] = {} # time index -> NumPy slab (remaining dims)
30+
self._max = max_levels
31+
# diagnostics
32+
self.loads = 0
33+
self.bytes_read = 0
34+
self._slab_bytes = int(np.prod(data.isel({time_dim: 0}).shape)) * data.dtype.itemsize
35+
36+
# -- transparency: forward everything we don't override -------------------
37+
def __getattr__(self, name):
38+
# __getattr__ only fires for misses; reach _data without recursing.
39+
return getattr(object.__getattribute__(self, "_data"), name)
40+
41+
def __repr__(self):
42+
return (
43+
f"WindowedArray(time_dim={self._tdim!r}, cached_levels={sorted(self._cache)}, "
44+
f"loads={self.loads})\n{self._data!r}"
45+
)
46+
47+
# -- window management ----------------------------------------------------
48+
def _read_level(self, lvl: int) -> np.ndarray:
49+
"""Bulk, sequential read of one time level into NumPy (the dask->NumPy step)."""
50+
return np.asarray(self._data.isel({self._tdim: int(lvl)}).values)
51+
52+
def _ensure(self, levels: np.ndarray) -> None:
53+
for lvl in levels:
54+
lvl = int(lvl)
55+
if lvl not in self._cache:
56+
self._cache[lvl] = self._read_level(lvl)
57+
self.loads += 1
58+
self.bytes_read += self._slab_bytes
59+
# retire stale levels (the clock only moves forward across the window)
60+
lo = int(np.min(levels))
61+
for old in [k for k in self._cache if k < lo]:
62+
del self._cache[old]
63+
if self._max is not None and len(self._cache) > self._max:
64+
for old in sorted(self._cache)[: len(self._cache) - self._max]:
65+
del self._cache[old]
66+
67+
# -- intercepted indexing -------------------------------------------------
68+
def isel(self, indexers: dict | None = None, **kwargs):
69+
sel = dict(indexers) if indexers is not None else {}
70+
sel.update({k: v for k, v in kwargs.items() if k not in _NON_INDEXER_KWARGS})
71+
72+
73+
# no time selection -> nothing to window; preserve control kwargs
74+
if self._tdim not in sel:
75+
return self._data.isel(indexers, **kwargs)
76+
77+
t_ind = sel[self._tdim]
78+
t_vals = np.asarray(t_ind.values if isinstance(t_ind, xr.DataArray) else t_ind)
79+
levels = np.unique(t_vals)
80+
self._ensure(levels)
81+
82+
# stack the resident levels into one small NumPy block; remap to local indices
83+
block = np.stack([self._cache[int(lvl)] for lvl in levels]) # (nlevels, *rest)
84+
nda = xr.DataArray(block, dims=self._data.dims) # NumPy-backed, original dim order
85+
local = np.searchsorted(levels, t_vals)
86+
sel[self._tdim] = xr.DataArray(local, dims=getattr(t_ind, "dims", ()))
87+
return nda.isel(sel) # plain vectorised gather in NumPy (no ignore_grid needed)
88+
89+
90+
def maybe_windowed(data: xr.DataArray, max_levels: int | None = None):
91+
"""Wrap dask-backed, field data in a ``WindowedArray``; else pass through.
92+
93+
NumPy-backed fields (already resident) and fields without a leading ``time``
94+
dimension are returned unchanged, so existing eager workflows are unaffected.
95+
Already-wrapped data is returned unchanged.
96+
"""
97+
if isinstance(data, WindowedArray):
98+
return data
99+
if data.dims and data.dims[0] == "time" and is_dask_collection(data.data):
100+
return WindowedArray(data, max_levels=max_levels)
101+
return data

src/parcels/_core/field.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def __init__(
100100

101101
@property
102102
def data(self):
103-
return self.model.data[self.name]
103+
return self.model.field_data(self.name)
104104

105105
@property
106106
def grid(self): # TODO PR: Remove in favour of referencing model grid directly

src/parcels/_core/fieldset.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,32 @@ def add_field(self, field: Field, name: str | None = None):
144144

145145
self.fields[name] = field
146146

147+
def to_windowed_arrays(self, *, max_levels: int | None = None):
148+
"""Wrap dask-backed field data in rolling time-window caches.
149+
150+
Opt-in optimization for forward-marching simulations where all particles
151+
share a single clock. Delegates to each underlying model; dask-backed,
152+
time-leading fields are served through a resident NumPy window (each time
153+
level loaded once and evicted as the clock advances) instead of re-reading
154+
chunks on every kernel step. NumPy-backed (eager) and non-time-leading
155+
fields are left unchanged, and re-invoking is idempotent, so this is safe
156+
to call more than once.
157+
158+
Parameters
159+
----------
160+
max_levels : int, optional
161+
Cap on the number of time levels kept resident per field. ``None``
162+
(default) retains every level the advancing clock still brackets.
163+
164+
Returns
165+
-------
166+
FieldSet
167+
``self``, to allow chaining.
168+
"""
169+
for model in self.models:
170+
model.to_windowed_arrays(max_levels=max_levels)
171+
return self
172+
147173
def add_constant_field(self, name: str, value, mesh: Mesh = "spherical"):
148174
"""Wrapper function to add a Field that is constant in space,
149175
useful e.g. when using constant horizontal diffusivity

src/parcels/_core/model.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import xarray as xr
99

1010
import parcels._sgrid as sgrid
11+
from parcels._core._windowed_array import maybe_windowed
1112
from parcels._core.basegrid import BaseGrid
1213
from parcels._core.field import Field, VectorField
1314
from parcels._core.utils.time import TimeInterval
@@ -58,6 +59,43 @@ def assert_valid_model_data(self) -> None:
5859
raise e
5960
return
6061

62+
def field_data(self, name: str) -> Any:
63+
"""Return the array backing field ``name``.
64+
65+
Normally this is the ``xr.DataArray`` held in the dataset. After
66+
:meth:`to_windowed_arrays`, dask-backed fields are served through a
67+
cached :class:`~parcels._core._windowed_array.WindowedArray` instead.
68+
"""
69+
windowed = self.__dict__.get("_windowed")
70+
if windowed is not None and name in windowed:
71+
return windowed[name]
72+
return self.data[name]
73+
74+
def to_windowed_arrays(self, *, max_levels: int | None = None) -> Self:
75+
"""Wrap dask-backed field data in rolling time-window caches.
76+
77+
Opt-in optimization for forward-marching simulations where all particles
78+
share a single clock. For each dask-backed, time-leading field, ``isel``
79+
then samples a resident NumPy window (each time level loaded once and
80+
evicted as the clock advances) instead of re-reading chunks and paying the
81+
dask scheduling overhead on every kernel step. NumPy-backed (eager) fields
82+
and non-time-leading fields are left unchanged.
83+
84+
Idempotent: re-invoking reuses the existing wrapper (and its warm cache)
85+
rather than rebuilding it.
86+
87+
Parameters
88+
----------
89+
max_levels : int, optional
90+
Cap on the number of time levels kept resident per field. ``None``
91+
(default) retains every level the advancing clock still brackets.
92+
"""
93+
windowed = self.__dict__.setdefault("_windowed", {})
94+
for name in self.scalar_field_names:
95+
current = windowed.get(name, self.data[name])
96+
windowed[name] = maybe_windowed(current, max_levels=max_levels)
97+
return self
98+
6199
@property
62100
def time_interval(self) -> TimeInterval | None:
63101
try:

tests/test_windowed_array.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for the transparent rolling time-window cache (WindowedArray)."""
2+
3+
import dask.array as da
4+
import numpy as np
5+
import pytest
6+
import xarray as xr
7+
8+
from parcels import FieldSet, ParticleSet
9+
from parcels._core._windowed_array import WindowedArray, maybe_windowed
10+
from parcels._datasets.structured.generated import simple_UV_dataset
11+
from parcels.kernels import AdvectionRK2
12+
13+
14+
def test_windowed_isel_matches_dask_loads_once_and_evicts():
15+
"""WindowedArray.isel must equal dask isel, load each level once, keep <=2 resident."""
16+
ntime, n, npart = 20, 64, 200
17+
rng = np.random.default_rng(0)
18+
base = rng.standard_normal((ntime, 3, n, n))
19+
lazy = xr.DataArray(da.from_array(base, chunks=(1, 3, n, n)), dims=("time", "depth", "lat", "lon"))
20+
win = WindowedArray(lazy)
21+
22+
worst, max_cache = 0.0, 0
23+
for step in range(40):
24+
ti = min(step // 2, ntime - 2) # advancing clock, 2 sub-steps per level
25+
yi, xi = rng.integers(0, n, npart), rng.integers(0, n, npart)
26+
zi = np.zeros(npart, dtype=int)
27+
sel = dict(
28+
time=xr.DataArray(np.r_[np.full(npart, ti), np.full(npart, ti + 1)], dims="p"),
29+
depth=xr.DataArray(np.r_[zi, zi], dims="p"),
30+
lat=xr.DataArray(np.r_[yi, yi], dims="p"),
31+
lon=xr.DataArray(np.r_[xi, xi], dims="p"),
32+
)
33+
got = win.isel(sel).data
34+
ref = lazy.isel(sel).data.compute()
35+
worst = max(worst, float(np.abs(got - ref).max()))
36+
max_cache = max(max_cache, len(win._cache))
37+
38+
assert worst == 0.0 # byte-identical to dask
39+
assert win.loads == ntime # each time level read exactly once
40+
assert max_cache <= 2 # only the bracketing levels resident
41+
42+
43+
def test_to_windowed_arrays_wraps_dask_but_not_numpy():
44+
ds = simple_UV_dataset(mesh="flat")
45+
fs_np = FieldSet.from_sgrid_conventions(ds, mesh="flat")
46+
fs_dk = FieldSet.from_sgrid_conventions(ds.chunk({"time": 1}), mesh="flat")
47+
48+
# construction is never windowing -- it is opt-in via the fieldset method
49+
assert not isinstance(fs_np.U.data, WindowedArray)
50+
assert not isinstance(fs_dk.U.data, WindowedArray)
51+
52+
assert fs_np.to_windowed_arrays() is fs_np # chainable
53+
fs_dk.to_windowed_arrays()
54+
55+
# numpy-backed field is left eager; dask-backed field gets wrapped
56+
assert not isinstance(fs_np.U.data, WindowedArray)
57+
assert isinstance(fs_dk.U.data, WindowedArray)
58+
# transparency: forwarded attributes still behave like the DataArray
59+
assert fs_dk.U.data.dims == fs_np.U.data.dims
60+
assert fs_dk.U.data.shape == fs_np.U.data.shape
61+
62+
63+
def test_to_windowed_arrays_is_idempotent_and_forwards_max_levels():
64+
ds = simple_UV_dataset(mesh="flat")
65+
fs = FieldSet.from_sgrid_conventions(ds.chunk({"time": 1}), mesh="flat")
66+
67+
fs.to_windowed_arrays(max_levels=3)
68+
first = fs.U.data
69+
assert isinstance(first, WindowedArray)
70+
assert first._max == 3
71+
72+
# re-wrapping returns the same object (idempotent, warm cache preserved)
73+
fs.to_windowed_arrays(max_levels=3)
74+
assert fs.U.data is first
75+
76+
77+
def test_maybe_windowed_passthrough_for_non_time_leading():
78+
da_no_time = xr.DataArray(da.zeros((3, 4), chunks=(3, 4)), dims=("lat", "lon"))
79+
assert maybe_windowed(da_no_time) is da_no_time # not wrapped (no leading time dim)
80+
81+
82+
@pytest.mark.parametrize("mesh", ["flat", "spherical"])
83+
def test_dask_advection_matches_numpy(mesh):
84+
"""An identical advection must give identical trajectories whether the field
85+
is numpy-backed or dask-backed (windowed).
86+
"""
87+
ds = simple_UV_dataset(mesh=mesh)
88+
ds["U"].data[:] = 1.0 # steady zonal flow -> in-bounds, deterministic
89+
90+
def run(chunked):
91+
d = ds.chunk({"time": 1}) if chunked else ds
92+
fs = FieldSet.from_sgrid_conventions(d, mesh=mesh)
93+
if chunked:
94+
fs.to_windowed_arrays()
95+
pset = ParticleSet(fs, lon=np.zeros(10), lat=np.linspace(-10, 10, 10))
96+
pset.execute(AdvectionRK2, runtime=7200, dt=np.timedelta64(15, "m"))
97+
return np.array(pset.lon), np.array(pset.lat)
98+
99+
lon_np, lat_np = run(False)
100+
lon_dk, lat_dk = run(True)
101+
np.testing.assert_allclose(lon_dk, lon_np, atol=1e-9)
102+
np.testing.assert_allclose(lat_dk, lat_np, atol=1e-9)

0 commit comments

Comments
 (0)