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 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-06 10:07:56 +02:00
1 parent b4b26d3e31
commit 13574bfcef
7 files changed
+321 -76

No files matched your search

+60 -11
View File
@@ -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 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 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 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 *legitimately* higher 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 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. (`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 `--`: 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` `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 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. 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 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 `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 `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 *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 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 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 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. 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, - `[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` 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, 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 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 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 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 (`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one
of its level (`k.lo > level` or `k.hi < level`): then every sample decides the same side, none side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and
is *at* the level, and an entering edge is exactly the bucket's first sample -- which is where none is *at* the level, so the fold is exact and not an approximation. It asks **separately**
the raw walk would have found it, so this is exact and not an approximation. It refuses a whether the bucket enters a side of the hysteresis band (see the invariant below), because a
bucket that straddles, and a bucket holding a non-finite sample, because the raw walk takes the single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket
first *finite* sample of the new side and an aggregate cannot say which that is; the caller wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw
then opens that bucket. Four things this rests on, each of which breaks it silently: 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 * **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. 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 * **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 `measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
anything. 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.** - **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
+54 -12
View File
@@ -201,13 +201,53 @@ measurement.
`period` and `frequency` deliberately take **no options**: the engine counts *rising* `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 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 only make that count wrong — a split near either extreme leaves the hysteresis band below no room
counting both edges halves the period. 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. A signal in this window, and a level in volts would have to be typed for every signal.
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. ### 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 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 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 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. 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`) - **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 folds a bucket's sums 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 in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
bucket's first sample, which is where the raw walk would have found it. Only the buckets that separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
**straddle** a level are opened, which on a clean waveform is one per crossing. 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 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 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 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 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 noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
this: `raw` near `covered` means the pyramid bought nothing. this: `raw` near `covered` means the pyramid bought nothing.
### What the math channels cost ### What the math channels cost
+11
View File
@@ -24,6 +24,17 @@ set, with <b>Save</b> and <b>Delete</b>.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a • An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
property of the signal and outlives the window; a position inside the window does not. That is 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. what stops the rulers blinking off at a mode change.
• A crossing is counted with <i>hysteresis</i>, 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 <tt>invalid</tt> / <tt>--</tt> rather than a plausible • A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case. invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds; • <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
+63 -15
View File
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
acc.open[e] = true; 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 } // 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) { 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 // 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 // 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) { for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]); const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue; 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; const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v; acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)]; ++acc.count[static_cast<std::size_t>(s)];
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps // The *edge* decision reads the band instead. A sample inside it is on neither side, so it
// the side it last decided. That is what stops a signal which merely touches the level from // decides no crossing and the walk keeps the side it last decided -- which is what stops a
// counting every sample as an edge. // signal that merely wobbles about the level from counting every wobble as an edge. With
if (v == acc.level) continue; // `hysteresis == 0` this is the single-level test, `v == level` skip included.
if (acc.have_side && s != (acc.side ? 1 : 0)) { int d;
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i)); 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<std::size_t>(d), time_of(chunk.offset + i));
}
acc.side = d != 0;
acc.have_side = true; 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 // 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. // the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false; if (k.count != k.span) return false;
// Strictly on one side, so every sample decides the same side and none is *at* the level -- // The *sums* need only the bare level: strictly on one side of it, so every sample decides the
// which is what makes this exact rather than an approximation of the walk. // 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 above = k.lo > acc.level;
const bool below = k.hi < acc.level; const bool below = k.hi < acc.level;
if (!above && !below) return false; if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u; 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.sum[e] += k.sum;
acc.count[e] += k.count; acc.count[e] += k.count;
if (acc.have_side && e != (acc.side ? 1u : 0u)) { if (change) enter_side(acc, e, time_of(k.offset));
// The edge is the bucket's *first* sample: everything before it was on the other side and if (reaches) {
// everything in the bucket is on this one, so that is where the raw walk would have found it. acc.side = e != 0;
enter_side(acc, e, time_of(k.offset)); acc.have_side = true;
} }
acc.side = e != 0;
acc.have_side = true;
return true; return true;
} }
+30 -4
View File
@@ -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. /// must adding a second measurement of the same type at the same level.
struct MeasureLevel { struct MeasureLevel {
double level = 0.0; 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 /// 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. /// `bottom` has always reported), index 1 the side above it.
double sum[2] = {0.0, 0.0}; double sum[2] = {0.0, 0.0};
@@ -170,6 +184,14 @@ struct MeasureLevel {
bool open[2] = {false, false}; 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 /// 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 /// 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. /// 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 /// 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. /// *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 /// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
/// the edge's position a guess -- and the caller must feed its raw samples to /// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
/// `measure_accumulate()` for this level instead. That is the only place a measurement still /// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing. /// 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, bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of, const std::function<double(std::size_t)>& time_of,
MeasureLevel& level); MeasureLevel& level);
+4 -1
View File
@@ -3125,7 +3125,10 @@ void update_measures(App& app, bool force) {
if (w.ok && !w.pct.empty()) { if (w.ok && !w.pct.empty()) {
w.level.assign(w.pct.size(), MeasureLevel{}); w.level.assign(w.pct.size(), MeasureLevel{});
for (std::size_t k = 0; k < w.pct.size(); ++k) { 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) { const auto accumulate = [&](const MeasureChunk& c) {
measure_accumulate(c, time_of, w.level.data(), w.level.size()); measure_accumulate(c, time_of, w.level.data(), w.level.size());
+99 -33
View File
@@ -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 # 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 # 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. # is what udpscope switches on, so `--time-mode first` has to land here.
signal_desc("time", rate=0.0, time_mode=time_mode, signal_desc(
time_idx=NO_TIME_SIGNAL, unit="s"), "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("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("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), 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 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)] stamps = [t0 + i * dt for i in range(batch)]
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps] # The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps] # 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] 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 # 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 # 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("--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("--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("--duration", type=float, default=0.0, help="0 = forever")
ap.add_argument("--stream-scale", type=float, default=1.0, ap.add_argument(
help="stream seconds per wall second: 0.1 mimics a producer whose own " "--stream-scale",
"clock advances ten times slower than the wire delivers samples") type=float,
ap.add_argument("--declared-rate", type=float, default=-1.0, default=1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") help="stream seconds per wall second: 0.1 mimics a producer whose own "
ap.add_argument("--time-mode", choices=("full", "first"), default="full", "clock advances ten times slower than the wire delivers samples",
help="how the stream carries its clock: 'full' stamps every sample " )
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first " ap.add_argument(
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the " "--declared-rate",
"rest by the declared cadence -- the shape a real MARTe2-style " type=float,
"producer with a single u64 clock has") default=-1.0,
ap.add_argument("--unit", default="V", help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'",
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 " ap.add_argument(
"the only way a probe can see the unitless readout formatting") "--time-mode",
ap.add_argument("--clock-jitter", type=float, default=0.0, choices=("full", "first"),
help="per-packet stamp jitter, as a fraction of one packet's span: a device " default="full",
"that stamps each batch from its own clock does not land on a perfect " help="how the stream carries its clock: 'full' stamps every sample "
"grid, so the stream's timestamps wobble about one. Non-cumulative " "(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
"(the mean rate is unchanged); 0 = the ideal grid") "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() args = ap.parse_args()
dt = args.stream_scale / args.rate # stream seconds per sample dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet period = args.batch / args.rate # wall seconds per packet
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate 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 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.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port)) sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001) sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, " print(
"stream clock x%.3g, declared %.0f S/s, time-mode %s)" "mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
% (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True) "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 peer = None
counter = 1 counter = 1
@@ -143,7 +197,10 @@ def main():
started = time.monotonic() started = time.monotonic()
next_send = started next_send = started
sock.sendto(build_config(declared, time_mode, args.unit), peer) 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: elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True) print("mock udpstreamer: DISCONNECT", flush=True)
peer = None peer = None
@@ -166,11 +223,20 @@ def main():
pdt = dt pdt = dt
if args.clock_jitter > 0.0: if args.clock_jitter > 0.0:
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) 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 counter += 1
t_stream += args.batch * pdt t_stream += args.batch * pdt
next_send += period 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) print("mock udpstreamer: done (%d packets)" % counter, flush=True)
return return