Monitor Framework

plesty.lib.monitor formalizes the platform’s live view: a Monitor projects data as it arrives onto whatever should be drawn. Where an analyzer is a pull-style transform of a finished dataset, a monitor is a push-style projection of a running one — a measuring experiment’s step records, a device’s telemetry, a hand-fed stream.

Watching never disturbs measuring. A monitor holds no device handle, sends no command, and writes nothing back; it reads what the experiment already persisted. That is why a viewer can be started, closed, and restarted in the middle of an eight-hour run without anyone thinking twice.

The three layers

The contract splits so that no layer depends on the one above it:

Layer

What it does

Depends on

Source

poll() → the frames that appeared since last time

nothing (stdlib)

Projection

Monitor.update(frame)TraceData per declared trace

the data layer

Rendering

Renderer.draw(...) → pixels

a GUI toolkit (plesty.lib.ui)

Only the third layer knows a toolkit exists, so the first two are testable headless and the same monitor renders in a Qt shell, in a recording, or into a report.

The contract

A subclass declares the channels it consumes and implements exactly two methods — traces() (what is drawn, declared once) and update() (what to draw for one frame):

from plesty.lib.monitor import Frame, Monitor, TraceData, TraceSpec

class Spectrum(Monitor):
    title = "Spectrum"
    input_schema = {
        "wavelength": {"dtype": "array_float", "unit": "nm",
                       "description": "Wavelength axis."},
        "counts": {"dtype": "array_float", "unit": "counts",
                   "description": "Detector counts."},
    }

    def traces(self) -> list[TraceSpec]:
        return [TraceSpec("spectrum", "line", x_label="Wavelength (nm)",
                          y_label="Counts", color="cyan")]

    def update(self, frame: Frame) -> dict[str, TraceData]:
        return {"spectrum": TraceData(x=frame["wavelength"], y=frame["counts"])}

submit() runs the base machinery around update():

  1. Frame validation — channel names, dtypes, and shapes are checked against input_schema; array channels are coerced to PlestyArray with the schema unit, because frames arrive from JSON records and telemetry events rather than from typed Python.

  2. update() — the subclass projection, pure with respect to the outside world: it reads the frame and its own accumulated state, nothing else.

  3. Trace check — the returned keys must be declared traces; a trace left out keeps what it was showing.

  4. Fan-out — every attached renderer is drawn, and snapshot() keeps the last payload so a renderer attaching later shows the current state instead of an empty plot.

Schema entries use the same dtype vocabulary as device and analyzer schemas (resolve_dtype), with the same shape and required keys.

TraceSpec.color names a design token ("cyan", "violet", "mint", …), never a hex value: the renderer resolves it against the theme, so a view drawn in the shell, in a report, and on a second rig looks the same.

Sources

Every source answers one question — what appeared since I last asked? — through a non-blocking poll(). No source starts a thread or blocks, so the clock belongs to the caller: a GUI timer, a test loop, a notebook cell.

Source

Follows

Typical use

RunSource

<run_dir>/records.jsonl, tailed by offset

any running experiment

TelemetrySource

TelemetryEvents of devices

live device readings

PushSource

whatever the producer emits

tests, notebooks

RunSource is the generic live view of any PLESTY experiment: the experiment framework appends one record line per completed step, and that line is the commit record of the write, so following the file needs no cooperation from the experiment and cannot disturb it. Each poll reads only the bytes appended since the last one, so following a run of thousands of steps on a network share stays cheap. It also means a viewer started mid-run still sees every earlier row. A Run from plesty.lib.experiment builds the source for you (run.source(mapper)), live or replayed.

from plesty.lib.monitor import RunSource

source = RunSource(
    "runs/qd_fss_20260804-214826",
    # The experiment's record shape stays its own business; the mapper picks
    # out the channels this view declares. Returning None drops a record —
    # bookkeeping steps such as the rig identity are not measurements.
    mapper=lambda rec: None if "hwp_deg" not in rec else {
        "wavelength": rec["wavelength_nm"], "counts": rec["counts"],
    },
)
monitor = Spectrum(source)
monitor.pump()          # → submits every row persisted so far
source.finished         # → the journal says the run is over

Push-style producers (telemetry hooks fired on a device thread) only buffer; the frames are handed out on the consumer’s thread. Buffers are bounded, so a slow consumer drops old frames rather than exhausting memory.

Design rules

  • Read-only. A monitor never writes back. Anything that commands hardware is a control panel, not a monitor — a different Panel in the same shell.

  • No threads, no polling loops. The framework owns no clock; sources are drained by whoever has one. This mirrors the telemetry system, which owns no loops either.

  • Projection is pure. update() touches no device, file, or GUI object. That is what makes a monitor testable without a display and reusable in a recording or an offline re-render.

  • Bounded memory. history caps the frames kept; accumulating views cap their own series. A day-long run must not grow the viewer without bound.

  • Errors stay local. A failing view never blinds the others: the shell catches per-panel failures and shows them in that panel’s title bar.

Where concrete monitors live

plesty-lib ships the contract, not the views — a spectrum plot is not core infrastructure. Reusable views live in their own package (plesty-common-monitors); rig-specific ones live next to the experiment that needs them.

Watching a run: Viz

Everything about watching is the same for every experiment — which run (the newest, or --run), where the share is mounted, live or replayed, in a window or rendered offscreen to a video, recorded or not. Viz owns all of it; the experiment writes one function that says which record key means what, and that is its whole viz module:

# plesty/pol_pl/viz.py
import sys

from plesty.common_monitors import SeriesMonitor, SpectrumMonitor, WaterfallMonitor
from plesty.common_monitors.series import series_mapper
from plesty.common_monitors.spectrum import spectrum_mapper
from plesty.lib.monitor import Viz
from plesty.lib.ui import MonitorPanel

viz = Viz("Polarization PL", experiment="pol_pl")

@viz.panels
def panels(run):
    spectra = spectrum_mapper("data_file", locate=run.local, row_key="hwp_deg")
    yield MonitorPanel(SpectrumMonitor(run.source(spectra), name="spectrum"), weight=3)
    yield MonitorPanel(WaterfallMonitor(run.source(spectra), name="map", y_label="HWP angle (deg)"), weight=3)
    if run.config.get("powermeter"):
        power = run.source(series_mapper("hwp_deg", "power_w", y_scale=1e6))
        yield MonitorPanel(SeriesMonitor(power, name="power", y_label="Power (uW)"), weight=2)

if __name__ == "__main__":
    sys.exit(viz.main())
python -m plesty.pol_pl.viz monitor                       # follow the newest run live
python -m plesty.pol_pl.viz monitor --replay 2 --record demo.mp4
python -m plesty.pol_pl.viz render --run pol_pl_20260805-091909   # <run-id>.mp4, no window

The function receives a Run: run.source(mapper) is already a RunSource or a ReplaySource as the subcommand decided — call it once per view: a source hands each record out once, so two views polling one source would split the rows between them (a mapper, by contrast, is freely shared); run.local translates the acquiring host’s paths onto this machine, run.config is the frozen configuration. Runs are looked for under --run-root (default PLESTY_DATA_MOUNT, else runs); --mount and --data-dir override the share’s two spellings for runs that did not record them. render sets Qt’s offscreen platform before the toolkit is imported and stops by itself once every source is exhausted; neither --help nor resolve_run needs the gui extra.

Testing a monitor

NullRenderer records draw calls instead of drawing them, so the whole path — source → validated frame → projection — is testable with no GUI:

monitor = Spectrum(source)
renderer = monitor.attach(NullRenderer())
assert monitor.pump() == 92
assert len(renderer.drawn) == 92
assert monitor.snapshot()["spectrum"].y.max() > 0

See also