Source code for visualize_accelerometry.plotting

"""
Plotting module — native Bokeh figures with LTTB downsampling.

Creates a main signal plot (with annotation overlays and box-select)
and a range selector (minimap) for navigating large time series.
LTTB downsampling keeps the browser responsive by limiting the number
of points sent over the websocket while preserving visual fidelity.
"""

import numpy as np
from bokeh.models import (
    BoxSelectTool, ColumnDataSource, DatetimeTickFormatter,
    Range1d, RangeTool,
)
from bokeh.plotting import figure

from .config import ARTIFACT_COLORS, LST_COLORS, UCHICAGO_MAROON

# Maximum points to send to the browser per signal axis.
# 10000 provides high visual fidelity while remaining responsive
# with the canvas backend (no WebGL).
MAX_POINTS = 10000


def _downsample(timestamps, values, n_out):
    """Downsample a time series using LTTB (Largest Triangle Three Buckets).

    LTTB selects representative points that preserve the visual shape
    of the signal.  Falls back to uniform strided sampling if the
    ``lttbc`` C extension is not installed.

    Parameters
    ----------
    timestamps : ndarray
        Datetime64 array of timestamps.
    values : ndarray
        Signal values corresponding to *timestamps*.
    n_out : int
        Target number of output points.

    Returns
    -------
    tuple of (ndarray, ndarray)
        Downsampled ``(timestamps, values)``.
    """
    if len(timestamps) <= n_out:
        return timestamps, values
    try:
        import lttbc
        # lttbc operates on float64 arrays
        ts_float = timestamps.astype(np.float64)
        vals_float = values.astype(np.float64)
        ds_ts, ds_vals = lttbc.downsample(ts_float, vals_float, n_out)
        return ds_ts.astype(timestamps.dtype), ds_vals
    except Exception:
        # Graceful fallback: take every Nth sample
        step = max(1, len(timestamps) // n_out)
        return timestamps[::step], values[::step]


[docs] def make_plot(pdf, annotation_cds): """Create the main signal plot and range selector. Parameters ---------- pdf : DataFrame or None Signal data with columns ``timestamp``, ``x``, ``y``, ``z``. If None or empty, returns empty placeholder plots. annotation_cds : dict[str, ColumnDataSource] Persistent Bokeh ColumnDataSources keyed by annotation type (``"chair_stand"``, ``"segment"``, etc.). Their ``.data`` is updated externally; the plot just references them so overlays refresh without rebuilding the figure. Returns ------- tuple of (Panel.pane.Bokeh, Panel.pane.Bokeh, Figure, ColumnDataSource, ColumnDataSource) ``(main_pane, range_pane, main_fig, signal_cds, range_source)`` where ``signal_cds`` is the downsampled signal data source and ``range_source`` is the minimap CDS (both needed for fast in-place data updates on navigation). """ import panel as pn if pdf is None or len(pdf) == 0: empty_fig1 = figure(height=300, sizing_mode="stretch_width") empty_fig2 = figure(height=130, sizing_mode="stretch_width") empty_cds = ColumnDataSource(data=dict(timestamp=[], x=[], y=[], z=[])) return ( pn.pane.Bokeh(empty_fig1, sizing_mode="stretch_width"), pn.pane.Bokeh(empty_fig2, sizing_mode="stretch_width"), empty_fig1, empty_cds, empty_cds, ) ts_raw = pdf["timestamp"].values # --- Downsample each axis independently via LTTB --- # Each axis may pick slightly different representative timestamps, # but we reuse the first axis's timestamps for all three. This is # a minor approximation that keeps the code simple without visible # impact on the plot. ds_data = {"timestamp": None} for col in ["x", "y", "z"]: ds_ts, ds_vals = _downsample(ts_raw, pdf[col].values, MAX_POINTS) if ds_data["timestamp"] is None: ds_data["timestamp"] = ds_ts ds_data[col] = ds_vals colsource = ColumnDataSource(data=ds_data) full_start = ts_raw[0] # Show ~10% of the file initially so the user sees detail initial_end_idx = min(len(ts_raw) - 1, int(len(ts_raw) * 0.1)) initial_end = ts_raw[initial_end_idx] # Explicit y_range computed from signal data. Using Range1d (not # DataRange1d) is critical because DataRange1d would auto-expand to # include annotation quad bounds, squashing the signal to a thin line. y_min = float(np.nanmin([np.nanmin(ds_data["x"]), np.nanmin(ds_data["y"]), np.nanmin(ds_data["z"])])) y_max = float(np.nanmax([np.nanmax(ds_data["x"]), np.nanmax(ds_data["y"]), np.nanmax(ds_data["z"])])) y_pad = max((y_max - y_min) * 0.05, 0.1) y_range = Range1d(start=y_min - y_pad, end=y_max + y_pad) # --- Main signal plot --- main_fig = figure( height=300, x_axis_type="datetime", x_axis_location="above", background_fill_color="#e8e8e8", x_range=Range1d(start=full_start, end=initial_end), y_range=y_range, sizing_mode="stretch_width", toolbar_location=None, ) main_fig.yaxis.visible = False for color, col in zip(LST_COLORS, ["x", "y", "z"]): main_fig.line( "timestamp", col, color=color, source=colsource, alpha=0.95, line_width=1.5, # Dim unselected data so the box-selected region stands out nonselection_alpha=0.2, selection_alpha=1, ) # Invisible scatter points on top of lines so that BoxSelectTool # can select data indices. Line glyphs alone don't support # index-based hit testing. main_fig.scatter( "timestamp", col, color=None, source=colsource, size=0, alpha=0, nonselection_alpha=0, selection_alpha=0, ) main_fig.xaxis.formatter = DatetimeTickFormatter( days="%Y/%m/%d", months="%Y/%m/%d %H:%M", hours="%Y/%m/%d %H:%M", minutes="%H:%M", seconds="%H:%M:%S", milliseconds="%Ss:%3Nms", ) # Width-only box select for time-range annotation box_select = BoxSelectTool(dimensions="width") main_fig.add_tools(box_select) main_fig.toolbar.active_drag = box_select # --- Annotation overlay quads --- # Quads span the full y_range so they are visible behind the signal. q_top = y_max + y_pad q_bot = y_min - y_pad # Activity type overlays (semi-transparent colored fills) for key, color in ARTIFACT_COLORS.items(): main_fig.quad( left="start_time", right="end_time", top=q_top, bottom=q_bot, fill_color=color, fill_alpha=0.2, line_alpha=0, source=annotation_cds[key], level="overlay", name="annotation_quad", ) # Flag overlays (hatch patterns with no fill, matching the original app) flag_hatches = { "segment": "cross", "scoring": "dot", "review": "spiral", } for key, hatch in flag_hatches.items(): main_fig.quad( left="start_time", right="end_time", top=q_top, bottom=q_bot, fill_color=None, fill_alpha=0, line_alpha=0, hatch_pattern=hatch, hatch_color="black", hatch_weight=0.5, hatch_alpha=0.1, source=annotation_cds[key], level="overlay", name="annotation_quad", ) # --- Range selector (minimap) --- # Subsample from the already-downsampled main data (10K → 2K) # instead of re-running LTTB on the full raw signal. n_main = len(ds_data["timestamp"]) step = max(1, n_main // 2000) range_data = { "timestamp": ds_data["timestamp"][::step], "x": ds_data["x"][::step], "y": ds_data["y"][::step], "z": ds_data["z"][::step], } range_source = ColumnDataSource(data=range_data) range_fig = figure( height=130, y_range=main_fig.y_range, x_axis_type="datetime", y_axis_type=None, tools="", toolbar_location=None, background_fill_color="#e8e8e8", sizing_mode="stretch_width", ) for color, col in zip(LST_COLORS, ["x", "y", "z"]): range_fig.line( "timestamp", col, color=color, source=range_source, alpha=0.8, line_width=1.2, ) range_fig.xaxis.formatter = DatetimeTickFormatter( days="%m/%d %H:%M", months="%m/%d %H:%M", hours="%m/%d %H:%M", minutes="%m/%d %H:%M", seconds="%m/%d %H:%M:%S", ) # RangeTool links the minimap's draggable overlay to main_fig.x_range range_tool = RangeTool(x_range=main_fig.x_range) range_tool.overlay.fill_color = UCHICAGO_MAROON range_tool.overlay.fill_alpha = 0.15 range_fig.add_tools(range_tool) range_fig.toolbar.active_multi = "auto" main_pane = pn.pane.Bokeh(main_fig, sizing_mode="stretch_width") range_pane = pn.pane.Bokeh(range_fig, sizing_mode="stretch_width") return main_pane, range_pane, main_fig, colsource, range_source
[docs] def update_plot_data(pdf, signal_cds, main_fig, range_source=None): """Update an existing plot's data without rebuilding figures. Replaces the signal CDS data, adjusts x/y ranges, and optionally updates the range selector CDS. Much faster than ``make_plot`` because Bokeh sends only a data-patch over the websocket instead of tearing down and reconstructing the entire document subtree. Parameters ---------- pdf : DataFrame New signal data with ``timestamp``, ``x``, ``y``, ``z``. signal_cds : ColumnDataSource The main signal CDS to update (returned by ``make_plot``). main_fig : Figure The main plot figure (for x_range / y_range adjustment). range_source : ColumnDataSource or None The range selector CDS. If None, the minimap is not updated. Returns ------- bool True if updated successfully, False if a full rebuild is needed. """ if pdf is None or len(pdf) == 0: return False ts_raw = pdf["timestamp"].values # Downsample ds_data = {"timestamp": None} for col in ["x", "y", "z"]: ds_ts, ds_vals = _downsample(ts_raw, pdf[col].values, MAX_POINTS) if ds_data["timestamp"] is None: ds_data["timestamp"] = ds_ts ds_data[col] = ds_vals # Update signal CDS in one shot (triggers a single websocket push) signal_cds.data = ds_data # Adjust y_range to fit new data y_min = float(np.nanmin([np.nanmin(ds_data["x"]), np.nanmin(ds_data["y"]), np.nanmin(ds_data["z"])])) y_max = float(np.nanmax([np.nanmax(ds_data["x"]), np.nanmax(ds_data["y"]), np.nanmax(ds_data["z"])])) y_pad = max((y_max - y_min) * 0.05, 0.1) q_top = y_max + y_pad q_bot = y_min - y_pad main_fig.y_range.start = q_bot main_fig.y_range.end = q_top # Update annotation quad renderers to match new y_range. # Annotation quads are tagged with name="annotation_quad" at creation. for renderer in main_fig.renderers: if getattr(renderer, "name", None) == "annotation_quad": renderer.glyph.top = q_top renderer.glyph.bottom = q_bot # Reset x_range to show ~10% of the file initially (matches make_plot) main_fig.x_range.start = ts_raw[0] initial_end_idx = min(len(ts_raw) - 1, int(len(ts_raw) * 0.1)) main_fig.x_range.end = ts_raw[initial_end_idx] # Update range selector if provided if range_source is not None: n_main = len(ds_data["timestamp"]) step = max(1, n_main // 2000) range_source.data = { "timestamp": ds_data["timestamp"][::step], "x": ds_data["x"][::step], "y": ds_data["y"][::step], "z": ds_data["z"][::step], } return True