Skip to content

Commit e992ba3

Browse files
committed
feat: Enhance GeoAnimatorManager to handle transformed data and prevent spurious events
1 parent bbe70ad commit e992ba3

2 files changed

Lines changed: 77 additions & 9 deletions

File tree

dvue/animator/reader.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,16 @@ def get_slice_range(self, start_out: int, end_out: int) -> pd.DataFrame:
867867
raw_end = int(raw_ti.searchsorted(raw_upper_ts, side="left"))
868868
raw_end = min(n_raw, raw_end)
869869

870+
# Extend the raw fetch by *_overlap* steps on each side.
871+
# For simple resamples _overlap == 0 so there is no change.
872+
# For composed transforms such as "Rolling 14 D → Daily mean" the
873+
# rolling window needs raw context beyond the output window before
874+
# the aggregate step is applied; without it the boundary output
875+
# days are computed from fewer than 14 days of raw data.
876+
if self._overlap > 0:
877+
raw_start = max(0, raw_start - self._overlap)
878+
raw_end = min(n_raw, raw_end + self._overlap)
879+
870880
if raw_start >= raw_end:
871881
return pd.DataFrame(
872882
np.nan,

dvue/animator/ui.py

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,8 @@ def __init__(
486486
self._transform_options = transform_options or {}
487487
self._buffer_chunk_size = buffer_chunk_size
488488
self._reader = self._setup_reader(initial_transform)
489+
self._initial_transform = initial_transform # for spurious-event guard
490+
self._transform_init_done = False # becomes True after first real change
489491
self._geo_id_column = geo_id_column
490492
self._title = title
491493
self._map_height = map_height
@@ -541,7 +543,7 @@ def __init__(
541543
if init_vmin == init_vmax:
542544
init_vmax = init_vmin + 1.0
543545

544-
init_series = reader.get_slice(reader.time_index[0])
546+
init_series = self._reader.get_slice(self._reader.time_index[0])
545547
init_values = [
546548
float(init_series.get(gid, np.nan)) for gid in self._geo_ids
547549
]
@@ -601,7 +603,7 @@ def __init__(
601603
sizing_mode="stretch_both",
602604
min_height=map_height,
603605
title=f"{title + ' \u2014 ' if title else ''}"
604-
f"{reader.time_index[0].strftime('%Y-%m-%d %H:%M')}",
606+
f"{self._reader.time_index[0].strftime('%Y-%m-%d %H:%M')}",
605607
tools="pan,wheel_zoom,box_zoom,reset,save",
606608
active_scroll="wheel_zoom",
607609
)
@@ -699,12 +701,16 @@ def __init__(
699701
# ----------------------------------------------------------------
700702
# 8. Control widgets.
701703
#
702-
# Time slider: DiscreteSlider whose options are the actual datetime
703-
# strings. The user sees readable timestamps instead of integers.
704-
# The slider value is the timestamp string; the index is derived
705-
# from the options list position inside _on_slider_change.
704+
# Use self._reader.time_index (the potentially-transformed reader)
705+
# rather than the raw 'reader' parameter so the slider options and
706+
# DatetimePicker range reflect the ACTUAL output time resolution.
707+
# When initial_transform is "none" these are identical; when it is
708+
# e.g. "Rolling 14 D → Daily mean" self._reader has a daily
709+
# time_index while 'reader' has the raw hourly one. Using the
710+
# wrong one here is the root cause of the "slider index N out of
711+
# range [0, M)" warnings when loading from a saved config.
706712
# ----------------------------------------------------------------
707-
ti = reader.time_index
713+
ti = self._reader.time_index
708714
# DiscretePlayer with integer indices 0..N-1 — compact options list,
709715
# no per-step string serialisation overhead. Built-in play/pause/
710716
# loop controls drive the animation; the Bokeh Div shows the resolved
@@ -1117,7 +1123,27 @@ def _on_slider_change(self, event: param.parameterized.Event) -> None:
11171123
if self._syncing:
11181124
return
11191125
idx = int(event.new)
1120-
ts = self._reader.time_index[idx]
1126+
ti = self._reader.time_index
1127+
if len(ti) == 0:
1128+
return
1129+
if not (0 <= idx < len(ti)):
1130+
import logging as _log
1131+
_log.getLogger(__name__).warning(
1132+
"GeoAnimatorManager: slider index %d is out of range "
1133+
"[0, %d) — stale browser session value; resetting to 0. "
1134+
"This typically happens when a browser tab reconnects to a "
1135+
"new server session that has a shorter dataset.",
1136+
idx, len(ti),
1137+
)
1138+
# Snap to the beginning and push that value back to the browser
1139+
# so the slider thumb and the displayed timestamp are in sync.
1140+
idx = 0
1141+
self._syncing = True
1142+
try:
1143+
self._time_slider.value = idx
1144+
finally:
1145+
self._syncing = False
1146+
ts = ti[idx]
11211147
ts_str = ts.strftime("%Y-%m-%d %H:%M")
11221148
# Sync DatetimePicker without causing a feedback loop.
11231149
self._syncing = True
@@ -1340,11 +1366,43 @@ def _on_transform_change(self, event: param.parameterized.Event) -> None:
13401366
while the computation is in progress.
13411367
"""
13421368
import threading
1369+
import logging as _log
1370+
1371+
new_name = event.new
1372+
1373+
# Guard against the spurious "none" event that Panel fires the first
1374+
# time the browser connects to sync widget state. When loading from a
1375+
# saved config (initial_transform != "none"), the Bokeh Select model
1376+
# briefly reports value="none" (the first option / default) before the
1377+
# Python-set value propagates to the browser. Applying this spurious
1378+
# "none" would silently reset the reader to the raw (untransformed)
1379+
# data — the symptom reported as "doing none transform on init".
1380+
#
1381+
# We suppress this ONLY once (first call) when the incoming value is
1382+
# "none" but the initially configured transform was non-"none". All
1383+
# subsequent "none" selections (user-initiated) are applied normally.
1384+
if (not self._transform_init_done
1385+
and new_name == "none"
1386+
and self._initial_transform not in (None, "", "none")):
1387+
_log.getLogger(__name__).debug(
1388+
"_on_transform_change: suppressing spurious 'none' event on "
1389+
"browser connect (initial_transform=%r); restoring widget label",
1390+
self._initial_transform,
1391+
)
1392+
self._transform_init_done = True # set before restore to skip guard on re-entry
1393+
# Restore the widget label so the dropdown shows the correct
1394+
# transform name. Setting this value triggers a recursive call to
1395+
# _on_transform_change with initial_transform (guard is skipped
1396+
# because _transform_init_done is now True), which rebuilds the
1397+
# reader under the document lock and loads frame 0 — making the
1398+
# data, the slider options, and the dropdown label all consistent.
1399+
self._transform_select.value = self._initial_transform
1400+
return
1401+
self._transform_init_done = True
13431402

13441403
current_ts = pd.Timestamp(
13451404
self._reader.time_index[self._time_slider.value]
13461405
)
1347-
new_name = event.new
13481406

13491407
# Show loading indicator immediately (Panel param — safe from watcher).
13501408
self._chart_pane.loading = True

0 commit comments

Comments
 (0)