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