Skip to content

Commit 1e99fbc

Browse files
committed
feat: Implement async prefetching in BufferedSlicingReader for smoother playback and enhance test coverage
1 parent e992ba3 commit 1e99fbc

5 files changed

Lines changed: 354 additions & 78 deletions

File tree

dvue/animator/multi_ui.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -702,12 +702,6 @@ def __init__(
702702
def collect_state(self) -> dict:
703703
"""Return a complete dict representing the current UI state + metadata."""
704704
meta = self._animate_meta
705-
cli_keys = meta.get("_transform_cli_keys", {})
706-
transform_display = (
707-
self._transform_select.value
708-
if self._transform_options
709-
else "none"
710-
)
711705
state: dict = {
712706
"version": 1,
713707
"mode": meta.get("mode", "multi"),
@@ -718,7 +712,6 @@ def collect_state(self) -> dict:
718712
"shapefile": meta.get("shapefile"),
719713
"shapefile_b": meta.get("shapefile_b"),
720714
"channel_id_column": meta.get("channel_id_column"),
721-
"transform": cli_keys.get(transform_display, "none"),
722715
"colormap": self.colormap,
723716
"vmin": self.vmin,
724717
"vmax": self.vmax,
@@ -791,7 +784,9 @@ def _setup_reader(self, base: SlicingReader, transform_name: str) -> SlicingRead
791784
else:
792785
from .reader import TransformedSlicingReader as _TFR
793786
reader = _TFR(reader, spec_or_fn)
794-
return BufferedSlicingReader(reader, chunk_size=self._buffer_chunk_size)
787+
return BufferedSlicingReader(
788+
reader, chunk_size=self._buffer_chunk_size, prefetch=True
789+
)
795790

796791
def _get_diff_reader(self) -> SlicingReader:
797792
"""Return a buffered DiffSlicingReader (constructed lazily, cached)."""
@@ -800,7 +795,10 @@ def _get_diff_reader(self) -> SlicingReader:
800795
self._base_reader_a, self._base_reader_b,
801796
)
802797
return BufferedSlicingReader(
803-
self._diff_reader_cache, chunk_size=self._buffer_chunk_size)
798+
self._diff_reader_cache,
799+
chunk_size=self._buffer_chunk_size,
800+
prefetch=True,
801+
)
804802

805803
# ------------------------------------------------------------------
806804
# Internal helpers
@@ -1256,11 +1254,13 @@ def _refresh_contours(self) -> None:
12561254
def _on_transform_change(self, event: param.parameterized.Event) -> None:
12571255
"""Apply a new transform to both readers, showing a loading indicator."""
12581256
import threading
1257+
import logging as _log
1258+
1259+
new_name = event.new
12591260

12601261
current_ts = pd.Timestamp(
12611262
self._reader_a.time_index[self._time_slider.value]
12621263
)
1263-
new_name = event.new
12641264

12651265
# Show loading spinner on all three map panes immediately.
12661266
for pane in (self._pane_a, self._pane_b, self._pane_diff):

dvue/animator/reader.py

Lines changed: 141 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ def get_slice(self, timestamp):
4949
from __future__ import annotations
5050

5151
import abc
52+
import threading
5253
from typing import Optional, Union
5354

5455
import numpy as np
@@ -276,30 +277,60 @@ class BufferedSlicingReader(SlicingReader):
276277
a new chunk is loaded via :meth:`~SlicingReader.get_slice_range` — a
277278
single I/O call that reads all steps at once (crucial for HDF5).
278279
280+
Prefetching (double-buffering)
281+
------------------------------
282+
When ``prefetch=True`` the reader keeps a **second** buffer holding the
283+
chunk *adjacent* to the current one in the direction of playback. As the
284+
cursor nears the edge of the current chunk a daemon thread loads the next
285+
chunk via :meth:`~SlicingReader.get_slice_range` **off the calling
286+
thread**, so the (potentially slow) HDF5 read + transform never blocks the
287+
UI/IOLoop. When the cursor crosses into the prefetched range the second
288+
buffer is promoted to current with no I/O on the calling thread.
289+
290+
This is the key to smooth playback for streaming-transform stacks
291+
(e.g. Godin → daily): without it, every chunk boundary forces a multi-
292+
second synchronous filter computation on the IOLoop, freezing the browser
293+
"every few days" of model time. A synchronous load is still used for the
294+
cold start and for random seeks (DatetimePicker jumps) that land outside
295+
both buffers.
296+
279297
Parameters
280298
----------
281299
reader : SlicingReader
282300
Underlying reader (e.g., :class:`~dsm2ui.animate.HydroH5FlowReader`).
283301
chunk_size : int, optional
284302
Number of time steps to buffer at once. Default ``200``.
285303
refill_margin : float, optional
286-
Fraction of *chunk_size* from either edge that triggers a refill.
287-
Default ``0.15`` — refill when within 30 steps of the edge for a
288-
200-step chunk.
304+
Fraction of *chunk_size* from either edge that triggers a refill (or,
305+
in prefetch mode, a background prefetch). Default ``0.15`` — act when
306+
within 30 steps of the edge for a 200-step chunk.
307+
prefetch : bool, optional
308+
Enable asynchronous double-buffering. Default ``False`` (preserves the
309+
original synchronous behaviour for backward compatibility). UI layers
310+
that drive playback should pass ``True``.
289311
"""
290312

291313
def __init__(
292314
self,
293315
reader: SlicingReader,
294316
chunk_size: int = 200,
295317
refill_margin: float = 0.15,
318+
prefetch: bool = False,
296319
) -> None:
297320
self._inner = reader
298321
self._chunk_size = chunk_size
299322
self._margin = max(1, int(chunk_size * refill_margin))
323+
self._prefetch = bool(prefetch)
300324
self._buf: Optional[pd.DataFrame] = None # shape (chunk, geo_ids)
301325
self._buf_start: int = 0
302326
self._buf_end: int = 0
327+
# Prefetch (double-buffer) state — guarded by ``_lock``.
328+
self._next_buf: Optional[pd.DataFrame] = None
329+
self._next_start: int = 0
330+
self._next_end: int = 0
331+
self._prefetching: bool = False
332+
self._last_idx: int = 0
333+
self._lock = threading.Lock()
303334
super().__init__(reader.time_index)
304335

305336
@property
@@ -314,33 +345,129 @@ def vmax(self) -> float:
314345
# Internal helpers
315346
# ------------------------------------------------------------------
316347

317-
def _load_chunk(self, center_idx: int) -> None:
348+
def _chunk_bounds(self, center_idx: int) -> tuple:
349+
"""Return clamped ``(start, end)`` for a chunk centred on *center_idx*."""
318350
n = len(self._time_index)
319351
half = self._chunk_size // 2
320352
start = max(0, center_idx - half)
321353
end = min(n, start + self._chunk_size)
322354
start = max(0, end - self._chunk_size) # re-clamp if at end
355+
return start, end
356+
357+
def _load_chunk(self, center_idx: int) -> None:
358+
"""Synchronously load the chunk centred on *center_idx* into ``_buf``."""
359+
start, end = self._chunk_bounds(center_idx)
323360
self._buf = self._inner.get_slice_range(start, end)
324361
self._buf_start = start
325362
self._buf_end = end
326363

327364
def _needs_refill(self, idx: int) -> bool:
328-
return (
329-
self._buf is None
330-
or idx < self._buf_start
331-
or idx >= self._buf_end
332-
or idx < self._buf_start + self._margin
333-
or idx >= self._buf_end - self._margin
334-
)
365+
"""True when a (synchronous) reload is required for *idx*.
366+
367+
A reload is needed when *idx* is outside the current buffer, or within
368+
``_margin`` of an edge **that is not the file boundary** (there is more
369+
data to load in that direction). Being within the margin of a clamped
370+
file edge does *not* trigger a reload — that previously caused the same
371+
boundary chunk to be re-read on every frame near the start/end of the
372+
dataset.
373+
"""
374+
if self._buf is None or idx < self._buf_start or idx >= self._buf_end:
375+
return True
376+
n = len(self._time_index)
377+
if idx < self._buf_start + self._margin and self._buf_start > 0:
378+
return True
379+
if idx >= self._buf_end - self._margin and self._buf_end < n:
380+
return True
381+
return False
382+
383+
# ------------------------------------------------------------------
384+
# Prefetch (double-buffer) helpers
385+
# ------------------------------------------------------------------
386+
387+
def _try_promote(self, idx: int) -> bool:
388+
"""Promote the prefetched buffer to current when it covers *idx*."""
389+
with self._lock:
390+
if (
391+
self._next_buf is not None
392+
and self._next_start <= idx < self._next_end
393+
):
394+
self._buf = self._next_buf
395+
self._buf_start = self._next_start
396+
self._buf_end = self._next_end
397+
self._next_buf = None
398+
return True
399+
return False
400+
401+
def _clear_next(self) -> None:
402+
"""Discard any prefetched buffer (e.g. after a random seek)."""
403+
with self._lock:
404+
self._next_buf = None
405+
406+
def _maybe_prefetch(self, idx: int, direction: int) -> None:
407+
"""Start a background load of the adjacent chunk when near the edge."""
408+
n = len(self._time_index)
409+
if direction >= 0:
410+
# Travelling forward — prefetch the chunk after the current one.
411+
if idx < self._buf_end - self._margin or self._buf_end >= n:
412+
return
413+
target_start = self._buf_end
414+
else:
415+
# Travelling backward — prefetch the chunk before the current one.
416+
if idx >= self._buf_start + self._margin or self._buf_start <= 0:
417+
return
418+
target_start = max(0, self._buf_start - self._chunk_size)
419+
420+
with self._lock:
421+
if self._prefetching:
422+
return
423+
if self._next_buf is not None and self._next_start == target_start:
424+
return # already prefetched
425+
self._prefetching = True
426+
threading.Thread(
427+
target=self._prefetch_worker, args=(target_start,), daemon=True
428+
).start()
429+
430+
def _prefetch_worker(self, target_start: int) -> None:
431+
"""Load a chunk in the background and store it as ``_next_buf``."""
432+
chunk = None
433+
start = end = 0
434+
try:
435+
n = len(self._time_index)
436+
start = max(0, min(target_start, n))
437+
end = min(n, start + self._chunk_size)
438+
start = max(0, end - self._chunk_size)
439+
chunk = self._inner.get_slice_range(start, end)
440+
except Exception:
441+
chunk = None
442+
with self._lock:
443+
if chunk is not None:
444+
self._next_buf = chunk
445+
self._next_start = start
446+
self._next_end = end
447+
self._prefetching = False
335448

336449
# ------------------------------------------------------------------
337450
# SlicingReader interface
338451
# ------------------------------------------------------------------
339452

340453
def get_slice(self, timestamp: pd.Timestamp) -> pd.Series:
341-
idx = self._time_index.get_indexer([timestamp], method="nearest")[0]
342-
if self._needs_refill(idx):
343-
self._load_chunk(idx)
454+
idx = int(self._time_index.get_indexer([timestamp], method="nearest")[0])
455+
if not self._prefetch:
456+
if self._needs_refill(idx):
457+
self._load_chunk(idx)
458+
return self._buf.iloc[idx - self._buf_start].astype(float)
459+
460+
# ----- prefetch mode (called from a single consumer thread) -----
461+
if self._buf is None or not (self._buf_start <= idx < self._buf_end):
462+
# Try the prefetched buffer first; fall back to a synchronous load
463+
# for cold start / random seeks that land outside both buffers.
464+
if not self._try_promote(idx):
465+
self._load_chunk(idx)
466+
self._clear_next()
467+
468+
direction = 1 if idx >= self._last_idx else -1
469+
self._last_idx = idx
470+
self._maybe_prefetch(idx, direction)
344471
return self._buf.iloc[idx - self._buf_start].astype(float)
345472

346473
def get_slice_range(self, start_idx: int, end_idx: int) -> pd.DataFrame:

0 commit comments

Comments
 (0)