added auto measuraments

This commit is contained in:
Martino Ferrari committed 2026-10-03 10:34:15 +02:00
1 parent 44fc4cdf36
commit 0eceaf537b
7 files changed
+2182 -55

No files matched your search

+155 -9
View File
@@ -58,6 +58,68 @@ frame and the GUI thread takes once per frame around binding the traces.
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
single consumer), never through the lock.
## Measurements
**Measurements** (overflow menu) reveals a pane with a `[+]` and one row per measurement:
`[badge] <type> <value> [edit]`. `[+]` opens a dialog with the signal, the type, the options
that type actually uses, and "show on plot"; `edit` opens the same dialog on that
measurement, with a `Delete` beside `Save`.
The signal list is the signals the **plots are showing**, in signal-list order: a
measurement reads the window a plot displays and its marks go on the row that plots the
signal, so the candidates are the traces on screen. That is also what keeps everything else
out of it — a signal nobody acquires, and a stream's own timestamp channel, which is acquired
but is not something to measure. A measurement whose signal is no longer plotted keeps it in
the list while it is being edited, so saving does not silently re-point it at whatever the
dropdown happened to be showing. The badge is the measurement's own colour, and
the marks it draws on the plot use it, so a readout and its ink are recognisably the same
measurement.
| type | what it is | options | shown on the plot as |
|---|---|---|---|
| `average` | the mean of the visible samples | — | a ruler at the mean |
| `top` / `bottom` | the high / low level: the mean of the samples above / below the split | split % | a ruler at the level |
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges |
| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle |
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
only make that count wrong — a split near either extreme counts noise as crossings, and
counting both edges halves the period.
The split is a share of the **window's own** min..max, not volts: a measurement is about this
signal in this window, and a level in volts would have to be typed for every signal. A
crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal
that merely touches the level does not count every sample as an event.
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
the plot) and the signal's chip, in the palette colour every other view names that signal
with. The dialog's fields and their wording follow the type — the split row is absent for
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
line says what would be drawn.
The values are **window statistics over exactly the visible range, at the stored rate**: the
same cut the export makes, so a measurement describes the data on screen rather than the ring
behind it — and the marks it draws belong to that window, so they stay in the viewport.
They are recomputed when the **window moves**, not on a timer: a timer decouples the two, and
with a scrolling window the stored marks then describe a range the plot has already left (the
min/max marker ends up outside the viewport, and a dragged marker survives until the next
tick). The trigger is 2 % of the span, so the numbers track the window within a few pixels
while the cost stays bounded by the window's motion rather than by the frame rate; one pass
copies the visible samples of each *channel* once, however many measurements read it.
**Show on plot** marks the result on the row that plots the signal: a ruler at the level
(`top`/`bottom`), both rulers at the extremes (`min`/`max`), the pulse's or the cycle's two
edge times as vertical markers. The marks are **readouts, not instruments**: a fixed pool of
cursors per row, created **unselectable** (`Plot::set_cursor_selectable()`, added for this), so
the controller's hit test never offers the pointer one — it can neither hover nor drag a
measurement's ruler. They are also re-placed every frame, because a mark is only correct for
the window its measurement was computed over. Re-placing alone was not enough: a drag is held
for the gesture's life, so the marker followed the pointer *between* frames and the readout
looked draggable.
## The session layout file
What you arranged is remembered per data source, in
@@ -409,8 +471,12 @@ figure so the legend name is right (there is one widget, so the row a drop lands
on is `PlotArea::row_at()`). The status line reports the outcome of a drop
(`plotting 'sine' on plot 1`, `plot 1 already holds four signals`), so an action
that changes nothing still says why. Each plot's y axis has a right-click menu
with auto scale, a fixed range, and one **Remove '<signal>' from plot N** entry per
occupied trace slot (a plot with nothing on it says so instead).
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
it says so instead). The two fits are the same pair the overflow menu and the `f` key
offer, split by what they touch: **Fit X axis** frames the window on the data the ring
holds and leaves every y scale exactly as the user set it, **Fit all** does that and
auto-scales the y axes too.
**The plot does not twitch.** Two independent causes were fixed, both of which
showed up as the x axis moving a few pixels every frame — including while paused,
@@ -518,6 +584,55 @@ switches them on as well, because a reset nobody can see reads as a dead button.
The two panels collapse vertically (`SLIDE_DOWN`), so hiding one gives its space
back instead of leaving an invisible gap in the column.
With the cursors on, a **readout panel** appears in the side column, and a dropdown
chooses what the instruments are:
| mode | instruments | the pane reads |
|---|---|---|
| `vertical` | two x markers, one pair for the whole figure | `T0`, `T1`, `dT` |
| `horizontal` | two y rulers on **each** row | `Y1`, `Y2`, `dY` per row |
| `both` | both of the above | the times, then a line per row |
| `mix` | the x pair, plus where each marker **crosses each trace** | the times, then `Y1`, `Y2`, `dY` per plotted signal |
The vertical pair is one stream time shared by every row, so the markers line up down
the column and dragging one on any row moves them all — the app reads the position back
out of the plot each frame, because a drag writes the *plot* and that is the only place
it is visible. A ruler is per row: each row has its own y axis, so two rows' rulers are
two different values, and the pane names the row.
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
through the library's `Plot::crosshair_value()` — the two bracketing samples,
interpolated — so it is the answer to "what is this signal at these two times" rather
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has
nothing at that time and `partial` when only one of the two markers is over samples.
The readouts are a **table**: a header naming the columns, then one row per instrument —
`| who | A | B | delta |`. The first row is the two times and their difference, and the
rows below it are the rulers (one per row, in `horizontal`/`both`) or the traces (in
`mix`). The time row is there only where the time cursors are: in `horizontal` the pair is
parked, and a row of times nothing on the plot can move would be a readout of nothing. Columns size to their widest cell, so a column of numbers is a column; the two
cursors are `A` and `B` and their difference is `delta`, whichever of the three the mode
puts in them.
Every readout is a **measurement**, not a raw number: at most two decimals, scaled by an
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
when they agree on one and print bare digits when they do not: a ruler is on the row's y
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
signal's **colour chip** instead of its name — the chip identifies it exactly as the signal
panel does — and the values are bare: `● 616.49 mV -24.57 mV -641.06 mV`, because the
pane is a readout beside a plot and `Y1:` labels there are noise.
`mix` also *draws* the crossings: a mark on each trace where each marker meets it, in the
marker's own colour, placed with the same call the pane reads (so the mark and the number
cannot disagree), and shown only where the trace actually has samples at that time. The
marks are annotations the plot holds from construction and that are moved, never added:
`Plot` appends annotations only, so a host that re-added them would grow the plot every
frame — which is why the library gained `Plot::set_annotation()` for this. Nothing marks
the ruler modes: a ruler *is* the mark.
**The time window is typed, not picked off a ladder.** The header bar's `window`
button reads the current span and opens onto `÷10` · a number · a unit (µs, ms, s,
min) · `×10`. A ladder of round numbers could only ever offer the spans someone had
@@ -573,9 +688,11 @@ logged with both rates and the new anti-alias corner. A window change that leave
`time_stride` alone rebuilds nothing. Per-channel `stride` is still always 1 (see the
limitations below); only the shared clock is decimated.
**Fit to data** (overflow menu) does what the plot's own `f` key does: auto-scales every row's y
axis and re-frames the x window on the data the ring holds, propagated to every
row. The status line acknowledges it (`fitted to the buffered data (1.25 s)`).
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
auto-scales every row's y axis and re-frames the x window on the data the ring holds,
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for
the common case where the window should be the data's but the levels were placed by hand.
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`).
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
@@ -607,6 +724,11 @@ removes it, a `+` on each boundary inserts one there — so the row an action na
row you are pointing at. `--plots` is now only the count to start with, and `RowSet::kMaxRows`
(8) is a ceiling rather than a menu: past about that a row is chrome and no plot.
The editor also carries its own way out: a **`Done`** button in the plot area's top-right
corner while it is up, because the toggle that opened it lives in the overflow menu — a
long way from the plot being edited. It turns that toggle off, so there is still exactly
one piece of state, and the buttons (its own included) go with it.
An insert or a remove **moves the rows' contents with them**: what row 1 plotted, in the
colours it plotted it in and with its cursors where they were, is on row 2 after inserting
above row 1. Only that state moves; the figure's `Plot`s are rebuilt and re-dealt, which is
@@ -711,6 +833,19 @@ now — they are listed so they are not mistaken for anything else:
hundred samples every fifth of a second made every frame span microseconds while
the frames were 100 ms apart).
**One source, one client, over unicast.** A UDPStreamer over unicast keeps a single
session: a second `udpscope`'s CONNECT displaces the first, the displaced client sees its
`silence_timeout_s` elapse, reconnects and displaces the other, and the two take turns
being served — which reads as "the second UI gets no data" (and both windows look alike
because they share the saved layout). Nothing on the client side can give both of them the
stream: the wire has one destination. Watching one source from several UIs is what
`--multicast <group> [--interface <local ip>]` is for — every instance joins the group and
the kernel hands each of them a copy. When the app sees the fight (a reconnect with no
frames delivered in the same second — the client's own retry counter is also shown in the
status line as `N reconnects` once it is nonzero) it says so in the status line rather than
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
state holds.
## Limitations
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
@@ -727,11 +862,22 @@ now — they are listed so they are not mistaken for anything else:
- **Calibration is applied at ingest**, so changing gain or offset rebuilds the
buffers (and drops the history). Applying it at read time would need the LOD
pyramid to be gain-aware.
- **Cursors are per row** and not synchronised with each other; show/hide parks
them outside the axis range rather than removing them, because `Plot` has no
`remove_cursor`. The trigger marker is the one exception: it has its own cursor
and is independent of the cursor toggle.
- **The horizontal rulers are per row**, since a y value means nothing without the
y axis it is on; the vertical pair is shared, so the same two times are on every
row. Show/hide *parks* every instrument outside the axis range rather than removing
it, because `Plot` has no `remove_cursor` — which is why switching mode re-places
rather than rebuilds, and why the frame tick reads the markers back: a parked
cursor's snapped position is a real number, so nothing may be inferred from it. The
trigger marker is separate: its own cursor, independent of the cursor toggle.
- **Verified headless only.** Acquisition, partitioning, the trigger state
machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
the right-click menu) were not driven by an automated test.
**A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints
`TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a
forked child of the text/atlas cases (`a_backend_without_an_atlas_uploads_nothing` is the
one that precedes it), not from a failing test: the suite still reports `355 cases,
0 failed`, and this is pre-existing rather than something the cursors work introduced. The
new ruler test asserts the *strokes* (`figure_rows_draw_their_own_horizontal_rulers`) with
the harness's no-atlas backend, so it neither needs nor trips the text path.