Source code for instro.lib.publishers.plot

"""Publisher that plots Measurement/Command data on a Plotly figure."""

from __future__ import annotations

import threading
from collections import defaultdict
from typing import TYPE_CHECKING

import numpy as np

from instro.lib.publishers.channel_buffer import NumpyRingBuffer
from instro.lib.publishers.publisher import Publisher
from instro.lib.types import Command, Measurement

# Plotly is the optional `plot` extra. The publisher only calls methods on the caller's
# figure, so importing instro never needs it.
if TYPE_CHECKING:
    import plotly.graph_objects as go  # type: ignore[import-untyped, import-not-found]


[docs] class PlotlyPublisher: """Plot Measurement/Command data on a Plotly figure, one trace per channel. Requires the ``plot`` extra (``pip install "instro[plot]"``). """ def __init__( self, figure: go.Figure | go.FigureWidget | None = None, max_points: int = 10_000, flush_interval: float = 0.1, time_window_s: float | None = None, ): """Create a publisher that plots each channel on a trace named after the channel. Args: figure: Figure to plot on. A ``go.FigureWidget`` updates live in a notebook. When omitted, the publisher creates a ``go.FigureWidget``, displays it right away, and shows it again on ``close()``. max_points: Points kept per channel; older points drop off the plot. flush_interval: Seconds between writes to the figure. time_window_s: Seconds of data the x axis shows, or ``None`` (the default) to autoscale. The window starts at the first point and scrolls forward. """ self._show_on_close = figure is None if figure is None: import plotly.graph_objects as go # type: ignore[import-untyped, import-not-found] from IPython.display import display # type: ignore[import-untyped, import-not-found] figure = go.FigureWidget(layout={"xaxis": {"type": "date"}}) display(figure) self.figure = figure # Set explicitly so the axis shows timestamps even before data arrives. self.figure.update_layout(xaxis_type="date") self._max_points = max_points self._flush_interval = flush_interval self._time_window_ns = None if time_window_s is None else int(time_window_s * 1e9) self._buffers: defaultdict[str, tuple[NumpyRingBuffer, NumpyRingBuffer]] = defaultdict( lambda: (NumpyRingBuffer(max_points, np.int64), NumpyRingBuffer(max_points, np.float64)) ) self._dirty: set[str] = set() self._lock = threading.Lock() self._stop = threading.Event() self._thread = threading.Thread(target=self._flush_periodically, daemon=True) self._thread.start()
[docs] def publish(self, data: Measurement | Command, **kwargs) -> None: """Buffer ``data`` for the next write to the figure.""" # Give each channel its own timestamps and values. batches: list[tuple[str, list[int], list]] if isinstance(data, Measurement): batches = [(channel, data.timestamps, values) for channel, values in data.channel_data.items()] else: batches = [(channel, [data.timestamp], [value]) for channel, value in data.channel_data.items()] # Add the numeric channels to their ring buffers and mark them for the next flush. with self._lock: for channel, timestamps, values in batches: if not values or not timestamps or isinstance(values[0], str): continue # Equal lengths keep the two buffers' write positions, and so every later x/y pair, in step. timestamps = timestamps[: len(values)] + [timestamps[-1]] * (len(values) - len(timestamps)) channel_timestamps, channel_values = self._buffers[channel] channel_timestamps.extend(timestamps) channel_values.extend(values) self._dirty.add(channel)
def _flush_periodically(self) -> None: while not self._stop.wait(self._flush_interval): self._flush() def _flush(self) -> None: # Copy the changed channels out of their ring buffers. pending = {} with self._lock: for channel in self._dirty: channel_timestamps, channel_values = self._buffers[channel] pending[channel] = ( channel_timestamps.get_latest(self._max_points), channel_values.get_latest(self._max_points).tolist(), ) self._dirty.clear() # Written outside the lock so a slow figure update never blocks publishers. Only the flush # thread, or close() after stopping it, writes to the figure. for channel, (timestamps, values) in pending.items(): x = np.datetime_as_string(np.asarray(timestamps, dtype="datetime64[ns]"), unit="us") trace = next(self.figure.select_traces(selector={"name": channel}), None) if trace is None: # "hv" holds each value until the next sample (forward fill) instead of interpolating. self.figure.add_scatter(x=x, y=values, name=channel, line_shape="hv") else: trace.update(x=x, y=values) # Show the time window from the first point, then scroll it forward once the newest point passes its end. if self._time_window_ns is not None and pending: oldest = min(int(timestamps[0]) for timestamps, _ in pending.values()) newest = max(int(timestamps[-1]) for timestamps, _ in pending.values()) start = max(oldest, newest - self._time_window_ns) window = np.array([start, start + self._time_window_ns], dtype="datetime64[ns]") self.figure.update_xaxes(range=np.datetime_as_string(window, unit="us").tolist())
[docs] def close(self) -> None: """Stop the flush thread, write any remaining points to the figure, and clear the buffers.""" self._stop.set() self._thread.join() self._flush() with self._lock: self._buffers.clear() # FigureWidget.show() does nothing outside a notebook, so show the figure through plotly.io. if self._show_on_close: import plotly.io as pio # type: ignore[import-untyped, import-not-found] pio.show(self.figure)