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:
1 parent
c38630643d
commit
749b34a2cb
7 files changed
+114
-1
No files matched your search
@@ -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
|
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.
|
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
|
### 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
|
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:
|
- **dropout** — the *absence* of a crossing for longer than a timeout. One event per outage:
|
||||||
the condition re-arms only when a crossing returns.
|
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
|
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
|
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.
|
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
|
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`,
|
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
|
`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.
|
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
|
**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
|
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
|
the filtered trace, the filter's history stays the last real samples it saw, and the trace picks
|
||||||
|
|||||||
@@ -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
|
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
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
|
- **`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
|
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
|
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||||
|
|||||||
@@ -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 `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
|
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.
|
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.**
|
- **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
|
`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
|
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||||
|
|||||||
@@ -76,6 +76,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
the pyramid for nothing.
|
the pyramid for nothing.
|
||||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||||
anything.
|
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.**
|
- **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
|
`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
|
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
|
`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=..
|
`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.
|
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
|
- **`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
|
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
|
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.
|
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
|
`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".
|
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
|
- **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
|
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.
|
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
|
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
|
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.
|
`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.**
|
- **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
|
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
|
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||||
|
|||||||
Reference in new issue
Block a user