docs: record the three noise-rejection controls and their invariants

README gains the three controls where each lives -- the trigger's `band`/`reject`, the
measurement's `noise`/`hf`, and the math channel's `de-noise` node -- each with its unit, what
"off" is, and what it costs, plus the two deliberate deviations from the design spec next to the
features they belong to: the band applies to `edge` and `window` only (a pulse's width is measured
from a crossing to its return, so a band there changes the measured width rather than rejecting
noise; a dropout is an absence with no level to compare), and the band is verified through
`BufferSearch` hit counts rather than `[condition-probe]` latch times, because probe-readings.md
states that a `t0` is not an assertion.

The invariant docs gain the rules a later change must not break: `EventCondition`'s two new fields
and the once-per-packet `configure(dt)` that must not reset; the crossing rule that a change is
between two *decided* sides; the measurement path's exception to "a readout quotes a stored
sample", why the band moves the edge decision only, why `measure_accumulate` loops samples outer,
and why a filtered window disables both bucket fast paths; that `MathOp` is append-only (the index
*is* the serialisation), that the median window is pooled in the runtime rather than held per node,
why the pole reuses `b0`/`z1`, and that median-then-pole is the design; the session's named
`noise`/`hf` keys and its thirteen-field node string that still reads twelve; and the two GTK
traps -- positionally brace-initialised dialogs, and a control that cannot apply being hidden
rather than disabled.
This commit is contained in:
Martino Ferrari committed 2026-10-13 16:05:28 +02:00
1 parent c38630643d
commit 749b34a2cb
7 files changed
+114 -1

No files matched your search

+8
View File
@@ -18,6 +18,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
added in the middle of either silently shifts every argument after it: add at the end of the widget group
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
says so as an out-of-bounds warning on `rows`.
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
+18
View File
@@ -241,6 +241,24 @@ and reading values off the widgets *inside* the editor cannot see that. It is th
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
A key whose target depends on an invisible selection state is how a graph gets destroyed by
accident; the `delete node` button stays the only way to remove a node.
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
("does this op read `fc` at all") and is what the dialog's corner row asks.
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
length, write position and fill, and the pool is sized in `math_prepare()`.
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
median a wide bump it cannot reject.
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
loads. The cap is what `double sorted[9]` in the step is sized for.
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
+10
View File
@@ -76,6 +76,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
the pyramid for nothing.
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything.
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
the last a different filter state.
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
@@ -32,6 +32,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`crosshair_interpolated_mode_blends_the_two_samples` and
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
y=..` agreeing with the pane's first value.
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
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 with `hf_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's `text=` go through
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
@@ -23,6 +23,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
was parsed into the last calibration record and the enable state was lost without a word.
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
hysteresis band the app has always counted with, and `hf = 0` is no filter.
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
- **The export is collected at click time, not in the file-dialog callback.** The live
view keeps scrolling while the chooser is up, so a late collection would write a
window the user never saw. The writer is picked by the chosen path's extension.
@@ -45,6 +45,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
the reject's coefficient at 1, which is a pass-through.
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
trigger is not supposed to see.
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
While an event is held, a capture is filling, or the view is paused, `tick()` calls
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and