Skip to content

Commit ba4e89f

Browse files
committed
feat: Introduce ResamplingSlicingReader and enhance transform handling in GeoAnimatorManager
1 parent 8a002ac commit ba4e89f

4 files changed

Lines changed: 228 additions & 72 deletions

File tree

dvue/animator/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
BufferedSlicingReader,
2323
TransformSpec,
2424
StreamingTransformedSlicingReader,
25+
ResamplingSlicingReader,
2526
DiffSlicingReader,
2627
RawSequentialBuffer,
2728
)
@@ -32,6 +33,7 @@
3233
"BufferedSlicingReader",
3334
"TransformSpec",
3435
"StreamingTransformedSlicingReader",
36+
"ResamplingSlicingReader",
3537
"DiffSlicingReader",
3638
"RawSequentialBuffer",
3739
"GeoAnimatorManager",

dvue/animator/multi_ui.py

Lines changed: 68 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
)
3838
from bokeh.plotting import figure as bk_figure
3939

40-
from .reader import SlicingReader, DiffSlicingReader, BufferedSlicingReader, RawSequentialBuffer
40+
from .reader import SlicingReader, DiffSlicingReader, BufferedSlicingReader, RawSequentialBuffer, \
41+
ResamplingSlicingReader
4142
from .ui import (
4243
CURATED_COLORMAPS,
4344
CURATED_COLORMAPS_WITH_SEP,
@@ -827,57 +828,62 @@ def _setup_reader(self, base: SlicingReader, transform_name: str) -> SlicingRead
827828
chunk_size = self._buffer_chunk_size
828829
min_chunk = 50
829830
max_chunk = 2000
831+
target_buf_s = 10.0
830832
if (
831833
transform_name
832834
and transform_name != "none"
833835
and transform_name in self._transform_options
834836
):
835837
spec_or_fn = self._transform_options[transform_name]
836-
if isinstance(spec_or_fn, TransformSpec):
837-
freq_nanos = 0
838+
if not isinstance(spec_or_fn, TransformSpec):
839+
raise TypeError(
840+
f"transform_options value for {transform_name!r} must be a "
841+
f"TransformSpec, got {type(spec_or_fn).__name__}. "
842+
"Use make_resample_transform(), make_moving_average_transform(), "
843+
"or make_godin_transform() from dsm2ui.animate."
844+
)
845+
freq_nanos = 0
846+
try:
847+
freq_nanos = int(
848+
pd.tseries.frequencies.to_offset(
849+
reader.time_index.freq
850+
).nanos
851+
)
852+
except (AttributeError, TypeError):
853+
pass
854+
855+
if spec_or_fn.kind == "aggregate":
856+
filter_spec = spec_or_fn.filter_spec
857+
if filter_spec is not None:
858+
try:
859+
raw_overlap = filter_spec.get_overlap(freq_nanos)
860+
except (AttributeError, TypeError):
861+
raw_overlap = 0
862+
if raw_overlap > 0:
863+
reader = RawSequentialBuffer(reader)
864+
reader = StreamingTransformedSlicingReader(reader, filter_spec)
865+
reader = ResamplingSlicingReader(
866+
reader,
867+
spec_or_fn.output_freq,
868+
spec_or_fn.resample_agg,
869+
)
870+
chunk_size = max(5, self._buffer_chunk_size // 20)
871+
min_chunk = 2
872+
max_chunk = 365
873+
target_buf_s = 30.0
874+
else:
838875
try:
839-
freq_nanos = int(
840-
pd.tseries.frequencies.to_offset(
841-
reader.time_index.freq
842-
).nanos
843-
)
844876
raw_overlap = spec_or_fn.get_overlap(freq_nanos)
845877
except (AttributeError, TypeError):
846878
raw_overlap = 0
847879
if raw_overlap > 0:
848880
reader = RawSequentialBuffer(reader)
849881
reader = StreamingTransformedSlicingReader(reader, spec_or_fn)
850-
# Scale chunk_size for coarse aggregate transforms so the number
851-
# of raw steps per chunk stays roughly constant. See the same
852-
# logic in GeoAnimatorManager._setup_reader for the rationale.
853-
if (
854-
spec_or_fn.kind == "aggregate"
855-
and spec_or_fn.output_freq is not None
856-
and freq_nanos > 0
857-
):
858-
try:
859-
out_ns = int(
860-
pd.tseries.frequencies.to_offset(
861-
spec_or_fn.output_freq
862-
).nanos
863-
)
864-
if out_ns > freq_nanos:
865-
ratio = out_ns // freq_nanos
866-
chunk_size = max(5, self._buffer_chunk_size // ratio)
867-
min_chunk = max(2, 50 // ratio)
868-
max_chunk = max(chunk_size, 2000 // ratio)
869-
except Exception:
870-
pass
871-
else:
872-
raise TypeError(
873-
f"transform_options value for {transform_name!r} must be a "
874-
f"TransformSpec, got {type(spec_or_fn).__name__}. "
875-
"Use make_resample_transform(), make_moving_average_transform(), "
876-
"or make_godin_transform() from dsm2ui.animate."
877-
)
882+
878883
return BufferedSlicingReader(
879884
reader, chunk_size=chunk_size, prefetch=True,
880885
adaptive=True, min_chunk_size=min_chunk, max_chunk_size=max_chunk,
886+
target_buffer_seconds=target_buf_s,
881887
)
882888

883889
def _get_diff_reader(self) -> SlicingReader:
@@ -1537,6 +1543,27 @@ def _compute() -> None:
15371543
for _tcb in self._extra_transform_callbacks:
15381544
_tcb(_new_spec)
15391545

1546+
# ── Pre-load both maps in the background thread ──────────────────
1547+
# _apply_frame (and its _update_map_a / _update_map_b calls)
1548+
# triggers cold-start HDF5 reads on the Tornado IOLoop, which
1549+
# freezes the browser for the duration of the chunk load.
1550+
# Pre-fetch both maps here, off the IOLoop, then pass the
1551+
# pre-computed values to _apply for pure Bokeh model mutations.
1552+
ts = ti[nearest_idx]
1553+
ts_str = ts.strftime("%Y-%m-%d %H:%M")
1554+
_geo_ids_a_snap = self._geo_ids_a
1555+
_geo_ids_b_snap = self._geo_ids_b
1556+
try:
1557+
_sa = new_reader_a.get_slice_nearest(ts)
1558+
vals_a = _sa.reindex(_geo_ids_a_snap).fillna(np.nan).tolist()
1559+
except Exception:
1560+
vals_a = [float("nan")] * len(_geo_ids_a_snap)
1561+
try:
1562+
_sb = new_reader_b.get_slice_nearest(ts)
1563+
vals_b = _sb.reindex(_geo_ids_b_snap).fillna(np.nan).tolist()
1564+
except Exception:
1565+
vals_b = [float("nan")] * len(_geo_ids_b_snap)
1566+
15401567
def _apply() -> None:
15411568
self._reader_a = new_reader_a
15421569
self._reader_b = new_reader_b
@@ -1552,9 +1579,12 @@ def _apply() -> None:
15521579
finally:
15531580
self._syncing = False
15541581

1555-
ts_str = ti[nearest_idx].strftime("%Y-%m-%d %H:%M")
15561582
self._time_div.text = f"<b>{ts_str}</b>"
1557-
self._apply_frame(nearest_idx, ts_str)
1583+
# Pure Bokeh mutations — no I/O on the IOLoop.
1584+
self._render_map_a_vals(ts, ts_str, vals_a)
1585+
self._render_map_b_vals(ts, ts_str, vals_b)
1586+
for _cb in self._extra_frame_callbacks:
1587+
_cb(ts)
15581588

15591589
for pane in (self._pane_a, self._pane_b, self._pane_diff):
15601590
pane.loading = False

dvue/animator/reader.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,13 +710,19 @@ class TransformSpec:
710710
:func:`~dsm2ui.animate.make_godin_transform` for production examples.
711711
"""
712712

713-
def __init__(self, transform_fn, kind: str, get_overlap, output_freq=None):
713+
def __init__(self, transform_fn, kind: str, get_overlap, output_freq=None,
714+
filter_spec=None, resample_agg="mean"):
714715
if kind not in ("convolution", "aggregate"):
715716
raise ValueError(f"kind must be 'convolution' or 'aggregate', got {kind!r}")
716717
self.transform_fn = transform_fn
717718
self.kind = kind
718719
self.get_overlap = get_overlap # callable(freq_nanos: int) -> int
719720
self.output_freq = output_freq
721+
# Optional: for kind="aggregate", carry the convolution pre-filter spec
722+
# so _setup_reader can stack STSR(filter_spec) → ResamplingSlicingReader
723+
# instead of using STSR's slow aggregate code path.
724+
self.filter_spec = filter_spec # TransformSpec or None
725+
self.resample_agg = resample_agg # aggregation for ResamplingSlicingReader
720726

721727

722728
class StreamingTransformedSlicingReader(SlicingReader):
@@ -894,6 +900,113 @@ def __exit__(self, *args):
894900
self.close()
895901

896902

903+
# ---------------------------------------------------------------------------
904+
# Resampling reader
905+
# ---------------------------------------------------------------------------
906+
907+
class ResamplingSlicingReader(SlicingReader):
908+
"""Wraps any :class:`SlicingReader` and resamples its output to a coarser frequency.
909+
910+
Unlike :class:`StreamingTransformedSlicingReader` with ``kind="aggregate"``,
911+
this reader requests only the inner steps needed for each output window —
912+
never a large overlap-padded chunk. It is the efficient outer layer for
913+
composed transforms::
914+
915+
BufferedSlicingReader(chunk_size=20)
916+
└── ResamplingSlicingReader(output_freq="D")
917+
└── StreamingTransformedSlicingReader(godin, kind="convolution")
918+
└── RawSequentialBuffer
919+
└── BaseReader
920+
921+
Parameters
922+
----------
923+
inner : SlicingReader
924+
The pre-filtered source (Godin STSR, rolling STSR, or a raw reader).
925+
output_freq : str
926+
Pandas offset string for the desired output step (e.g. ``"D"``, ``"h"``).
927+
agg : {"mean", "max", "min", "sum"}, optional
928+
Aggregation applied when resampling. Default ``"mean"``.
929+
"""
930+
931+
def __init__(self, inner: SlicingReader, output_freq: str, agg: str = "mean") -> None:
932+
self._inner = inner
933+
self._output_freq = output_freq
934+
self._agg = agg
935+
936+
raw_ti = inner.time_index
937+
out_freq = pd.tseries.frequencies.to_offset(output_freq)
938+
out_start = raw_ti[0].floor(output_freq)
939+
out_end = raw_ti[-1].floor(output_freq)
940+
out_ti = pd.date_range(out_start, out_end, freq=output_freq)
941+
if out_ti.freq is None:
942+
out_ti = out_ti.copy()
943+
out_ti.freq = out_freq
944+
945+
self._vmin = inner.vmin
946+
self._vmax = inner.vmax
947+
948+
super().__init__(out_ti)
949+
950+
@property
951+
def vmin(self) -> float:
952+
return self._vmin
953+
954+
@property
955+
def vmax(self) -> float:
956+
return self._vmax
957+
958+
def get_slice_range(self, start_out: int, end_out: int) -> pd.DataFrame:
959+
n_out = len(self._time_index)
960+
start_out = max(0, min(start_out, n_out))
961+
end_out = max(start_out, min(end_out, n_out))
962+
if start_out >= end_out:
963+
return pd.DataFrame(index=self._time_index[start_out:end_out])
964+
965+
out_freq = pd.tseries.frequencies.to_offset(self._output_freq)
966+
out_start_ts = self._time_index[start_out]
967+
last_out_ts = self._time_index[end_out - 1]
968+
raw_upper_ts = last_out_ts + out_freq
969+
970+
raw_ti = self._inner.time_index
971+
raw_start = int(raw_ti.searchsorted(out_start_ts, side="left"))
972+
raw_end = int(raw_ti.searchsorted(raw_upper_ts, side="left"))
973+
raw_end = min(len(raw_ti), raw_end)
974+
975+
if raw_start >= raw_end:
976+
return pd.DataFrame(index=self._time_index[start_out:end_out])
977+
978+
raw_df = self._inner.get_slice_range(raw_start, raw_end)
979+
if raw_df.empty:
980+
return pd.DataFrame(index=self._time_index[start_out:end_out])
981+
982+
resampled = getattr(raw_df.resample(self._output_freq), self._agg)()
983+
if resampled.index.freq is None:
984+
resampled.index.freq = out_freq
985+
986+
want = self._time_index[start_out:end_out]
987+
result = resampled.reindex(want)
988+
if result.index.freq is None:
989+
result.index.freq = self._time_index.freq
990+
return result
991+
992+
def get_slice(self, timestamp: pd.Timestamp) -> pd.Series:
993+
i = int(self._time_index.get_indexer([timestamp], method="nearest")[0])
994+
chunk = self.get_slice_range(i, i + 1)
995+
if len(chunk) == 0:
996+
return pd.Series(dtype=float)
997+
return chunk.iloc[0].astype(float)
998+
999+
def close(self) -> None:
1000+
if hasattr(self._inner, "close"):
1001+
self._inner.close()
1002+
1003+
def __enter__(self):
1004+
return self
1005+
1006+
def __exit__(self, *args):
1007+
self.close()
1008+
1009+
8971010
# ---------------------------------------------------------------------------
8981011
# Raw sequential look-ahead buffer
8991012
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)