A measurement that runs for eight hours used to be a directory you inspected afterwards. plesty-lib 0.3.5 ships the live view as platform infrastructure: a Monitor contract that projects arriving data onto declared traces, a dockable shell that hosts those views, and Viz — a ready-made monitor / render command line that any experiment adopts by writing one function.

The design rule underneath is that watching never disturbs measuring. A monitor holds no device handle, sends no command, and writes nothing back. It follows the records the experiment has already committed to disk — which is why a viewer can be opened, closed, and reopened in the middle of a run without anyone thinking twice, and why a run started before the viewer still shows every earlier row.

Experiment records.jsonl RunSource → Monitor Panel in the shell

The contractThree layers, none depending on the one above

Source
poll() → the frames that appeared since last time. Pure stdlib; no toolkit, no threads.
Projection
Monitor.update(frame) → one TraceData per declared trace. Pure with respect to the outside world.
Rendering
Renderer.draw(...) → pixels. The only layer that knows a GUI toolkit exists.

Because only the third layer imports a toolkit, the first two are testable headless and the same monitor renders in a window, into a recording, or offscreen into a video. import plesty.lib.monitor pulls in no GUI at all — it works on a lab server with no display stack.


For operatorsSeeing the data while it is being measured

Users

An experiment that ships a viz module is watched with two subcommands. Nothing about the run has to be named — the newest one is found for you:

# follow the newest run of this experiment, live
python -m plesty.pol_pl.viz monitor

# replay a stored run at the pace it happened, 2 rows per refresh,
# and record the window to a video
python -m plesty.pol_pl.viz monitor --replay 2 --record demo.mp4

# no window at all: encode a stored run offscreen
python -m plesty.pol_pl.viz render --run pol_pl_20260805-091909

How the run is found

Runs are directories named <experiment>_<YYYYMMDD-HHMMSS> under a run root. Viz resolves them with list_runs / latest_run from plesty.lib.experiment.runs, so "the newest run" is a real lookup, not a convention each viewer re-derives:

FlagDefaultWhat it answers
--run-root$PLESTY_DATA_MOUNT, else runsWhich directory holds the runs
--runnewest run of this experimentWhich run to open
--mount$PLESTY_DATA_MOUNTThis machine's spelling of the shared data root
--data-dir$PLESTY_DATA_DIRThe same root as the acquiring host wrote it
--interval-msshell clockHow often panels refresh
Two spellings of one shareThe rig that measures and the desk that watches often mount the same network share under different names. The run journals the acquiring host's path (PLESTY_DATA_DIR); the viewer knows its own mount (PLESTY_DATA_MOUNT); run.local(host_path) translates between them. Set the two variables once per machine and every viz command works with no path flags.

What the window does without any further code

Rearrange
Move a panel to another edge, or drop it on another panel to tab the two together. Drag the handle between panels to resize.
Float
Pull a panel out into its own window — onto a second screen — and drag it back later. The button does the same.
Remember
The arrangement is saved on exit and restored next time, per window title. Layout → Reset to defaults undoes a layout that went wrong.
Record
One captured frame per refresh, encoded when recording stops. Whatever is docked is captured, including a panel added halfway through.

A panel that raises is caught and reports the error in its own title bar — one broken view never blinds the rest of the window.

Showing a run to a colleague--record turns a two-hour measurement into a video of it building up. MP4 comes from the record extra; with Pillow alone the recorder writes an animated GIF rather than refusing, because a recording that produces no file is worse than one in the wrong format.

Installing the viewer

uv add "plesty-lib[gui]"      # PySide6 + pyqtgraph — the window
uv add "plesty-lib[record]"   # Pillow + imageio-ffmpeg — MP4 output

Neither --help nor run resolution needs the gui extra: no toolkit is imported until main() actually opens a window, and render selects Qt's offscreen platform before the toolkit loads.


For contributorsWriting a view of your own

Developers

A monitor 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"])}

Everything around update() is base machinery. submit() validates the frame against input_schema (names, dtypes, shapes — array channels are coerced to PlestyArray with the schema's unit, because frames arrive from JSON records rather than typed Python), calls the projection, checks that the returned keys are declared traces, then fans out to every attached renderer. snapshot() keeps the last payload so a renderer attaching later shows the current state instead of an empty plot.

You implementYou declareThe base class handles
traces()
update(frame)
title
input_schema
validation · trace check · renderer fan-out · history · pump() · snapshot()

Trace kinds are line, scatter, image, scalar and text. TraceSpec.color names a design token — "cyan", "violet", "mint", "amber", "rose", "blue" — never a hex value; the renderer resolves it against the theme, so the same view looks the same in the shell, in a recording and on a second rig.

Feeding it: pick a source

SourceFollowsTypical use
RunSource<run_dir>/records.jsonl, tailed by byte offsetAny running experiment
ReplaySourceThe same records, rationed per pollReplaying a stored run at its own pace
TelemetrySourceDevice TelemetryEventsLive device readings
PushSourceWhatever the producer emitsTests, notebooks

The records.jsonl convention is what makes RunSource generic. The experiment framework appends one fsynced 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 across a network share stays cheap (unread is a stat call, not a glob).

A FrameMapper bridges the experiment's record shape to the channels your view declares — returning None drops a record, so bookkeeping steps are not mistaken for measurements:

from plesty.lib.monitor import RunSource

source = RunSource(
    "runs/qd_fss_20260804-214826",
    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

Giving your experiment a viz: one function

This is what Viz removed. Which run, where the share is mounted, live or replayed, window or offscreen, recorded or not — identical for every experiment. What differs is only which record key means what, so that is all an experiment writes:

# 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"), 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())

The function is handed a Run. run.source(mapper) is already a RunSource or a ReplaySource, as the subcommand decided; run.local translates the acquiring host's paths onto this machine; run.config is the frozen configuration, which is enough to decide which panels a given run deserves. Panel(weight=…) says what each is worth before the operator rearranges them.

One source per viewCall run.source(mapper) once per view. A source hands each record out exactly once, so two views polling one source would split the rows between them. A mapper, by contrast, is a pure function and is freely shared — as spectra is above.

Testing it, with no display

NullRenderer records draw calls instead of drawing them, so the whole path — source → validated frame → projection — is covered headless:

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

Panels are toolkit-free until they are built, so a shell's whole composition — which panels, in which order, with which weights — is testable the same way.

The rules a new view has to respect

Where to contribute a view

plesty-lib ships the contract, not the views — a spectrum plot is not core infrastructure. Reusable views (spectrum, waterfall, series) live in plesty-common-monitors; rig-specific ones live next to the experiment that needs them. Adding a monitor there means a new Monitor subclass plus, usually, a mapper factory so experiments can wire their own record keys to it in one line.

Where to start readingdocs/monitor.md and docs/ui.md in plesty-lib carry the full contract, the source table and the shell's panel API. The framework landed across plesty-lib 0.3.3 – 0.3.5.