Skip to content

Commit 84463aa

Browse files
Merge branch 'main' into support_some_fields_without_time_in_fieldset
2 parents 3d9cc83 + 5a0df96 commit 84463aa

6 files changed

Lines changed: 355 additions & 4 deletions

File tree

docs/user_guide/examples/tutorial_Argofloats.ipynb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,6 @@
107107
" \"CopernicusMarine_data_for_Argo_tutorial/data\"\n",
108108
")\n",
109109
"\n",
110-
"# TODO check how we can get good performance without loading full dataset in memory\n",
111-
"ds_fields.load() # load the dataset into memory\n",
112-
"\n",
113110
"# Select fields\n",
114111
"fields = {\n",
115112
" \"U\": ds_fields[\"uo\"],\n",
@@ -120,6 +117,7 @@
120117
"# Convert to SGRID-compliant dataset and create FieldSet\n",
121118
"ds_fset = parcels.convert.copernicusmarine_to_sgrid(fields=fields)\n",
122119
"fieldset = parcels.FieldSet.from_sgrid_conventions(ds_fset)\n",
120+
"fieldset.to_windowed_arrays()\n",
123121
"\n",
124122
"# Define a new Particle type including extra Variables\n",
125123
"ArgoParticle = parcels.Particle.add_variable(\n",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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

src/parcels/_core/field.py

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

102102
@property
103103
def data(self):
104-
return self.model.data[self.name]
104+
return self.model.field_data(self.name)
105105

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

src/parcels/_core/fieldset.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,39 @@ def add_field(self, field: Field, name: str | None = None):
152152

153153
self.fields[name] = field
154154

155+
def to_windowed_arrays(self, *, max_levels: int | None = None):
156+
"""Wrap dask-backed field data in rolling time-window caches.
157+
158+
Opt-in optimization for forward-marching simulations where all particles
159+
share a single clock. Delegates to each underlying model; dask-backed,
160+
time-leading fields are served through a resident NumPy window (each time
161+
level loaded once and evicted as the clock advances) instead of re-reading
162+
chunks on every kernel step. NumPy-backed (eager) and non-time-leading
163+
fields are left unchanged, and re-invoking is idempotent, so this is safe
164+
to call more than once.
165+
166+
Parameters
167+
----------
168+
max_levels : int, optional
169+
Hard cap on the number of time levels kept resident per field.
170+
With the default ``None``, each interpolation call decides what
171+
stays resident: the cache keeps exactly the span of time indices
172+
that call requests and evicts every level outside it. During time
173+
integration particles bracket the current time between two
174+
adjacent levels, so the default keeps at most two levels resident.
175+
Only when a single call requests a wider time span (e.g. particles
176+
spread across many time levels) does the window grow beyond that,
177+
and ``max_levels`` then bounds its size.
178+
179+
Returns
180+
-------
181+
FieldSet
182+
``self``, to allow chaining.
183+
"""
184+
for model in self.models:
185+
model.to_windowed_arrays(max_levels=max_levels)
186+
return self
187+
155188
def add_constant_field(self, name: str, value, mesh: ptyping.Mesh = "spherical"):
156189
"""Wrapper function to add a Field that is constant in space,
157190
useful e.g. when using constant horizontal diffusivity

src/parcels/_core/model.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import parcels._sgrid as sgrid
1212
import parcels._typing as ptyping
13+
from parcels._core._windowed_array import maybe_windowed
1314
from parcels._core.basegrid import BaseGrid
1415
from parcels._core.field import Field, VectorField
1516
from parcels._core.utils.time import TimeInterval
@@ -62,6 +63,50 @@ def assert_valid_model_data(self) -> None:
6263
raise e
6364
return
6465

66+
def field_data(self, name: str) -> Any:
67+
"""Return the array backing field ``name``.
68+
69+
Normally this is the ``xr.DataArray`` held in the dataset. After
70+
:meth:`to_windowed_arrays`, dask-backed fields are served through a
71+
cached :class:`~parcels._core._windowed_array.WindowedArray` instead.
72+
"""
73+
windowed = self.__dict__.get("_windowed")
74+
if windowed is not None and name in windowed:
75+
return windowed[name]
76+
return self.data[name]
77+
78+
def to_windowed_arrays(self, *, max_levels: int | None = None) -> Self:
79+
"""Wrap dask-backed field data in rolling time-window caches.
80+
81+
Opt-in optimization for forward-marching simulations where all particles
82+
share a single clock. For each dask-backed, time-leading field, ``isel``
83+
then samples a resident NumPy window (each time level loaded once and
84+
evicted as the clock advances) instead of re-reading chunks and paying the
85+
dask scheduling overhead on every kernel step. NumPy-backed (eager) fields
86+
and non-time-leading fields are left unchanged.
87+
88+
Idempotent: re-invoking reuses the existing wrapper (and its warm cache)
89+
rather than rebuilding it.
90+
91+
Parameters
92+
----------
93+
max_levels : int, optional
94+
Hard cap on the number of time levels kept resident per field.
95+
With the default ``None``, each interpolation call decides what
96+
stays resident: the cache keeps exactly the span of time indices
97+
that call requests and evicts every level outside it. During time
98+
integration particles bracket the current time between two
99+
adjacent levels, so the default keeps at most two levels resident.
100+
Only when a single call requests a wider time span (e.g. particles
101+
spread across many time levels) does the window grow beyond that,
102+
and ``max_levels`` then bounds its size.
103+
"""
104+
windowed = self.__dict__.setdefault("_windowed", {})
105+
for name in self.scalar_field_names:
106+
current = windowed.get(name, self.data[name])
107+
windowed[name] = maybe_windowed(current, max_levels=max_levels)
108+
return self
109+
65110
@property
66111
def time_interval(self) -> TimeInterval | None:
67112
try:

0 commit comments

Comments
 (0)