From 13574bfcefcc7527daf280f29c469bef1d0610ef Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Tue, 6 Oct 2026 10:07:56 +0200 Subject: [PATCH] fix: a crossing is counted with hysteresis, so noise is not counted as cycles period, frequency and pulse duration counted every sign change about a single level, which is unusable on any real signal: the mid level is exactly where a waveform moves fastest, so an interferer faster than the fundamental recrosses it several times per true crossing. A 5 Hz sine with a 500 Hz tone at a tenth of the amplitude read 79 crossings for 9, period=0.025622 against 0.2. MeasureLevel gains a hysteresis band sized once in measure_set_level(): a tenth of the window's peak-to-peak, clamped to half the room to the nearer extreme so a threshold set near one stays usable. The band moves the edge decision only -- the sum/count split stays at the bare level, since that split is top/bottom and has a documented exact reading. The bucket fast path tests the band and the level separately rather than folding them, or it would refuse exactly the buckets a crossing passes through: clean cost is unchanged at raw=0.1% and 1.41 ms a recompute. tools/mock_udpstreamer.py gains --noise, defaulting to 0 so the exact probe assertions still hold. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 71 ++++++++++++++++--- README.md | 66 ++++++++++++++---- src/help/10-measurements.txt | 11 +++ src/measure.cpp | 78 +++++++++++++++++---- src/measure.hpp | 34 +++++++-- src/ui.cpp | 5 +- tools/mock_udpstreamer.py | 132 ++++++++++++++++++++++++++--------- 7 files changed, 321 insertions(+), 76 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ed8e759..46c7d8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,9 +170,12 @@ precedent, not an exception. waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the measurement-cost invariant below). A `raw` near `covered` means the walks went back to reading the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is - *legitimately* near `covered` for a window shorter than a bucket (64 samples) or a signal noisy - enough to straddle a level everywhere, so read it on the fast mock + *legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy + enough to cross a threshold everywhere, so read it on the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples. + `--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and + that is the interferer's own cost rather than a regression -- the figure to compare a change + against is the **clean** one. Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`: `UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting` with nothing bound and the axis at its default. The race-free reading is an earlier @@ -438,7 +441,14 @@ Two lines of the self-check output are assertions in disguise: plottable signal and lets the tick compute them, so what it prints is what a user reads. On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and `bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s - with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. `marks=` lists each mark with its kind, and the **mix is the point**: + with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS` + ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`. + **Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read + those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the + hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as + the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy -- + the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96 + ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**: `level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value *and* a marker at the first sample that reached it — two level marks at both extremes was the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with @@ -764,7 +774,7 @@ Two lines of the self-check output are assertions in disguise: so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. -- `[help] pages=14 parsed=14 shortest='Pause and play' 1434 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. +- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. - `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row, then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B` and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr, @@ -1568,13 +1578,19 @@ far the largest file. level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it to a sample, two buckets at most whatever the window's size. Pass two - (`measure_accumulate_bucket()`) folds a bucket whole only when it lies **strictly** on one side - of its level (`k.lo > level` or `k.hi < level`): then every sample decides the same side, none - is *at* the level, and an entering edge is exactly the bucket's first sample -- which is where - the raw walk would have found it, so this is exact and not an approximation. It refuses a - bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the - first *finite* sample of the new side and an aggregate cannot say which that is; the caller - then opens that bucket. Four things this rests on, each of which breaks it silently: + (`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one + side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and + none is *at* the level, so the fold is exact and not an approximation. It asks **separately** + whether the bucket enters a side of the hysteresis band (see the invariant below), because a + single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket + wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw + walk would have found it. It refuses a bucket that straddles the level, one that enters a side + somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first + *finite* sample of the new side and an aggregate cannot say which that is); the caller then opens + that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to + `measure_accumulate()`, which adds the same samples again, and sums added above the `return false` + double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must + be. Four things this rests on, each of which breaks it silently: * **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum. * **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls @@ -1593,6 +1609,39 @@ far the largest file. `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say anything. +- **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 + nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real -- + the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the + fundamental recrosses a single level several times per true crossing. Measured on a 5 Hz sine with + a 500 Hz tone at a tenth of the amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true + crossings counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against + `period=0.200188` with the band. Four rules, each of which was wrong first: + * **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather + than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket + path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi - + level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2 + (`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05. + * **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and + a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never + decides that side and the measurement reads nothing at all, which is worse than reading + something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band, + `enters=4 period=0.200000` with the clamp. + * **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean + above and below the threshold -- so a band there changes a documented exact reading. `top` on a + unit sine must still be `0.636620`. + * **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the + band is a parameter of the engine rather than a mode of it. + The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at + 1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A + `/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I + src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty + squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same + `enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion + that the fast path is a fast path and not an approximation. The app's reading is + `[measures-probe]` against `--noise 0.1`. + - **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/README.md b/README.md index 9113e59..9d6c639 100644 --- a/README.md +++ b/README.md @@ -201,13 +201,53 @@ measurement. `period` and `frequency` deliberately take **no options**: the engine counts *rising* crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could -only make that count wrong — a split near either extreme counts noise as crossings, and -counting both edges halves the period. +only make that count wrong — a split near either extreme leaves the hysteresis band below no room +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. A -crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal -that merely touches the level does not count every sample as an event. +signal in this window, and a level in volts would have to be typed for every signal. + +### 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 +the window's peak-to-peak either side of the split, and an edge is counted only once the signal +has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and +it is the difference between counting the waveform's cycles and counting its noise. + +Counting on one level was the first implementation and it fails on anything real, badly and +silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there +is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any +interferer faster than the fundamental recrosses the level several times per true crossing, and +each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the +amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving +`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely +noisy, it is a different order. With the band the same window reads `period=0.200188`. The band +holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`, +and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2. + +Three things about it are deliberate, and each was wrong first. + +- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare + level, because that split *is* `top` and `bottom` — the mean of the samples above and below the + threshold — and a band there changes a documented exact reading. `top` on a unit sine must still + be `0.636620`, 2/π. +- **The band is clamped to stay inside the data**, to half the room between the level and the + nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic + signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper + threshold *above every sample in the window*: the walk never decides the upper side and the + measurement reads nothing at all, which is strictly worse than reading something noisy. Measured + on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with + the clamp. +- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold + condition to `level ± h` would refuse every bucket lying inside the band — and those are + precisely the buckets a crossing passes through, so the raw walk would get a *share of the + window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always + be binned; only a side *change* whose position is inside the bucket is refused. The clean fast + mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the + 0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's. + +A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one +does not count every sample as an event. Each measurement's row carries **two badges**: its own colour (the same one its marks use on the plot) and the signal's chip, in the palette colour every other view names that signal @@ -258,18 +298,20 @@ Both walks go through it: the window's size. The two stamps the scan needed are read at the end rather than at every chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times. - **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`) - folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample - in it decides the same side, none is *at* the level, and an entering edge is exactly the - bucket's first sample, which is where the raw walk would have found it. Only the buckets that - **straddle** a level are opened, which on a clean waveform is one per crossing. + folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample + in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks + separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold + holds that crossing at its first sample, exactly, which is where the raw walk would have found + it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is + opened — on a clean waveform a bucket or two per crossing. The raw path is still there and still exact, for the window's partial edge buckets, for the samples produced since this frame's `acquire_all()` (bounded by one frame of production), and -for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads +for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal -noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else -changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly +noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing +else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly this: `raw` near `covered` means the pyramid bought nothing. ### What the math channels cost diff --git a/src/help/10-measurements.txt b/src/help/10-measurements.txt index 151dfa9..bb08408 100644 --- a/src/help/10-measurements.txt +++ b/src/help/10-measurements.txt @@ -24,6 +24,17 @@ set, with Save and Delete. • An invalid pass keeps its level marks and drops its time marks. A level is a property of the signal and outlives the window; a position inside the window does not. That is what stops the rulers blinking off at a mode change. +• A crossing is counted with hysteresis, not on a bare level: the thresholds are a tenth of + the window's peak to peak either side of the share, and an edge is counted only once the signal + has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is + exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a + single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the + amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third + of the waveform's own size. +• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme + usable: a fixed band there would sit above every sample and the measurement would read nothing. + A share near an extreme still leaves the band little room, so it rejects less — which is one + reason period and frequency offer no share to set. • A window with too few crossings reads invalid / -- rather than a plausible invented number. Frequency over a window holding one edge is the usual case. • frequency is reported in Hz and period/pulse duration in seconds; diff --git a/src/measure.cpp b/src/measure.cpp index 47598f6..6600f1e 100644 --- a/src/measure.cpp +++ b/src/measure.cpp @@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) { acc.open[e] = true; } +/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than +/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a +/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and +/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes +/// on anything less square than a sine. +constexpr double kHysteresisFrac = 0.10; + } // namespace +void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) { + const double span = s.hi - s.lo; + acc.level = s.lo + pct * span; + // Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span + // above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never + // decides the upper side and the measurement reads nothing at all, which is strictly worse than + // a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a + // fixed band, `enters=4 period=0.200000` with the clamp. + const double room = std::min(s.hi - acc.level, acc.level - s.lo); + acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room)); +} + void measure_scan(const MeasureChunk& chunk, MeasureStats& s) { // The chunk's own extremes first, in a loop that touches nothing but the values: *where* an // extreme is costs an index, and the caller turns the two that survive into stamps once at the @@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk, for (std::size_t i = 0; i < chunk.count; ++i) { const double v = static_cast(chunk.value[i]); if (!std::isfinite(v)) continue; + // The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and + // below the threshold, and a band here would move a documented exact reading. const int s = v > acc.level ? 1 : 0; acc.sum[static_cast(s)] += v; ++acc.count[static_cast(s)]; - // A sample *at* the level is on neither side, so it decides no crossing and the walk keeps - // the side it last decided. That is what stops a signal which merely touches the level from - // counting every sample as an edge. - if (v == acc.level) continue; - if (acc.have_side && s != (acc.side ? 1 : 0)) { - enter_side(acc, static_cast(s), time_of(chunk.offset + i)); + // The *edge* decision reads the band instead. A sample inside it is on neither side, so it + // decides no crossing and the walk keeps the side it last decided -- which is what stops a + // signal that merely wobbles about the level from counting every wobble as an edge. With + // `hysteresis == 0` this is the single-level test, `v == level` skip included. + int d; + if (v > acc.level + acc.hysteresis) { + d = 1; + } else if (v < acc.level - acc.hysteresis) { + d = 0; + } else { + continue; } - acc.side = s != 0; + if (acc.have_side && d != (acc.side ? 1 : 0)) { + enter_side(acc, static_cast(d), time_of(chunk.offset + i)); + } + acc.side = d != 0; acc.have_side = true; } } @@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k, // A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes // the first *finite* sample of the new side, and an aggregate cannot say which that is. if (k.count != k.span) return false; - // Strictly on one side, so every sample decides the same side and none is *at* the level -- - // which is what makes this exact rather than an approximation of the walk. + // The *sums* need only the bare level: strictly on one side of it, so every sample decides the + // same bin and none is *at* it, which is what makes the fold exact rather than an approximation. const bool above = k.lo > acc.level; const bool below = k.hi < acc.level; if (!above && !below) return false; const std::size_t e = above ? 1u : 0u; + // The band is deliberately *not* folded into the test above. Tightening the whole condition to + // `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side + // of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing + // passes through -- which hands the raw walk a share of the window rather than a bucket or two per + // crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant. + // Instead: a bucket on one side of the level can always be *binned*, and only a *side change* + // whose position is inside the bucket is refused. + const double top = acc.level + acc.hysteresis; + const double bot = acc.level - acc.hysteresis; + // Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a + // raw sample inside it decides none, so the walk state is left alone. + const bool reaches = above ? (k.hi > top) : (k.lo < bot); + const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u); + // A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere + // inside itself, and only the raw walk can say where. One wholly past the threshold holds it at + // the bucket's first sample, exactly, which is where the raw walk would have found it. + const bool fully_outside = above ? (k.lo > top) : (k.hi < bot); + // Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`, + // which adds the same samples again. Sums added above the refusal double-counted every opened + // bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be. + if (change && !fully_outside) return false; acc.sum[e] += k.sum; acc.count[e] += k.count; - if (acc.have_side && e != (acc.side ? 1u : 0u)) { - // The edge is the bucket's *first* sample: everything before it was on the other side and - // everything in the bucket is on this one, so that is where the raw walk would have found it. - enter_side(acc, e, time_of(k.offset)); + if (change) enter_side(acc, e, time_of(k.offset)); + if (reaches) { + acc.side = e != 0; + acc.have_side = true; } - acc.side = e != 0; - acc.have_side = true; return true; } diff --git a/src/measure.hpp b/src/measure.hpp index d7579d4..e635ab3 100644 --- a/src/measure.hpp +++ b/src/measure.hpp @@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu /// must adding a second measurement of the same type at the same level. struct MeasureLevel { double level = 0.0; + /// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal + /// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample + /// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count + /// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed + /// several times per true crossing by any interferer faster than the fundamental, because near + /// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a + /// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading + /// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`. + /// + /// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`, + /// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold -- + /// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi). + /// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level. + double hysteresis = 0.0; /// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what /// `bottom` has always reported), index 1 the side above it. double sum[2] = {0.0, 0.0}; @@ -170,6 +184,14 @@ struct MeasureLevel { bool open[2] = {false, false}; }; +/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share +/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule: +/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the +/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration` +/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every +/// sample in the window, which reads as nothing measured at all rather than as a noisy reading. +void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct); + /// Pass two: one chunk into every level at once. The usual case is a single level -- every /// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of /// measurements on one signal costs two walks, not two per measurement. @@ -182,10 +204,14 @@ void measure_accumulate(const MeasureChunk& chunk, /// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge /// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken. /// -/// False means the bucket straddles the level -- or holds a non-finite sample, which would make -/// the edge's position a guess -- and the caller must feed its raw samples to -/// `measure_accumulate()` for this level instead. That is the only place a measurement still -/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing. +/// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this +/// level instead. That is the only place a measurement still reads raw samples in bulk, and on a +/// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket +/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would +/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the +/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the +/// first test -- see the comment in the implementation for why refusing every bucket inside the band +/// costs a share of the window rather than a bucket. bool measure_accumulate_bucket(const MeasureBucket& bucket, const std::function& time_of, MeasureLevel& level); diff --git a/src/ui.cpp b/src/ui.cpp index 27645df..238d9a7 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -3125,7 +3125,10 @@ void update_measures(App& app, bool force) { if (w.ok && !w.pct.empty()) { w.level.assign(w.pct.size(), MeasureLevel{}); for (std::size_t k = 0; k < w.pct.size(); ++k) { - w.level[k].level = w.stats.lo + w.pct[k] * (w.stats.hi - w.stats.lo); + // The level *and* its hysteresis band, in the engine: how wide the band is is a property of + // how an edge is counted, not of the pane, and two expressions for it is how the walk and + // the bucket fast path end up disagreeing about where a crossing was. + measure_set_level(w.level[k], w.stats, w.pct[k]); } const auto accumulate = [&](const MeasureChunk& c) { measure_accumulate(c, time_of, w.level.data(), w.level.size()); diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py index f22f831..8115dfc 100644 --- a/tools/mock_udpstreamer.py +++ b/tools/mock_udpstreamer.py @@ -55,8 +55,9 @@ def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"): # The stream's clock: referenced by every data signal, so udpscope treats # it as the time base rather than a plottable channel. Its *own* time_mode # is what udpscope switches on, so `--time-mode first` has to land here. - signal_desc("time", rate=0.0, time_mode=time_mode, - time_idx=NO_TIME_SIGNAL, unit="s"), + signal_desc( + "time", rate=0.0, time_mode=time_mode, time_idx=NO_TIME_SIGNAL, unit="s" + ), signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit), signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit), signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit), @@ -66,10 +67,25 @@ def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"): return header(PKT_CONFIG, 0, len(payload)) + payload -def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY): +def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0): stamps = [t0 + i * dt for i in range(batch)] - sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps] - square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps] + # The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which + # is the shape that is hardest on anything counting crossings: near the sine's own mid level + # the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal + # recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and + # it is the default because the documented probe assertions are *exact* (`peak to peak=2`, + # `top=0.636620`) and any interferer moves the extremes. + if noise > 0.0: + sine = [ + math.sin(2.0 * math.pi * freq * t) + + random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t) + for t in stamps + ] + else: + sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps] + square = [ + 1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps + ] ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps] # In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer # stamps the first sample of the batch and says nothing about the rest. A scalar @@ -90,30 +106,58 @@ def main(): ap.add_argument("--batch", type=int, default=100, help="samples per packet") ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz") ap.add_argument("--duration", type=float, default=0.0, help="0 = forever") - ap.add_argument("--stream-scale", type=float, default=1.0, - help="stream seconds per wall second: 0.1 mimics a producer whose own " - "clock advances ten times slower than the wire delivers samples") - ap.add_argument("--declared-rate", type=float, default=-1.0, - help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") - ap.add_argument("--time-mode", choices=("full", "first"), default="full", - help="how the stream carries its clock: 'full' stamps every sample " - "(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first " - "sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the " - "rest by the declared cadence -- the shape a real MARTe2-style " - "producer with a single u64 clock has") - ap.add_argument("--unit", default="V", - help="the unit the data signals declare in CONFIG; '' declares none, which " - "is an ordinary stream (and what every math channel looks like) and is " - "the only way a probe can see the unitless readout formatting") - ap.add_argument("--clock-jitter", type=float, default=0.0, - help="per-packet stamp jitter, as a fraction of one packet's span: a device " - "that stamps each batch from its own clock does not land on a perfect " - "grid, so the stream's timestamps wobble about one. Non-cumulative " - "(the mean rate is unchanged); 0 = the ideal grid") + ap.add_argument( + "--stream-scale", + type=float, + default=1.0, + help="stream seconds per wall second: 0.1 mimics a producer whose own " + "clock advances ten times slower than the wire delivers samples", + ) + ap.add_argument( + "--declared-rate", + type=float, + default=-1.0, + help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'", + ) + ap.add_argument( + "--time-mode", + choices=("full", "first"), + default="full", + help="how the stream carries its clock: 'full' stamps every sample " + "(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first " + "sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the " + "rest by the declared cadence -- the shape a real MARTe2-style " + "producer with a single u64 clock has", + ) + ap.add_argument( + "--unit", + default="V", + help="the unit the data signals declare in CONFIG; '' declares none, which " + "is an ordinary stream (and what every math channel looks like) and is " + "the only way a probe can see the unitless readout formatting", + ) + ap.add_argument( + "--noise", + type=float, + default=0.0, + help="amplitude of an interferer added to `sine`: a tone at 100x the fundamental, " + "scaled by a fresh uniform random factor per sample. 0 = the ideal sine, which is " + "what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; " + "0.1 is the figure the measurement hysteresis is sized against", + ) + ap.add_argument( + "--clock-jitter", + type=float, + default=0.0, + help="per-packet stamp jitter, as a fraction of one packet's span: a device " + "that stamps each batch from its own clock does not land on a perfect " + "grid, so the stream's timestamps wobble about one. Non-cumulative " + "(the mean rate is unchanged); 0 = the ideal grid", + ) args = ap.parse_args() - dt = args.stream_scale / args.rate # stream seconds per sample - period = args.batch / args.rate # wall seconds per packet + dt = args.stream_scale / args.rate # stream seconds per sample + period = args.batch / args.rate # wall seconds per packet declared = args.rate if args.declared_rate < 0.0 else args.declared_rate time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY @@ -121,9 +165,19 @@ def main(): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("0.0.0.0", args.port)) sock.settimeout(0.001) - print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, " - "stream clock x%.3g, declared %.0f S/s, time-mode %s)" - % (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True) + print( + "mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, " + "stream clock x%.3g, declared %.0f S/s, time-mode %s)" + % ( + args.port, + args.rate, + args.batch, + args.stream_scale, + declared, + args.time_mode, + ), + flush=True, + ) peer = None counter = 1 @@ -143,7 +197,10 @@ def main(): started = time.monotonic() next_send = started sock.sendto(build_config(declared, time_mode, args.unit), peer) - print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True) + print( + "mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, + flush=True, + ) elif magic == MAGIC and ptype == PKT_DISCONNECT: print("mock udpstreamer: DISCONNECT", flush=True) peer = None @@ -166,11 +223,20 @@ def main(): pdt = dt if args.clock_jitter > 0.0: pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) - sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq, time_mode), peer) + sock.sendto( + build_data( + counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise + ), + peer, + ) counter += 1 t_stream += args.batch * pdt next_send += period - if args.duration > 0.0 and started is not None and now - started > args.duration: + if ( + args.duration > 0.0 + and started is not None + and now - started > args.duration + ): print("mock udpstreamer: done (%d packets)" % counter, flush=True) return