# 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](analyzer.md) 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`](ui.md)) | 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): ```python 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`](data_schemas.md)), 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](ui.md#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` | `/records.jsonl`, tailed by offset | any running experiment | | `TelemetrySource` | `TelemetryEvent`s 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](experiment.md) 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. ```python 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`](ui.md#panels) 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: ```python # 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()) ``` ```bash 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 # .mp4, no window ``` The function receives a [`Run`](experiment.md#data-on-a-shared-disk): `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: ```python 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 - [Shell framework](ui.md) — the home window that hosts monitors as rearrangeable sub-windows. - [Experiment framework](experiment.md) — what `RunSource` follows. - [Device telemetry](device/logging_system.md) — what `TelemetrySource` subscribes to.