@@ -49,6 +49,7 @@ def get_slice(self, timestamp):
4949from __future__ import annotations
5050
5151import abc
52+ import threading
5253from typing import Optional , Union
5354
5455import 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