Source code for ezmsg.dashboard.backend.services.stream_tap

"""Watching what a publisher is actually sending, from the dashboard backend.

The backend already holds a live ``GraphContext``, so it can create an ezmsg
``Subscriber`` on any topic directly: in its own event loop, with no unit
injected into the graph and no edge added to the topology. That is the whole
mechanism. There is no shared-memory hop and no helper process, because the
thing that forces those on the desktop tools -- a renderer living in a process
that cannot be an ezmsg node -- does not apply when the renderer is a browser at
the other end of a socket the backend is already serving.

Two properties of ezmsg's ``Subscriber`` are what make it safe to point this at
a production graph:

* ``leaky=True`` drops notifications instead of applying backpressure, and
  releases the publisher's buffer for each one it drops (see
  ``Subscriber._handle_dropped_notification``). A wedged browser tab cannot
  stall the graph it is watching. This is the single property worth checking
  first in any "tap a live pipeline" design, and ezmsg provides it at the client
  layer.
* ``recv_zero_copy()`` yields the message itself, so nothing is pickled or
  deep-copied on the way in. We reduce inside the context manager and let go.

What leakiness costs is contiguity: a subscriber that falls behind loses whole
messages, and a sweep drawn straight from those would join two ends of a gap
into a smooth, entirely fictional trace. Hence the ring in
:mod:`..stream_frames`, written at message rate and read at frame rate, which
reports the gap instead of hiding it.

``ezmsg-tools`` supplies the AxisArray-to-plottable reduction
(``ezmsg.tools.plot.describe``). It is an optional dependency: without it the
message inspector still works on every topic -- it needs nothing but the message
-- and only the plotting modes are unavailable. Nothing else in the dashboard
imports it.
"""

from __future__ import annotations

import asyncio
import contextlib
import logging
import math
import sys
import time
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from typing import Any

import numpy as np
from ezmsg.util.messages.axisarray import AxisArray

from ..stream_frames import RingCursor, SampleRing, envelope_pairs, ring_capacity_for

logger = logging.getLogger("ezmsg.dashboard.stream_tap")

try:
    from ezmsg.tools.chmeta import channel_names
    from ezmsg.tools.plot.describe import (
        StreamShape,
        UnsupportedMetricError,
        describe_axisarray,
        flatten_for_plot,
    )

    PLOTTING_AVAILABLE = True
    PLOTTING_UNAVAILABLE_REASON: str | None = None
except ImportError as exc:  # pragma: no cover - exercised by the extra being absent
    StreamShape = Any  # type: ignore[assignment,misc]
    UnsupportedMetricError = NotImplementedError  # type: ignore[assignment,misc]
    channel_names = None  # type: ignore[assignment]
    describe_axisarray = None  # type: ignore[assignment]
    flatten_for_plot = None  # type: ignore[assignment]
    PLOTTING_AVAILABLE = False
    PLOTTING_UNAVAILABLE_REASON = (
        f"Plotting needs the 'viz' extra (ezmsg-tools): {exc}. "
        "Install with `pip install ezmsg-dashboard[viz]`. The message inspector works without it."
    )

#: How many messages a tap's subscriber may fall behind before ezmsg starts
#: dropping them for us. Deep enough to ride out a scheduling hiccup without
#: losing samples, shallow enough that a stalled tap does not sit on a large
#: share of the publisher's buffers.
LEAKY_MAX_QUEUE = 8

#: Seconds of slack the ring stages between the recv loop and the pump. This is
#: jitter tolerance, not display history -- the history lives in the browser.
RING_SECONDS = 2.0

#: Ceiling on one ring's footprint. A 30 kHz 256-channel stream would otherwise
#: turn RING_SECONDS into hundreds of megabytes.
RING_MAX_BYTES = 32 * 1024 * 1024

#: How many channels the browser draws at once. Not a limit on the stream: a
#: wider one is plotted too, a window at a time, and scrolled.
#:
#: There was a hard ceiling here once, which refused to plot anything wider. It
#: was put in on the assumption that the wire cost scaled with frame rate, and
#: that stopped being true when the sweep gained a time base -- a frame carries
#: only the columns that elapsed, so traffic follows columns per second and the
#: channel count multiplies a much smaller number. At a 2 s window, 2048
#: channels cost about 6 MB/s, which is not a reason to refuse a stream.
MAX_DRAWN_CHANNELS = 512

#: Cap on payload columns per frame regardless of what a client asks for.
MAX_COLUMNS = 4096

#: Seconds of signal a sweep window shows by default.
#:
#: This is what sets the decimation ratio, and it matters far more than it
#: looks. Without a time base, a frame's samples are simply spread across the
#: whole window, so a 30 kHz stream read at 30 Hz puts ~1000 samples into ~760
#: columns -- barely any decimation, a window covering 33 ms, a plot that
#: repaints entirely every frame, and tens of MB/s on the wire. Fixing the
#: window in *time* makes a column a fixed number of samples, which is what
#: makes both the picture and the bandwidth behave.
DEFAULT_WINDOW_SECONDS = 2.0
MIN_WINDOW_SECONDS = 0.05
MAX_WINDOW_SECONDS = 60.0

#: View kinds a *stream* can be, decided by the data rather than by the client.
#: Letting the client pick would mean a browser could ask for a sweep of a
#: spectrum, and the honest answer to that is not a plot.
SWEEP = "sweep"
SPECTRUM = "spectrum"
SCATTER = "scatter"
PLOT_MODES = (SWEEP, SPECTRUM, SCATTER)

#: What a *client* may ask for. ``auto`` takes whatever view the stream turns
#: out to support; ``inspect`` asks for metadata only and never opens the data
#: path at all, which is the right mode for a topic carrying something no plot
#: can draw -- and cheap enough to leave running on a busy stream.
AUTO = "auto"
INSPECT = "inspect"
CLIENT_MODES = (AUTO, INSPECT)


[docs] class StreamTapError(RuntimeError): """A tap could not be opened, or cannot render the stream it found."""
[docs] class StreamTapUnavailableError(StreamTapError): """Taps are not available at all -- no GraphContext, or no 'viz' extra."""
def _try_asarray(data: Any) -> np.ndarray | None: """``np.asarray`` if it produces something numeric, else None. A failed conversion does not always raise: numpy will happily wrap an object it cannot interpret in a 0-d object array, which is not something any of this can plot. """ try: result = np.asarray(data) except Exception: # noqa: BLE001 - any conversion failure is the same failure here return None return None if result.dtype == object else result def _bfloat16_via_bits(data: Any) -> np.ndarray | None: """``bfloat16`` -> float32 by moving bits, never by running framework compute. ``bfloat16`` is exactly the top 16 bits of an IEEE float32, so widening it is a shift, not a conversion -- which is fortunate, because the obvious route is unsafe here. Asking the framework to cast (``array.astype(float32)``) runs a kernel on its default device, and an array that arrived over ezmsg's transport is backed by a buffer that device cannot address. On MLX that returns values near 2.5e38 and NaN rather than failing: silently wrong, which for a monitoring tool is worse than not plotting at all. Reinterpreting the bits in numpy touches only memory we can already read, and was checked against a float32 copy of the same signal. Returns None if this is not a bfloat16 array, or if its bits cannot be reached without compute either. """ if "bfloat16" not in str(getattr(data, "dtype", "")).lower(): return None reinterpret = getattr(data, "view", None) module = sys.modules.get(type(data).__module__) native_uint16 = getattr(module, "uint16", None) if module is not None else None if not callable(reinterpret) or native_uint16 is None: return None try: bits = _try_asarray(reinterpret(native_uint16)) except Exception: # noqa: BLE001 - any failure just means this route is closed return None if bits is None or bits.dtype != np.uint16: return None # Little-endian assumption, the same one the wire format makes. return (bits.astype(np.uint32) << 16).view(np.float32)
[docs] def as_numpy(data: Any) -> np.ndarray: """A numpy view of a message's payload, whatever framework produced it. ezmsg does not require an ``AxisArray``'s ``data`` to be a numpy array, and a pipeline running on MLX -- or torch, or jax -- hands over its own array type. Those satisfy numpy's buffer or ``__array__`` protocol, so this is usually a *view* and not a conversion: on Apple silicon's unified memory, ``np.asarray`` of an MLX array aliases the very same buffer. That aliasing is worth stating plainly, because writing through the result would write into the publisher's array. Everything downstream of here either copies into the ring or copies explicitly; none of it writes in place. Two things need handling beyond the happy path: * **dtypes numpy has no equivalent for.** ``bfloat16`` is the one that matters: it fails the buffer protocol outright, and no dtype hint gets around it. See :func:`_bfloat16_via_bits`, and note in particular that the obvious fix -- asking the framework to cast -- silently corrupts data that arrived over the wire. * **laziness.** MLX does not evaluate until something reads the buffer, so this is where a publisher's pending work gets forced. It is cheap here because the message had to be materialised to be delivered at all. Deliberately does *not* fall back to running the producing framework's compute on the payload. That is what the bfloat16 note above is about, and the failure it produces is invisible. :raises StreamTapError: if the payload cannot be read as numeric data. The caller turns that into a plot error and leaves the message inspector working, which is the useful half for a stream nothing can draw. """ if isinstance(data, np.ndarray): return data converted = _try_asarray(data) if converted is not None: return converted converted = _bfloat16_via_bits(data) if converted is not None: return converted raise StreamTapError( f"message data is {type(data).__module__}.{type(data).__name__} with dtype " f"{getattr(data, 'dtype', 'unknown')}, which cannot be read as a numpy array. " "The inspector still works; plotting needs a payload numpy can view." )
def _clean_float(value: Any) -> float | None: """A JSON-safe float, or None for anything non-finite. Sample data routinely carries NaN and inf, and the header travels as strict JSON. Dropping to None keeps one bad statistic from failing the whole frame. """ try: as_float = float(value) except (TypeError, ValueError): return None return as_float if math.isfinite(as_float) else None def _sample_dim(mode: str, dims: list[str]) -> str: """Which dimension a stream advances along, or "" if it has none. Empty means the whole message is one frame of per-channel values -- always true for scatter, and true for a bare ``ch`` stream that gets drawn as a sweep advancing one column per message. Those still plot, and usefully: the x axis becomes message arrivals instead of seconds. This has to be derived from the dims and not from the mode alone, because it is also what tells ``describe_axisarray`` which dimension is *not* channels. Naming an absent dimension there would fold the sample axis into the channel count; naming a present one wrongly would do the reverse. """ if mode == SWEEP: return "time" if "time" in dims else "" if mode == SPECTRUM: return "freq" return "" def _infer_mode(msg: AxisArray) -> str: """Pick a default view for a message, mirroring sigmon's dispatch. Deliberately the same rules the desktop tool uses, so a stream that shows up as a spectrum there does not show up as a sweep here. """ dims = list(msg.dims) if "time" in dims: return SWEEP if "freq" in dims: return SPECTRUM if "ch" in dims and _channel_positions(msg) is not None: return SCATTER return SWEEP def _channel_positions(msg: AxisArray) -> np.ndarray | None: """``(n_channels, 2)`` electrode positions from the ``ch`` axis, if present. Only returns positions that are not all-zero: a structured ``ch`` axis often carries x/y fields left at their defaults, and drawing every channel on top of the origin is worse than offering no map at all. """ if "ch" not in msg.dims: return None try: ch_axis = msg.get_axis("ch") except (KeyError, ValueError): return None ch_data = getattr(ch_axis, "data", None) if ch_data is None or getattr(ch_data, "dtype", None) is None or ch_data.dtype.names is None: return None if "x" not in ch_data.dtype.names or "y" not in ch_data.dtype.names: return None x = np.asarray(ch_data["x"], dtype=np.float32) y = np.asarray(ch_data["y"], dtype=np.float32) if not (np.any(x != 0) or np.any(y != 0)): return None return np.column_stack((x, y)) def _axis_entry_labels(axis: Any, size: int) -> list[str] | None: """One name per entry of a coordinate axis, or None if it has no names. Handles the two shapes an axis's ``data`` actually takes: a structured array with a ``label`` field (the ``ch`` convention), and a plain array of strings (how a small ``feat``-style axis usually spells itself). """ data = getattr(axis, "data", None) if data is None: return None values = np.asarray(data) if values.shape[:1] != (size,): return None if values.dtype.names: return list(channel_names(values, size)) if values.dtype.kind in "US": return [str(value) for value in values] return None def _channel_axes( msg: AxisArray, dims: list[str], shape: tuple[int, ...], sample_dim: str, metric_dim: str | None, ) -> list[dict[str, Any]]: """The dimensions that get folded together into "channels", in fold order. ``flatten_for_plot`` reshapes everything that is neither the sample axis nor the metric tuple into one channel axis, and ``np.moveaxis`` preserves the relative order of what it does not move -- so this list, in ``dims`` order, *is* the order the flattening uses. Reporting it is what lets the browser label a flattened channel correctly instead of guessing. Guessing is the status quo and it is wrong in two different ways. For a ``(time, ch, feat)`` stream, flat channel 1 is ``(ch0, feat1)`` but takes the name of ``ch1`` -- a different electrode, silently. For ``(time, feat, ch)`` the first block is right and everything after it gets an invented ``chN``. """ axes: list[dict[str, Any]] = [] for dim, size in zip(dims, shape): if dim == sample_dim or dim == metric_dim: continue axis = msg.axes.get(dim) axes.append( { "name": dim, "size": int(size), "labels": _axis_entry_labels(axis, int(size)) if axis is not None else None, } ) return axes def _composite_channel_labels(channel_axes: list[dict[str, Any]]) -> list[str] | None: """One name per *flattened* channel, e.g. ``sbp/E12``. Correct by construction: it walks the same axis order the reshape does, so a name cannot drift from the trace it belongs to. """ if not channel_axes: return None if not any(axis["labels"] for axis in channel_axes): return None def entry(axis: dict[str, Any], index: int) -> str: labels = axis["labels"] if labels and index < len(labels): return labels[index] # Unlabelled axes still need to say which one they are, or a composite # name reads as though the index belonged to the labelled axis. return f"{axis['name']}{index}" names = [""] for axis in channel_axes: names = [ f"{prefix}/{entry(axis, index)}" if prefix else entry(axis, index) for prefix in names for index in range(axis["size"]) ] return names def _axes_identity(msg: AxisArray) -> tuple[tuple[str, int], ...]: """Cheap fingerprint of a message's axis objects. A processor that leaves an axis alone passes the same object through, so in the steady state this is a handful of pointer reads per message. It is a *trigger*, not a decision: a change here only causes a re-describe, and the resulting description is then compared by value before anything is rebuilt. That is what keeps an id() collision from mattering. """ return tuple(sorted((name, id(axis)) for name, axis in msg.axes.items())) @dataclass class _Description: """Everything the browser needs to set up a plot for a stream. Compared by value to decide whether the ring and the client's plot have to be rebuilt, so every field here is one whose change invalidates them. """ mode: str #: Views this stream can be drawn as. The first is the default; the browser #: may pick another without re-subscribing, because every view here is a #: reading of the same frames. available_modes: list[str] n_channels: int width: int srate: float dims: list[str] sample_dim: str dtype: str channel_labels: list[str] | None unit: str | None metric_kind: str | None key: str | None complex_magnitude: bool channel_positions: list[list[float]] | None freq_gain: float | None n_bins: int | None channel_axes: list[dict[str, Any]] def to_payload(self) -> dict[str, Any]: return { "mode": self.mode, "available_modes": self.available_modes, "n_channels": self.n_channels, "width": self.width, "srate": _clean_float(self.srate) or 0.0, "dims": self.dims, "sample_dim": self.sample_dim, "dtype": self.dtype, "channel_labels": self.channel_labels, "unit": self.unit, "metric_kind": self.metric_kind, "key": self.key, "complex_magnitude": self.complex_magnitude, "channel_positions": self.channel_positions, "channel_axes": self.channel_axes, "freq_gain": _clean_float(self.freq_gain), "n_bins": self.n_bins, } @dataclass class _InspectState: """What the message inspector reports, for any message type at all. Kept separate from :class:`_Description` because it must survive messages that no plot can draw -- a plain dataclass, a dict, a list of strings. Being able to say "this topic is carrying a `Foo` at 12 Hz" for *anything* in the graph is most of the value of the feature. """ type_name: str = "" module: str = "" is_axisarray: bool = False dims: list[str] | None = None shape: list[int] | None = None dtype: str | None = None key: str | None = None axes: dict[str, dict[str, Any]] | None = None attrs: dict[str, str] | None = None repr_preview: str | None = None plottable: bool = False plot_error: str | None = None def to_payload(self) -> dict[str, Any]: return { "type_name": self.type_name, "module": self.module, "is_axisarray": self.is_axisarray, "dims": self.dims, "shape": self.shape, "dtype": self.dtype, "key": self.key, "axes": self.axes, "attrs": self.attrs, "repr_preview": self.repr_preview, "plottable": self.plottable, "plot_error": self.plot_error, } def _describe_axis(axis: Any) -> dict[str, Any]: """One axis rendered for the inspector, without putting arrays on the wire.""" data = getattr(axis, "data", None) if data is not None: as_array = np.asarray(data) fields = list(as_array.dtype.names) if as_array.dtype.names else None return { "kind": "coord", "unit": str(getattr(axis, "unit", "") or ""), "length": int(as_array.shape[0]) if as_array.ndim else 0, "fields": fields, } return { "kind": "linear", "unit": str(getattr(axis, "unit", "") or ""), "gain": _clean_float(getattr(axis, "gain", None)), "offset": _clean_float(getattr(axis, "offset", None)), } def _preview(value: Any, limit: int = 160) -> str: """A short, always-safe rendering of an arbitrary value. ``repr`` on a user object can raise or return something enormous; the inspector must not be the reason a tap dies, so both are contained here. """ try: text = repr(value) except Exception as exc: # noqa: BLE001 - a broken __repr__ is the caller's, not ours return f"<unreprable {type(value).__name__}: {exc}>" return text if len(text) <= limit else text[: limit - 1] + "…"
[docs] class StreamTap: """One subscriber on one topic, shared by every client watching it. Sharing matters more than it looks: each extra ``Subscriber`` on a topic is another consumer the publisher has to serve and another set of buffers it has to hold, so opening one per browser tab would make the dashboard's cost to the graph scale with how many people are looking at it. """
[docs] def __init__( self, topic: str, subscriber_factory: Callable[..., Any], *, subscriber_release: Callable[[Any], None] | None = None, ring_seconds: float = RING_SECONDS, ring_max_bytes: int = RING_MAX_BYTES, ) -> None: self._topic = topic self._subscriber_factory = subscriber_factory self._subscriber_release = subscriber_release self._ring_seconds = ring_seconds self._ring_max_bytes = ring_max_bytes self._subscriber: Any = None self._task: asyncio.Task[None] | None = None self._ref_count = 0 self._status = "connecting" self._status_detail: str | None = None self._axes_identity: tuple[tuple[str, int], ...] | None = None self._shape_signature: tuple[Any, ...] | None = None self._description: _Description | None = None self._description_generation = 0 self._inspect = _InspectState() self._ring: SampleRing | None = None self._latest: np.ndarray | None = None self._latest_generation = 0 self._flatten_shape: _FlattenShape | None = None self._message_count = 0 self._first_message_monotonic: float | None = None self._last_message_monotonic: float | None = None self._rate_window: list[float] = []
# -- lifecycle --------------------------------------------------------- @property def topic(self) -> str: return self._topic @property def ref_count(self) -> int: return self._ref_count
[docs] async def start(self) -> None: if self._task is None: self._task = asyncio.create_task(self._run(), name=f"dashboard-tap:{self._topic}")
[docs] async def stop(self) -> None: task, self._task = self._task, None if task is not None: task.cancel() with contextlib.suppress(asyncio.CancelledError): await task await self._close_subscriber()
async def _close_subscriber(self) -> None: subscriber, self._subscriber = self._subscriber, None if subscriber is None: return subscriber.close() with contextlib.suppress(Exception): await subscriber.wait_closed() if self._subscriber_release is not None: # GraphContext keeps every client it creates in a set it only clears # on session teardown, with no removal API. Taps open and close for # the life of the dashboard, so without this the set grows by one # dead subscriber per closed panel. self._subscriber_release(subscriber)
[docs] def acquire(self) -> None: self._ref_count += 1
[docs] def release(self) -> None: self._ref_count = max(0, self._ref_count - 1)
# -- receive path ------------------------------------------------------ async def _run(self) -> None: try: self._subscriber = await self._subscriber_factory( self._topic, leaky=True, max_queue=LEAKY_MAX_QUEUE, ) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 - surfaced to the client as status self._set_status("error", f"could not subscribe to '{self._topic}': {exc}") return self._set_status("waiting", None) try: while True: async with self._subscriber.recv_zero_copy() as msg: self._ingest(msg) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 - surfaced to the client as status logger.exception("stream tap on %r stopped", self._topic) self._set_status("error", f"tap stopped: {exc}") def _set_status(self, status: str, detail: str | None) -> None: self._status = status self._status_detail = detail def _ingest(self, msg: Any) -> None: """Absorb one message. Runs inside ``recv_zero_copy``, so keep it short. Every failure here is contained: a message we cannot make sense of turns into an inspector entry and a status, never into a dead tap. A tap that dies on one odd message would be useless for the case it is most needed in, which is a pipeline that has started emitting something unexpected. """ now = time.monotonic() self._message_count += 1 if self._first_message_monotonic is None: self._first_message_monotonic = now self._last_message_monotonic = now self._rate_window.append(now) if len(self._rate_window) > 64: del self._rate_window[:-64] try: self._observe(msg) except Exception as exc: # noqa: BLE001 - never let a plot problem kill the tap self._inspect.plottable = False self._inspect.plot_error = str(exc) self._ring = None self._latest = None self._description = None self._set_status("live", None) return self._set_status("live", None) def _observe(self, msg: Any) -> None: if not isinstance(msg, AxisArray): self._observe_foreign(msg) return identity = _axes_identity(msg) signature = self._signature(msg) if identity != self._axes_identity or signature != self._shape_signature: self._axes_identity = identity self._shape_signature = signature self._redescribe(msg) if self._description is None: return self._store(msg, self._description) def _signature(self, msg: AxisArray) -> tuple[Any, ...]: """Shape fingerprint with the sample axis masked out. The sample axis is the one dimension that legitimately differs from message to message, so including its length would re-describe the stream on every message and defeat the point of the check. """ dims = list(msg.dims) # Deliberately not routed through _infer_mode: that inspects the `ch` # axis data to choose scatter, which is real per-message work, and the # only thing needed here is the name of the dimension whose length is # allowed to vary. sample_dim = "time" if "time" in dims else ("freq" if "freq" in dims else "") shape = tuple(-1 if dim == sample_dim else int(size) for dim, size in zip(dims, msg.data.shape)) # `str(dtype)` rather than numpy's `dtype.str`: the payload is not # necessarily a numpy array, and a foreign dtype object has no `.str`. # All this needs is a token that changes when the dtype does. return (tuple(dims), shape, str(msg.data.dtype), getattr(msg, "key", None)) def _observe_foreign(self, msg: Any) -> None: """Record a non-AxisArray message for the inspector and stop there.""" self._inspect = _InspectState( type_name=type(msg).__name__, module=type(msg).__module__, is_axisarray=False, repr_preview=_preview(msg), plottable=False, plot_error=f"{type(msg).__name__} is not an AxisArray; only the inspector can show it.", ) self._description = None self._ring = None self._latest = None def _redescribe(self, msg: AxisArray) -> None: """Rebuild what we know about the stream, and the ring if it changed.""" dims = list(msg.dims) attrs = getattr(msg, "attrs", None) or {} inspect_state = _InspectState( type_name=type(msg).__name__, module=type(msg).__module__, is_axisarray=True, dims=dims, shape=[int(size) for size in msg.data.shape], dtype=str(msg.data.dtype), key=str(getattr(msg, "key", "") or "") or None, axes={name: _describe_axis(axis) for name, axis in msg.axes.items()}, attrs={str(name): _preview(value, 80) for name, value in attrs.items()}, repr_preview=None, ) if not PLOTTING_AVAILABLE: inspect_state.plottable = False inspect_state.plot_error = PLOTTING_UNAVAILABLE_REASON self._inspect = inspect_state self._description = None self._ring = None self._latest = None return try: description = self._build_description(msg, dims) except StreamTapError as exc: inspect_state.plottable = False inspect_state.plot_error = str(exc) self._inspect = inspect_state self._description = None self._ring = None self._latest = None return inspect_state.plottable = True inspect_state.plot_error = None self._inspect = inspect_state if description != self._description: self._description = description self._description_generation += 1 self._rebuild_stores(description) def _build_description(self, msg: AxisArray, dims: list[str]) -> _Description: # Raises StreamTapError for a payload numpy cannot view, which the # caller reports as a plot error with the inspector left intact. data = as_numpy(msg.data) mode = _infer_mode(msg) sample_dim = _sample_dim(mode, dims) # `time_axis` names the dimension that is *not* channels, so it has to # track the mode. Left at its "time" default a spectrum would fold its # frequency bins into the channel count -- a 1024-bin 32-channel stream # describing itself as 32768 channels, which then trips the channel # ceiling rather than plotting. shape: StreamShape = describe_axisarray(msg, time_axis=sample_dim) width = len(shape.metric.labels) if shape.metric is not None else 1 metric_kind = shape.metric.kind if shape.metric is not None else None if mode == SWEEP and shape.metric is not None and metric_kind != "minmax": # describe_axisarray recognises dispersion pairs like (mean, std), # which are the same shape as an envelope and must not be drawn as # one. Say what arrived instead of drawing something plausible. raise StreamTapError( f"stream carries a {metric_kind!r} metric axis, which the sweep view " "cannot draw yet (only (min, max) envelopes are supported)." ) channel_axes = _channel_axes( msg, dims, tuple(int(size) for size in data.shape), sample_dim, shape.metric.axis if shape.metric is not None else None, ) # Composite names win over the single-axis ones from `describe_axisarray` # whenever more than one dimension was folded into "channels": those are # per-axis-entry, so on a folded stream they run out and, worse, can land # on the wrong trace. composite_labels = _composite_channel_labels(channel_axes) channel_labels = composite_labels or (list(shape.channel_labels) if shape.channel_labels else None) # Computed regardless of the inferred mode: a (time, ch) stream with real # electrode positions can be drawn as a map *as well as* a sweep, and the # browser decides which. Positions are static per description, so this # costs one extraction per shape change either way. positions = _channel_positions(msg) # A map needs one position per drawn channel. That holds for an ordinary # (time, ch) stream, and for a folded one only once an axis is pinned -- # which is a browser-side choice, so it offers the view and the browser # decides whether the current selection can use it. scatter_possible = positions is not None and len(positions) == int(shape.n_channels) available_modes = [SPECTRUM] if mode == SPECTRUM else [SWEEP] if scatter_possible: available_modes.append(SCATTER) if mode == SCATTER: # Inferred as a map, but it plots as a sweep too, one column per # message -- useful for watching a single channel's value move. available_modes = [SCATTER, SWEEP] freq_gain: float | None = None n_bins: int | None = None if mode == SPECTRUM: n_bins = int(data.shape[dims.index("freq")]) with contextlib.suppress(Exception): freq_gain = float(msg.get_axis("freq").gain) # `shape.srate` is derived from the gain of whichever axis was named # `time_axis`, so it only means samples-per-second in sweep mode. For a # spectrum that same number is bins-per-Hz inverted, which is what # `freq_gain` carries properly; reporting it as a sample rate would put # a wrong time axis under the plot. srate = float(shape.srate) if mode == SWEEP else 0.0 return _Description( mode=mode, available_modes=available_modes, n_channels=int(shape.n_channels), width=int(width), srate=srate, dims=dims, sample_dim=sample_dim, dtype=str(msg.data.dtype), channel_labels=channel_labels, unit=shape.unit, metric_kind=metric_kind, key=str(getattr(msg, "key", "") or "") or None, complex_magnitude=bool(np.iscomplexobj(data)), channel_positions=positions.tolist() if positions is not None else None, freq_gain=freq_gain, n_bins=n_bins, channel_axes=channel_axes, ) def _rebuild_stores(self, description: _Description) -> None: """Point the tap at fresh storage for a stream whose shape just changed.""" self._latest = None self._latest_generation += 1 self._flatten_shape = _FlattenShape( n_channels=description.n_channels, metric=_FlattenMetric(labels=("min", "max")) if description.width == 2 else None, ) if description.mode != SWEEP: # Spectrum and scatter replace their whole frame each message, so a # ring would only ever hold data the next message invalidates. self._ring = None return capacity = ring_capacity_for( srate=description.srate, n_channels=description.n_channels, width=description.width, seconds=self._ring_seconds, max_bytes=self._ring_max_bytes, ) self._ring = SampleRing(capacity, description.n_channels, description.width) def _store(self, msg: AxisArray, description: _Description) -> None: data = as_numpy(msg.data) if description.complex_magnitude: # A spectrum is routinely complex. Magnitude is the only reduction # that means anything on a plot without also asking about phase. data = np.abs(data) if description.sample_dim: sample_index = description.dims.index(description.sample_dim) if sample_index != 0: data = np.moveaxis(data, sample_index, 0) else: # No sample axis: the whole message is one frame. The leading # singleton is what lets it share the reduction below, and for a # sweep it is what makes each message advance the plot by one # column. data = data.reshape((1, *data.shape)) block = flatten_for_plot(data, self._plot_shape(description)) if block.ndim == 2: block = block[:, :, np.newaxis] block = block.astype(np.float32, copy=False) if description.mode == SWEEP: if self._ring is not None: # Copies into the ring, so nothing here outlives the message. self._ring.write(block) return # Spectrum keeps the whole frame as (n_bins, n_channels); scatter keeps # the most recent sample, which is what a map of "right now" means. # # The copy is load-bearing. Everything above -- asarray, moveaxis, # reshape, flatten_for_plot, the float32 cast -- can hand back a *view* # of msg.data, and msg.data is the publisher's shared-memory buffer, # borrowed only for the duration of recv_zero_copy. Storing a view would # leave the plot reading a buffer the publisher is free to overwrite, # and would pin the segment open past the lease. frame = block[:, :, 0] self._latest = np.array(frame if description.mode == SPECTRUM else frame[-1:], dtype=np.float32) self._latest_generation += 1 def _plot_shape(self, description: _Description) -> Any: """The ``StreamShape`` stand-in ``flatten_for_plot`` reads. Rebuilt only when the description changes -- ``_rebuild_stores`` is the one place that happens -- because building a real ``StreamShape`` per message would re-run channel-label extraction in the hot path for a reduction that never looks at the labels. """ return self._flatten_shape # -- read path ---------------------------------------------------------
[docs] def snapshot_rate_hz(self) -> float | None: """Message rate over the recent window, or None before it means anything.""" if len(self._rate_window) < 2: return None span = self._rate_window[-1] - self._rate_window[0] if span <= 0: return None return (len(self._rate_window) - 1) / span
[docs] def status_payload(self) -> dict[str, Any]: last_seen = None if self._last_message_monotonic is not None: last_seen = max(0.0, time.monotonic() - self._last_message_monotonic) return { "topic": self._topic, "status": self._status, "detail": self._status_detail, "message_count": self._message_count, "rate_hz": _clean_float(self.snapshot_rate_hz()), "seconds_since_last_message": _clean_float(last_seen), "watchers": self._ref_count, }
[docs] def inspect_payload(self) -> dict[str, Any]: return self._inspect.to_payload()
@property def description(self) -> _Description | None: return self._description @property def description_generation(self) -> int: return self._description_generation
[docs] def pending_samples(self, cursor: RingCursor) -> int: """How many samples this cursor has not read yet. Lets a client decide whether a read is worth making before it makes one, which is what keeps the frame rate from dictating the time base. """ ring = self._ring if ring is None: return 0 return max(0, ring.total_written - cursor.total_read)
[docs] def read_sweep(self, cursor: RingCursor, target_columns: int) -> tuple[np.ndarray, bool, int, int] | None: """``(pairs, overflowed, first_sample_index, n_samples)`` or None if idle. ``target_columns`` is how many columns *this block* should occupy, which the caller derives from the window's time base -- not the width of the plot. Passing the plot width here instead would make every frame fill the whole window regardless of how much time it covered. """ ring = self._ring if ring is None: return None block, overflowed, first_sample_index = ring.read(cursor) if block.shape[0] == 0 and not overflowed: return None pairs = envelope_pairs(block, max(1, target_columns)) return pairs, overflowed, first_sample_index, int(block.shape[0])
[docs] def read_latest(self, seen_generation: int) -> tuple[np.ndarray, int] | None: """The most recent spectrum/scatter frame, if it is newer than ``seen``.""" if self._latest is None or self._latest_generation == seen_generation: return None return self._latest, self._latest_generation
[docs] def reset_cursor(self, cursor: RingCursor) -> None: """Point a cursor at the newest sample, discarding the backlog. Used when a client first attaches or after a shape change: the samples already in the ring belong to the previous plot, and prepending them to a freshly-configured one would draw the old stream's tail on the new stream's axes. """ cursor.total_read = self._ring.total_written if self._ring is not None else 0
@dataclass class _FlattenMetric: labels: tuple[str, ...] @dataclass class _FlattenShape: """Duck-typed stand-in for the two fields ``flatten_for_plot`` consults.""" n_channels: int metric: _FlattenMetric | None
[docs] @dataclass class StreamTapClient: """One websocket's view of a tap: what it wants, and where it has read to.""" tap: StreamTap mode: str max_columns: int window_seconds: float = DEFAULT_WINDOW_SECONDS cursor: RingCursor = field(default_factory=RingCursor) seen_description_generation: int = -1 seen_latest_generation: int = -1 #: Fractional column carried between frames. Rounding each frame's column #: count independently would let the time base drift against the sample #: clock, slowly and invisibly. column_remainder: float = 0.0 @property def topic(self) -> str: return self.tap.topic
[docs] def set_max_columns(self, max_columns: int) -> None: self.max_columns = max(1, min(MAX_COLUMNS, int(max_columns)))
[docs] def set_window_seconds(self, window_seconds: float) -> None: self.window_seconds = max(MIN_WINDOW_SECONDS, min(MAX_WINDOW_SECONDS, float(window_seconds)))
[docs] def effective_columns(self, srate: float) -> int: """How many columns the requested window is actually worth. The plot's pixel budget is a *ceiling*, not a target. A slow stream cannot fill it: two seconds of a 100 Hz signal is 200 samples, and a column can never stand for less than one sample. Spreading those 200 samples across a 2000-column plot is what the earlier version did, and it silently showed twenty seconds while the caption said two -- with the window control apparently dead, because every window short enough to matter produced the same one-sample column. Shrinking the column count instead keeps the window honest; the plot simply draws fewer, wider columns, which is all the data there is. """ if srate <= 0: # No sample rate: the stream advances one column per message and # seconds do not enter into it. return self.max_columns return max(2, min(self.max_columns, math.ceil(srate * self.window_seconds)))
[docs] def samples_per_column(self, srate: float) -> float: """How many source samples one output column stands for. A stream with no sample rate advances one column per message, so there is nothing to average and this is 1. """ if srate <= 0: return 1.0 return max(1.0, (srate * self.window_seconds) / self.effective_columns(srate))
[docs] def shown_seconds(self, srate: float) -> float | None: """How much time the plot will *actually* span. Usually the requested window, but not always: a very slow stream runs into the two-column floor, and half a sample of a 1 Hz signal is not something anything can draw. The client captions from this rather than from the request, so the axis never claims a span the data does not have. """ if srate <= 0: return None return (self.effective_columns(srate) * self.samples_per_column(srate)) / srate
[docs] def take_description(self) -> dict[str, Any] | None: """The stream description, but only once per change.""" description = self.tap.description generation = self.tap.description_generation if description is None or generation == self.seen_description_generation: return None self.seen_description_generation = generation self.tap.reset_cursor(self.cursor) self.seen_latest_generation = -1 payload = description.to_payload() payload["generation"] = generation return payload
[docs] def take_frame(self) -> tuple[dict[str, Any], np.ndarray] | None: """The next data frame for this client, or None if there is nothing new. Returns the header and payload rather than sending them, so the framing and the mode dispatch can be tested without a websocket on either end. Callers must consume :meth:`take_description` first: the header refers to a description generation, and a frame that arrives before the description it belongs to would be drawn on the previous stream's axes. """ if self.mode == INSPECT: return None description = self.tap.description if description is None: return None if description.mode == SWEEP: window_columns = self.effective_columns(description.srate) per_column = self.samples_per_column(description.srate) pending = self.tap.pending_samples(self.cursor) + self.column_remainder target = int(pending // per_column) if target <= 0: # Not enough new samples to fill a column yet. Returning early # keeps a fast frame rate from emitting sub-column frames, which # would put more columns on the plot than the time base allows. return None target = min(target, window_columns) result = self.tap.read_sweep(self.cursor, target) if result is None: self.column_remainder = 0.0 return None pairs, overflowed, first_sample_index, n_samples = result # Carry the leftover so the column clock does not drift against the # sample clock over a long session. self.column_remainder = ( 0.0 if overflowed else max(0.0, n_samples + self.column_remainder - pairs.shape[0] * per_column) ) srate = description.srate header = { "kind": "stream.data", "mode": SWEEP, "generation": self.seen_description_generation, # Payload is (n_out, n_channels, components): one row per # column, one texel per channel, which the browser uploads # straight into an RG32F texture. "n_out": int(pairs.shape[0]), "n_channels": int(pairs.shape[1]), # Min/max pairs, always -- see envelope_pairs. The renderer # never has to branch on whether decimation happened. "components": 2, # How wide the browser's ring must be for the window to mean # what the caption says. Sent per frame rather than with the # description because it moves with the window control, which # does not change the stream's shape. "columns": window_columns, # What is actually spanned, not what was asked for. "window_seconds": self.shown_seconds(description.srate), "samples_per_column": per_column, "n_samples": n_samples, "first_sample_index": first_sample_index, "t_start": (first_sample_index / srate) if srate > 0 else None, "t_end": ((first_sample_index + n_samples) / srate) if srate > 0 else None, "overflow": overflowed, } return header, pairs latest = self.tap.read_latest(self.seen_latest_generation) if latest is None: return None frame, generation = latest self.seen_latest_generation = generation header = { "kind": "stream.data", "mode": description.mode, "generation": self.seen_description_generation, "n_out": int(frame.shape[0]), "n_channels": int(frame.shape[1]), "components": 1, "overflow": False, } return header, frame
[docs] class StreamTapManager: """Refcounted registry of live taps, one per topic."""
[docs] def __init__( self, subscriber_factory: Callable[..., Any], *, subscriber_release: Callable[[Any], None] | None = None, ) -> None: self._subscriber_factory = subscriber_factory self._subscriber_release = subscriber_release self._taps: dict[str, StreamTap] = {} self._lock = asyncio.Lock()
@property def active_topics(self) -> list[str]: return sorted(self._taps)
[docs] async def shutdown(self) -> None: async with self._lock: taps, self._taps = list(self._taps.values()), {} for tap in taps: await tap.stop()
[docs] @contextlib.asynccontextmanager async def client( self, topic: str, *, mode: str, max_columns: int, window_seconds: float = DEFAULT_WINDOW_SECONDS, ) -> AsyncIterator[StreamTapClient]: if mode not in CLIENT_MODES: raise StreamTapError(f"unknown stream mode {mode!r}; expected one of {', '.join(CLIENT_MODES)}") if not topic: raise StreamTapError("a stream tap needs a topic") async with self._lock: tap = self._taps.get(topic) if tap is None: tap = StreamTap(topic, self._subscriber_factory, subscriber_release=self._subscriber_release) self._taps[topic] = tap await tap.start() tap.acquire() client = StreamTapClient( tap=tap, mode=mode, max_columns=max(1, min(MAX_COLUMNS, max_columns)), ) client.set_window_seconds(window_seconds) try: yield client finally: async with self._lock: tap.release() if tap.ref_count == 0 and self._taps.get(topic) is tap: del self._taps[topic] stopping = tap else: stopping = None if stopping is not None: await stopping.stop()
[docs] def availability_payload() -> dict[str, Any]: """Whether plotting modes can run here, for the frontend to gate its UI on.""" return { "inspector": True, "plotting": PLOTTING_AVAILABLE, "reason": PLOTTING_UNAVAILABLE_REASON, "max_drawn_channels": MAX_DRAWN_CHANNELS, "max_columns": MAX_COLUMNS, }