`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics accumulator keeps running on the hidden page -- which nothing tested before. It switches to Measure, forces one pass so there is a baseline, switches away to Signals and reads again two seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie about the tab: - it **enables its own signal**, because a signal starts disabled (enabling is what starts acquisition) and a probe that waited for one to be on would be reading another probe's side effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run; - it **assigns that channel to a plot**, because a channel with no trace leaves the live window *still*, and a still window is precisely the state `update_measures()` is supposed to skip. Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view pinned and only four passes in twelve seconds, none of them the tab's doing. Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape (`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and the re-measured warning figures. The invariants record the sidebar as a switcher above a stack with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it, that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen. `shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers. The README records the deviation from `SPECS.md` that names the panes as separate collapsible sections. Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`; `signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1) (sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon.
7.7 KiB
7.7 KiB
Invariants: readouts, cursors and the value formatter
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
Part of CLAUDE.md. These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- A readout quotes a stored sample, never a value between two of them. The hover
crosshair and the cursors pane's
mixvalues are one call,Plot::crosshair_value(), and it reports the nearer of the two samples bracketing the x (a tie reads the earlier). Upstream interpolated linearly, and that is wrong twice over here: ingest decimation is a boxcar average overtime_stridesamples, so a number between two stored samples is a second estimate laid over the first that nothing on screen or in an export holds; and past the sample spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The change is indeps/plotter, which keeps the blend asCrosshairValue::Interpolatedfor a host whose x is a quantity it is willing to interpolate — the nearest sample is the default and udpscope never states the other, so aset_crosshair_value_mode()call appearing here is a regression, not a feature. Both the mark and the number come from one call,Plot::crosshair_point(), which returns the quoted sample's own x as well as its y — so every mark this app draws for a readout is snapped to the sample it quotes. The library'sCrosshairRow::markerand the cursors pane'smixcrossing annotations (place_crossings(), which must usecrosshair_point()and notcrosshair_value()) are both placed from it. Two consequences that look like defects and are not: the mark sits beside the polyline at a deep zoom (one y feeding both the number and the dot is what keeps them from disagreeing), and a crossing dot sits a little off its own vertical cursor line — up to half a sample period, invisible at a whole-window view and the width of the panel at a few samples across, which is the only zoom at which anyone is asking which point it is. The line says where the marker is; the dot says which datum is quoted. The hover mark is also deliberately larger than a trace's own sample dots (Style::crosshair_marker_px9 againstmarker_px3), because a snapped mark lands among them and at their size reads as one more sample. The guards are the library'scrosshair_value_is_the_nearest_sample,the_crosshair_marker_snaps_to_the_sample_it_reports,crosshair_interpolated_mode_blends_the_two_samplesandthe_crosshair_mark_is_bigger_than_a_sample_dot, plus[cursors-probe]'sfirst mark at x=.. y=..agreeing with the pane's first value. - A measurement with a non-zero
hf_fcis the one exception, and it is inherent rather than a rounding error. Itsmin,max,peak to peak,average,topandbottomare computed from a one-pole-filtered copy of the window, so the quoted value need not equal any stored sample -- and withshow_on_plotits ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering is; it must not be "fixed" by snapping the readout to the nearest sample. Every measurement withhf_fc == 0, which is the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the window's first sample and never from history before the window, so a measurement never depends on data outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming from earlier samples. format_measure()is the one formatter for both panes, and its SI prefix does not depend on there being a unit. The cursors pane's values and the measurements pane'stext=go through it, so a change reaches both. The prefix ladder used to be insideif (!unit.empty()), which made two decimals of a small unitless value read0.00— the reading gone, not merely coarse — and that is a whole class of channels, not a corner:measure_unit()returns""for any signal whose CONFIG declares no unit, and a math channel never has one. The regression net could not see it becausetools/mock_udpstreamer.pydeclaredunit="V"for every data signal; it now takes--unit '', and that is the flag to run the measures and cursors probes under when touching this (text=[636.62m],● sine=585.24m). With no unit the prefix is written tight against the number —1.34m, not1.34 m— because the space exists to separate the number from its unit and a spacedmalone reads as metres; with one it stays spaced and the prefix joins the unit (636.62 mV). Below1e-12the ladder runs out, and%.2fwould print0.00again, so that range falls back to%.3g.- With the instruments off the readout blanks, and the panel stays on screen. Cursors-off makes the
pane insensitive rather than hiding it -- a panel you can still see is a panel you can read, and the
settings you are about to switch back on must not vanish while you look for them -- and every cell in
it is written
"--", the spelling an unavailable cell already uses. A readout quotes a stored sample; with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting a sample the cursors no longer name. Two spellings of one state in one grid would be the defect. - The cursors are read back from the plots only while a gesture is on that row.
Cursor::xis the free pointer position while a drag runs and the snapped value otherwise -- which is what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on every frame therefore adopts its own echo back, and -- because the vertical pair is one value shared by every row -- a row that has not been re-written yet hands the previous shared time straight back. That is the "drag it and it springs back" defect, and the fix isRowSet::sync_cursors(int drag_row): the tick passes the row whose controller reportsgrabbed_cursor() != kInvalid, and every other frame does nothing. The values are echoed back withCursorPlacement::Freefor the same reason -- the controller snaps at the end of the gesture (Controller::end_drag()), and snapping the echo fights the hand that is still moving the cursor. - The cursors' on/off is one flag on the
RowSet, never a copy per row. It is one piece of state (the toolbar toggle) and the mode is one value; a per-row copy is somethinginsert_row()'sclear_row(), or any rebuild, can clear behind the user's back, which is how a row added while the cursors were already on came up with its instruments parked while every other row showed them. The same goes for the vertical pair'sx: it is one stream time for the figure, so it lives on theRowSettoo, andRowkeeps only what is genuinely per row (the rulers' values, the traces, the colours). - Every instrument this app computed is out of the pointer's reach. The trigger marker and
the measurement marks are cursors created with
Plot::add_cursor()/add_h_ruler(), and a cursor is grabbable by default -- so each takesset_cursor_selectable(id, false). Parking it outside the axis is not enough: the controller holds a marker for the gesture's life and follows the pointer between frames, so a readout parked atkParkedcould still be dragged. The trigger marker says where the scope put the edge and a measurement mark says what a measurement measured; neither is something a user may move. The dump printsreadout instruments locked: N, which must be 1 -- a drag is not something a probe can perform, so the state is what is asserted.