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

+50 -1
View File
@@ -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
+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