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.
The contractThree layers, none depending on the one above
TraceData per declared trace. Pure with respect to the outside world.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
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:
| Flag | Default | What it answers |
|---|---|---|
--run-root | $PLESTY_DATA_MOUNT, else runs | Which directory holds the runs |
--run | newest run of this experiment | Which run to open |
--mount | $PLESTY_DATA_MOUNT | This machine's spelling of the shared data root |
--data-dir | $PLESTY_DATA_DIR | The same root as the acquiring host wrote it |
--interval-ms | shell clock | How often panels refresh |
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
⤢ button does the same.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.
--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
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 implement | You declare | The base class handles |
|---|---|---|
traces()update(frame) | titleinput_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
| Source | Follows | Typical use |
|---|---|---|
RunSource | <run_dir>/records.jsonl, tailed by byte offset | Any running experiment |
ReplaySource | The same records, rationed per poll | Replaying a stored run at its own pace |
TelemetrySource | Device TelemetryEvents | Live device readings |
PushSource | Whatever the producer emits | Tests, 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.
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
- Read-only. A monitor never writes back. Anything that commands hardware is a control panel, not a monitor — a different
Panelin the same window. - No threads, no polling loops. The framework owns no clock; the shell's
interval_msis the only one in the application, and it drains every source. - Projection is pure.
update()touches no device, file, or GUI object. That is what makes it reusable in a recording and an offline re-render. - Bounded memory.
historycaps 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 is caught and reported in its own title bar.
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.
docs/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.