From 749b34a2cb1ce4477c2bda5dce80bab212ec095d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 13 Oct 2026 16:05:28 +0200 Subject: [PATCH] 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. --- README.md | 51 ++++++++++++++++++- docs/claude/invariants-gtk-and-chrome.md | 8 +++ docs/claude/invariants-math-channels.md | 18 +++++++ docs/claude/invariants-measurements.md | 10 ++++ .../claude/invariants-readouts-and-cursors.md | 9 ++++ docs/claude/invariants-session-and-export.md | 7 +++ docs/claude/invariants-trigger-and-pinning.md | 12 +++++ 7 files changed, 114 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e7e50bf..3e64450 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,23 @@ to reject noise with, 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. +Two more options ride on it, both at their old behaviour by default: + +| option | what it is | unit | off | +|---|---|---|---| +| `noise` | the hysteresis band's half-width, as the next section describes | % of the window's peak-to-peak | `0` disables the band; the default `10` is the band the app has always counted with | +| `hf` | a one-pole low-pass over the window, before *any* statistic | Hz corner | `0` is off | + +The band is clamped to half the room between the split and the nearer extreme, so a 50 % band cannot invert +the thresholds. `hf` is the **one** place where a readout does not quote a stored sample: with `hf > 0` the +extremes, the mean and the levels come from a filtered copy of the window, so a ruler can sit at a level the +drawn trace -- which is raw -- never reaches. That is what filtering *is*; it must not be "fixed" by snapping +the value back to the nearest sample, and with `hf = 0` the old rule holds exactly. + +Both cost: a filtered window disables the two bucket fast paths, because a bucket is an aggregate of *raw* +samples and cannot be mixed into a filtered walk. A filtered window is still walked once and shared by every +measurement that asks for it, so a pane of five measurements at two corners costs two walks, not five. + ### Why a crossing is counted with hysteresis A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of @@ -993,6 +1010,24 @@ modes are unchanged, and only the question "did an event happen at this sample" - **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage: the condition re-arms only when a crossing returns. +**Noise rejection on the trigger's own input.** A *band* and a *reject* are offered beside the level. +`band` is a share of the channel's own span either side of the level -- a second, undecided threshold, so a +sample inside it decides nothing -- and `reject` is a one-pole low-pass corner in Hz, one state per +configured condition, recomputed when the corner or the sample interval changes. **Off is `band = 0` and +`reject = 0`**, which is bit for bit the trigger that was there before them. + +They apply to **edge** and **window** and *not* to **pulse width** or **dropout**. That is a deliberate +deviation from the design spec, which asked for every condition type: a pulse's width is measured from a +crossing to its return, so a band there would change the *measured width* rather than reject noise (the +documented pulse-width readings move with it), and a dropout is a measured *absence*, which a band cannot +describe -- there is no level to compare. + +The band and the reject are verified through `BufferSearch` hit counts rather than through +`[condition-probe]` latch times, also deliberately: `probe-readings.md` states that a `t0` is **not** an +assertion, being a stream stamp rebased by whichever rebuild last dropped history -- it read `1.7103`, +`1.6975` and `1.8719` on three consecutive runs of the same leg. The counts, and what each must read, are in +`probe-readings.md`. + A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a width to a window trigger. The condition is **not** persisted in the session file — the session has never persisted the trigger — so a restart comes up on an edge trigger. @@ -1112,9 +1147,23 @@ an *ordinary channel*: the model synthesises a signal entry for it, so an enable plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and another math channel can be its source. The operations are `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`, `band pass` (biquads, cutoffs in Hz against the -stored rate), `sum`, `difference`, `product` and `ratio`; the arithmetic ops take two inputs, which +stored rate), `de-noise` (a running median followed by a one-pole), `sum`, `difference`, `product` +and `ratio`; the arithmetic ops take two inputs, which is what makes branching — the sum of a raw and a filtered channel — expressible at all. +`de-noise` is **last in the dropdown on purpose**: a node's op is stored in the session as its index +through `math_op_at()`, so an op inserted among the others would reinterpret every node in every file written +before it. + +**`de-noise` is one node, not a median the user chains into a low pass.** Its window is `med_n` samples, odd +and 3..9 (the dialog offers only those, and the engine rounds to it anyway, so a hand-edited file loads), and +an impulse shorter than half the window cannot move the median at all; the one-pole at `fc` Hz then takes the +hash the median leaves behind, and `fc = 0` is the median alone. The order is the design: a pole *before* a +median feeds the impulse into the filter's state for its whole settling time, which is the thing the node +exists to avoid. It costs a per-sample insertion sort of at most nine doubles into a local, on the producer +thread where every other op runs -- `math cost:` reads **103 ns/slot** with a de-noise channel in the probe's +three graphs. + **A gap is a gap in a filtered trace, never the end of it.** A hole the resequencer reserved is NaN in the source, and the filters deliberately do not integrate it: the hole shows as a hole in the filtered trace, the filter's history stays the last real samples it saw, and the trace picks diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 7ea07c2..1cbeaaa 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -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 diff --git a/docs/claude/invariants-math-channels.md b/docs/claude/invariants-math-channels.md index 584769c..8b0778c 100644 --- a/docs/claude/invariants-math-channels.md +++ b/docs/claude/invariants-math-channels.md @@ -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 diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md index 316a084..a1c0b0b 100644 --- a/docs/claude/invariants-measurements.md +++ b/docs/claude/invariants-measurements.md @@ -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 diff --git a/docs/claude/invariants-readouts-and-cursors.md b/docs/claude/invariants-readouts-and-cursors.md index a6e9717..1ea179e 100644 --- a/docs/claude/invariants-readouts-and-cursors.md +++ b/docs/claude/invariants-readouts-and-cursors.md @@ -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 diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md index 8a12c53..6a6ae1b 100644 --- a/docs/claude/invariants-session-and-export.md +++ b/docs/claude/invariants-session-and-export.md @@ -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. diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md index 4211185..0efe476 100644 --- a/docs/claude/invariants-trigger-and-pinning.md +++ b/docs/claude/invariants-trigger-and-pinning.md @@ -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