diff --git a/CLAUDE.md b/CLAUDE.md
index acd3dbc..312d49e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,7 +57,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| [invariants-rendering.md](docs/claude/invariants-rendering.md) | How the renderer maps a visible range onto samples (never the `dx()` grid), what `gather()` emits and relative to which origin, why every series needs a real `MinMaxPyramid`, and why a dense trace is drawn one pixel column at a time. |
| [invariants-trigger-and-pinning.md](docs/claude/invariants-trigger-and-pinning.md) | The event window counted in stream seconds end to end, what pins an axis in each mode, and what each pin is measured *from* (the snapshot's own stamps, not the request). |
| [invariants-time-axis-and-window.md](docs/claude/invariants-time-axis-and-window.md) | **No plot interaction changes the time scale**; `apply_time_window()` is the only path that does; how the x labels pick their form; what a fit restores. |
-| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
+| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted with hysteresis, and which mean over a split is which. |
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
diff --git a/README.md b/README.md
index 6b8578c..40baf19 100644
--- a/README.md
+++ b/README.md
@@ -236,6 +236,8 @@ measurement.
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
| `peak to peak` | the swing, `max - min` — the extremes, ripple and overshoot included | — | a ruler at each extreme |
| `amplitude` | the two flat levels' delta, `top - bottom` | — | a ruler at each flat level |
+| `above` / `below` | the mean of the samples **above** / **below** a share — a mean *over a side*, not a level (see below) | split % | a ruler at the mean |
+| `between` | the mean of the samples **between** two shares | from %, to % | a ruler at the mean |
| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges |
| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle |
@@ -263,6 +265,39 @@ for it at all. Where the pyramid cannot summarize the window — an `hf`-filtere
shorter than a bucket — there is no density estimate, and the types fall back to the old
behaviour: the mean of each side of the split, and `max - min` for `amplitude`.
+**`above`, `below` and `between` are those side means, exposed.** The flat-level change kept the
+split's own two means as its *fallback* for a window the pyramid cannot summarize; these three
+types are that fallback, named, so the two different questions about one split can be asked side by
+side. On a unit sine `above 50` reads `0.636620` — 2/π, exactly what `top` reported before it became
+a level — where `top` reads `0.957988`; on the mock's band-limited square `above 50` reads
+`0.986496`, where the plateau is 1 and `top` is `0.999983`, because the mean *over a side* carries
+the edges' ring and the level the signal dwells at does not. `between` is new arithmetic in one
+sense only: it is the samples above the lower edge **and not** above the upper one, so it is a
+*difference of two levels* — no wider split can answer a band, because a band's two edges are two
+thresholds — and it rides the same bucket walk (a bucket wholly inside the band folds into both
+accumulators whole), which is why `measure cost:`'s `raw` is unchanged by it. The readings, on the
+fast mock at a 2 s window, `[bandavg-probe]`: sine `above 50` `0.636620`, `below 50` `-0.636620`,
+`between 50 75` `0.255873` (3(2−√3)/π — neither the window's mean nor the band's edge); rippled
+square `above 50` `0.986496` and `between 50 75` `0.302744` (the two edges' transitions);
+`ramp` `between 50 75` `0.250000` and `above 50` `0.500000`, because a uniform density makes every
+band's mean that band's **own centre**; ideal square (`--square-harmonics 0`) `above 50` `1.000000`
+and `between 50 75` `--`, because every sample is exactly ±1 and the band holds none.
+
+Three things to know before reading one of those numbers:
+
+- **The pair is ordered on read, not refused.** "between 80 % and 20 %" is the band between 20 % and
+ 80 %: the two shares name two *edges* and the band does not depend on which was typed first, so a
+ reversed pair is normalized rather than turned into a silent `--`.
+- **Nothing to measure reads `--`**, like every other type: a share at `100 %` has no sample above
+ it (the level is the window's own maximum) and a band whose interior holds none — the ideal
+ square above — is `invalid`/`--`, never `0`.
+- **The sixth decimal follows the window's boundary.** The live window is closed at both ends, so
+ its oldest and newest sample share a phase, and one extra sample out of ~2.6 M moves the mean by
+ about one unit in the sixth decimal: over one run `above 50` on the sine printed `0.636620` on
+ most `[measure-trace]` lines and `0.636621` on a few, `above 50` on the square `0.986496` on most
+ and `0.986495` on the rest. The old `top`/`bottom` spread exactly the same way — it is a property
+ of a mean over a finite window, not of these types.
+
`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 leaves the hysteresis band below no room
@@ -272,7 +307,7 @@ The split is a share of the **window's own** min..max, not volts: a measurement
signal in this window, and a level in volts would have to be typed for every signal. For
`top`/`bottom` it says **which side** the level is read from, so any split between the two
plateaus reads the same number — it is no longer a mean *over* the side, which is the reading a
-ringing edge used to drag.
+ringing edge used to drag — and for `above`/`below` it is the level that mean is taken about.
Two more options ride on it, both at their old behaviour by default:
@@ -337,7 +372,9 @@ Each measurement's row carries **two badges**: its own colour (the same one its
the plot) and the signal's chip, in the colour every other view names that signal with —
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
-`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
+`average`, `min` and `max`, the side row only for `pulse duration`, the second share ("to % of the
+range") only for `between`, which is the one type whose two edges are both parameters, and the
+words on the split row name its side ("above % of the range" for `above`) — and the "show on plot"
line says what would be drawn.
The values are **window statistics over exactly the visible range, at the stored rate**: the
@@ -382,7 +419,8 @@ Both walks go through it:
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
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 crossing types — `pulse duration`, `period`, `frequency` — and, as a fallback
+- **Pass two** (the crossing types — `pulse duration`, `period`, `frequency` — the band averages,
+ and, as a fallback
for a window with no histogram, `top`/`bottom`)
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
@@ -396,12 +434,17 @@ samples produced since this frame's `acquire_all()` (bounded by one frame of pro
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
exactly `2` on a unit sine, and the shared pass that carries it is untouched — `top` has no
*exact* reading to protect any more, because it is a flat level read from a density rather than
-the half-wave mean it used to be (`0.636620` on a unit sine; it is `0.957988` now, near the peak,
-because a sine has no plateau). A window shorter than a bucket, or a signal
+the half-wave mean it used to be (that mean is now `above 50`, which reads `0.636620` on a unit
+sine where `top` reads `0.957988`, near the peak, because a sine has no plateau). A window shorter
+than a bucket, or a signal
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 — and `raw` is unchanged by the flat
-levels, which read the *same* bucket list a second time and never a sample.
+levels, which read the *same* bucket list a second time and never a sample, and by the band
+averages for the same reason (`between` is a **second level in the same walk**: with the
+measurements probe adding one of every type at a 2 s window, `raw` went from `113966` of
+`240719031` covered — `0.0%` — with ten types to `134978` of `255372033` — `0.1%` — with
+thirteen).
### What the math channels cost
diff --git a/docs/claude/invariants-measurements.md b/docs/claude/invariants-measurements.md
index 9358f82..d6e4844 100644
--- a/docs/claude/invariants-measurements.md
+++ b/docs/claude/invariants-measurements.md
@@ -1,6 +1,6 @@
# Invariants: the measurements pane
-Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
+Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted, and which mean over a split is which.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
@@ -66,9 +66,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
- more: it is a density estimate and not a mean, and the two places that used to assert
- `top == 0.636620` on a sine now assert what the flat level actually reads -- see the flat-level
- invariant below. Everything above is about the *bucket* path being a fast path and not an
+ more: it is a density estimate and not a mean, so the assertion that used to be
+ `top == 0.636620` on a sine now belongs to `above 50`, which *is* a mean over a side and reads
+ exactly 2/pi again -- see the band-average invariant below, and the flat-level one for what
+ `top` reads instead. Everything above is about the *bucket* path being a fast path and not an
approximation, and that is unchanged.)
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
@@ -139,6 +140,60 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
the pyramid cannot summarize.
+- **`Above`/`Below`/`Between` are the *means over a side of a share*, and they are the readings
+ `Top`/`Bottom` gave before a flat level replaced them.** That change kept the split's two means as
+ its *internal fallback* for a window the pyramid cannot summarize (above); these three types are
+ that fallback, exposed and named, which is what lets the two different questions about one split be
+ asked side by side -- on a unit sine `above 50` reads `0.636620` (2/pi) where `top` reads
+ `0.957988`, and on the mock's band-limited square `above 50` reads `0.986496` where the plateau is
+ `1.000000` and `top` `0.999983`: the edge's ring is *inside* this mean and not inside that level.
+ Five rules, and the first two are what a reader is most likely to assume wrongly:
+ * **A band is not a wider split, and needs no walk of its own.** `Above`/`Below` are
+ `MeasureLevel::sum[1]`/`count[1]` and `sum[0]`/`count[0]` at the measurement's own share.
+ `Between` is the **difference of two levels' upper sides** -- `sum[1](lo) - sum[1](hi)` over
+ `count[1](lo) - count[1](hi)`, i.e. the samples above the lower edge and *not* above the upper
+ one -- and no single level's two sums can bound a band, because a band's two edges are two
+ thresholds. Both accumulators come out of one pass two over one window (the pane registers one
+ level per edge, `w.pct`, and both are deduplicated into it), so the difference is exact rather
+ than approximate. `v == level` is the below side, as everywhere, so the band is `(lo, hi]`.
+ * **It rides the bucket fast path unchanged.** A bucket wholly inside the band is strictly above
+ the lower level *and* strictly below the upper one, so both accumulators fold it whole; only the
+ buckets that straddle either edge are opened, exactly as for one level. `measure cost:` is the
+ guard: adding all three types to a pane of every type left `raw` at `0.1%` of `covered`
+ (`113966` of `240719031` with the ten types, `134978` of `255372033` with the thirteen), and a
+ band is *two entries of the level list*, not a second pass.
+ * **`measure_level_pcts()` is the only place the shares are read**, and it clamps both to `[0,100]`
+ and puts `Between`'s pair **in order**: "between 80 % and 20 %" is the band between 20 % and
+ 80 %. Normalized rather than refused because the shares name two *edges*, and the band does not
+ depend on which the user typed first; the pane's dedup key and both accumulators are keyed on the
+ ordered pair, so one band is one walk however it was written.
+ * **Nothing to measure reads `--`**, exactly as for every other type: a side holding no sample (a
+ share at `100 %`, whose level is the window's own maximum) and an *interior* holding none are
+ both `invalid`/`--` and never `0`. The ideal `--square-harmonics 0` square is where the second
+ shows -- every sample is exactly +/-1, so the band `(0, 0.5]` holds nothing -- against the same
+ band on the band-limited square, which holds the edges' transitions (`between 50 75` =
+ `0.302744`, `between 25 75` = `0.000000`: the symmetric band's two halves cancel, the transition
+ being odd about its own midpoint).
+ * **The reading is a mean over the window's *samples*, so its sixth decimal follows the window's
+ boundary.** A live window is closed at both ends, so its oldest and newest sample share a phase
+ and the mean carries one sample that is not in an open interval -- about one unit in the sixth
+ decimal out of 2.6 M samples. Measured over one fast-mock run, `[measure-trace]`: `above 50` on
+ the sine printed `0.636620` on 48 lines and `0.636621` on 2, `below 50` `-0.636620` on 37 and
+ `-0.636619` on 18, `above 50` on the square `0.986496` on 32 and `0.986495` on 18, and
+ `between 50 75` on the ramp `0.250000`/`0.250001`/`0.249999`. The old `Top`/`Bottom` spread the
+ same way -- it is a property of a mean over a finite window, not of these types, and a reading
+ that has to be exact to six figures is read off `[bandavg-probe]`'s nine.
+ Measured on the fast mock, 2 s window, the mock's default 30-harmonic square, `[bandavg-probe]`
+ (nine significant digits, then what the pane prints):
+ `above 50` on the sine `0.636620262` -> `636.62 mV`; `below 50` on the sine `-0.636619283`;
+ `between 50 75` on the sine `0.255873597` -- the analytic `3(2-sqrt 3)/pi = 0.255873`, which is
+ neither the window's mean (`0`) nor the band's edge (`0.5`); `above 50` on the square
+ `0.986494746` / `0.986495505` (one or two samples at the window's end); `between 50 75` on the
+ `ramp` `0.250000385` and `above 50` `0.500000` -- a uniform density makes every band's mean that
+ band's own centre, so a quarter and a half of the range are the shape's prediction and not a
+ coincidence; `above 100` on the `ramp` `--`; and the ideal square's `between 50 75` `--`.
+ Editing the sine's band through the dialog to `50/90` takes it to `0.431361697`
+ (`0.8 / (2 asin 0.8)`), which is the check that the second share is the one the engine reads.
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
@@ -170,12 +225,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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`.** Two things read it and neither may be
+ * **The `sum`/`count` split stays at the bare `level`.** Four things read it and none may be
moved by a band: a crossing's *side* is decided by the sample's own value against the level
- (`v == level` is the below side, as it always was), and `Top`/`Bottom` fall back to the mean of
- each side on a window with no bucket histogram. The band is about *deciding a crossing*, not
- about where a sample lives, so folding it in would change a filtered window's `top` for no gain
- to any crossing count.
+ (`v == level` is the below side, as it always was), `Top`/`Bottom` fall back to the mean of
+ each side on a window with no bucket histogram, and `Above`/`Below`/`Between` read it on
+ *every* window -- their whole definition is that split, so a band folded into it would move a
+ documented exact reading (2/pi on a sine) for no gain to any crossing count. The band is about
+ *deciding a crossing*, not about where a sample lives.
* **`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
diff --git a/docs/claude/invariants-session-and-export.md b/docs/claude/invariants-session-and-export.md
index 654e1b6..550e0ea 100644
--- a/docs/claude/invariants-session-and-export.md
+++ b/docs/claude/invariants-session-and-export.md
@@ -42,9 +42,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
was parsed into the last calibration record and the enable state was lost without a word.
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
-- **Measurement options persist under *named* keys -- `noise` and `hf` -- so a file written before them
- loads at the defaults**, which are exactly the behaviour it was written against: `noise = 10` is the
- hysteresis band the app has always counted with, and `hf = 0` is no filter.
+- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
+ file written before them loads at the defaults**, which are exactly the behaviour it was written against:
+ `noise = 10` is the
+ hysteresis band the app has always counted with, `hf = 0` is no filter, and a file with no
+ `threshold2` -- every file written before `between` existed -- leaves that measurement at
+ `threshold2 = 75`, which is the value the dialog offers a new one. The *pair* is stored as written
+ rather than as ordered: `measure_level_pcts()` is what puts a reversed pair in order on read, so a
+ file records what the user typed and a measurement file round-trips unchanged.
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 1b8a4d8..1a80b10 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -218,6 +218,9 @@ Two lines of the self-check output are assertions in disguise:
pane_h=H switch_on=X reset_sensitive=Y`
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
plottable signal and lets the tick compute them, so what it prints is what a user reads.
+ The dump line carries one more field for the one type that reads *two* shares:
+ `measure N: 'sig' between thr=50.0% thr2=75.0% above=1 show=1 -> ok ...`, where `thr2` appears
+ only for `between` (see the band-average probe below).
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and
`bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**,
`pulse duration` ≈ 0.5 s
@@ -226,7 +229,9 @@ Two lines of the self-check output are assertions in disguise:
window rather than from a mean over a side, and a sine has no plateau — so the densest band
sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give.
`amplitude` is that pair's delta (`top - bottom`), which is why it reads about 1.92 rather than
- the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The measured
+ the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The mean over a half
+ is not gone, though: it is `above 50` on this stream (and `-above 50` is `below 50`), and its
+ own probe asserts it — see `[bandavg-probe]` below. The measured
figures, and the reason they are what they are, are in
[invariants-measurements.md](invariants-measurements.md) — on the mock's `square` the
separation is the visible one (`top`/`bottom` `0.999983`/`-0.999983`, `amplitude` `1.999966`,
@@ -275,10 +280,15 @@ Two lines of the self-check output are assertions in disguise:
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
step still drags a mark *programmatically* and requires the next frame to put it back
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
- side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
+ band= side= show= shares=A/B` is the per-type behaviour: `period` and `frequency` must show **neither** the
split nor the side row (they compute from the mid level's rising crossings, and an option
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
- `pulse duration` a side. A measurement whose signal is not acquired, or
+ `pulse duration` a side. `band` is the *second* share's row, which only `between` shows -- it is
+ the one type whose two edges are both parameters -- and `shares` is what the two spin fields are
+ *showing*, read back out of the widgets: a share that is stored but not seeded into its field, or
+ a field that is built but not read back on apply, is then visible in this line rather than only in
+ the reading it produces. `above`/`below` show the threshold row and no band row, with their own
+ label (`above % of the range`). A measurement whose signal is not acquired, or
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
@@ -1040,6 +1050,70 @@ phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four)
- `phase=2` — after Reset. **`n` must be 0 or 1** (0 if the report ran before the next recompute,
1 if after); anything larger means Reset cleared nothing.
+## `[bandavg-probe]`
+
+`UDPSCOPE_PROBE_BANDAVG_MS`. Needs `UDPSCOPE_ENABLE_ALL_MS` ahead of it, and it must **not** be
+scheduled with `UDPSCOPE_PROBE_MEASURE_MS` or `UDPSCOPE_PROBE_MEASURES_MS`: all three add
+measurements to the same pane, and the later one would report the earlier one's rows as its own.
+Run it against the fast mock (`--rate 1300000 --batch 1000 --freq 1`) **at a 2 s window** — the
+figures below are shares of the range, and a window that holds a fifth of a cycle is a different
+question, not a worse answer. Its own sequence is about 12 s, so `UDPSCOPE_EXIT_AFTER_MS=17000`.
+
+It adds seven measurements, one per shape whose band mean can be computed by hand, and every one of
+them is a *mean over a side or a band* rather than a level. `--square-harmonics 30` and
+`--square-harmonics 0` are two runs — the second is the ideal step, where the band holds nothing:
+
+| measurement | shape | reading | why that shape |
+|---|---|---|---|
+| `above 50` | 1 Hz unit sine | `0.636620` | 2/pi, the figure the old `top` documented |
+| `below 50` | 1 Hz unit sine | `-0.636620` | its other side: exactly `-above 50` |
+| `between 50/75` | 1 Hz unit sine | `0.255873` | `3(2-sqrt 3)/pi`, and neither 0 (the window's mean) nor 0.5 (the band's edge) |
+| `above 50` | `square`, 30 harmonics / `0` | `0.986496` / `1.000000` | the reading that commit recorded as the *old* `top`; the ideal step has no ring to put inside it |
+| `between 50/75` | `square`, 30 harmonics / `0` | `0.302744` / `--` | the two edges' transitions the band holds; **every** sample is exactly +/-1 on the ideal step, so it holds nothing |
+| `between 50/75` | `ramp` | `0.250000` | uniform density: a band's mean *is* that band's centre |
+| `above 100` | `ramp` | `--` | a share at the top of the range has no sample above it |
+
+Four line kinds, and the reading is the third:
+
+```
+[bandavg-probe] waiting stored=2108000 of 2600000 slots for a 2.000 s window
+[bandavg-probe] readings window channel=0 lo=-1.000000 hi=1.000000 samples=2600000 levels=2
+[bandavg-probe] readings above 'sine' shares=50% -> ok 0.636620262 text=[636.62 mV]
+[bandavg-probe] edit opened band_row=1 shares=50.0/75.0 (as stored)
+[bandavg-probe] after delete measures=6 -> 5 (want one fewer)
+```
+
+- `waiting` — the probe reads nothing until the ring holds a **full window**: a share of a *partial*
+ window is a different number (and one that keeps moving while the ring fills), and the mock
+ delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a ring the probe has just re-enabled needs
+ seconds of wall clock before a 2 s window is there. The wait is read off the store
+ (`stored_slots()` against `window / dt`), not guessed from a stopwatch. **A report taken before it
+ finishes is the one reading this probe can produce that is not the share's** — the settled lines
+ are the ones to quote.
+- `window` — the window each reading was taken over, per channel: `lo`/`hi` are that window's own
+ extremes (so `lo`/`hi` of the split are visible here), `samples` is what it holds, and `levels`
+ is how many level accumulators the pane registered for that channel. **`between` on the sine makes
+ `levels=2` where `top`/`between` alone would make one**: a band is two entries of the level list,
+ and a second measurement of the same band does not add a third.
+- the value line — the pane's own numbers, nine significant digits and then the row's formatted text
+ (`text=`), with the shares it was given. `-> invalid` with `text=[--]` is the nothing-to-measure
+ reading (the last two rows of the table). The value carries the measurement's **unit**; the three
+ new types are signal-valued like `top`/`average`, so a sine reads `mV` and a quantity with no unit
+ reads the bare number.
+- `edit opened` — the dialog's second-share row for a `between`: **`band_row` must be 1** (only
+ `between` offers it) and `shares` must be the pair that was *stored*, read back out of the two
+ spin fields. The probe then sets them to `50/90` and saves, and the next report is the proof the
+ field is wired: the sine's `between` reads `0.431361697` (`0.8 / (2 asin 0.8)`), where neither of
+ its sides would.
+- `after delete` — the cycle's last leg through the same dialog: **one fewer** measurement, process
+ alive. The two shares are the thing a delete could drop on the floor.
+- The `[measure-trace]` line the probe starts carries the same five readings every 250 ms, which is
+ where the *sixth* decimal's spread is visible (see the band-average invariant in
+ [invariants-measurements.md](invariants-measurements.md)): `above 50` on the sine reads
+ `0.636620` on almost every line and `0.636621` on a few, `above 50` on the square `0.986496` on
+ most and `0.986495` on the rest. **A line's `--` on a *lagging* trace is the trace's, not the
+ reading's** — the pane is refreshed on a cadence, so quote an earlier line beside its own `view=`.
+
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index ad9f5fc..cc3635b 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -120,13 +120,45 @@ precedent, not an exception.
thing that reads a second time: `top`/`bottom`/`amplitude` fold a histogram out of the **same**
fetched bucket list, so they add buckets and not samples (measured: `raw=0.1%` and `2.1%` with
the interferer, against `0.1%`/`2.1%` before those types had a flat level, for 0.44→0.50 ms and
- 0.52→0.65 ms a pass -- the buckets are the budget).
+ 0.52→0.65 ms a pass -- the buckets are the budget). It is the guard on the band averages for the
+ same reason: `between` is a *second level in the same walk*, not a second pass, and measured on
+ the fast mock at a 2 s window with the measurements probe adding one of **every** type, `raw`
+ stayed at a fraction of a percent of `covered` —
+ `covered=240719031 buckets=3760828 raw=113966 (0.0%)` for the ten types before, against
+ `covered=255372033 buckets=3989978 raw=134978 (0.1%)` for the thirteen after, at 0.8 → 1.1
+ ns/covered sample (`data` 156.85 → 178.38 ms: the pane holds three more measurements and one more
+ level, and both walk the same fetched bucket list).
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
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
+- `[bandavg-probe]` (stderr, `UDPSCOPE_PROBE_BANDAVG_MS`) — the **band averages**:
+ `above`/`below`/`between`, the plain means over one side of a share and over a band, which are
+ *different numbers* from `top`/`bottom`'s flat levels (see the band-average invariant in
+ [invariants-measurements.md](invariants-measurements.md)). It adds seven measurements, on the three
+ shapes whose band means can be computed by hand, and the four figures to read are:
+
+ * the 1 Hz unit sine at a **2 s window**: `above 50` = **`0.636620`** (2/pi) and `below 50` =
+ **`-0.636620`** — the exact figures the old `top`/`bottom` documented, and the reason this type
+ exists at all; `between 50 75` = `0.255873` (`3(2-sqrt 3)/pi`), an interior mean no side and no
+ whole-window statistic reports;
+ * the mock's band-limited `square` (30 harmonics, its default): `above 50` = **`0.986496`**, the
+ reading the commit that made `top` a flat level recorded as the *old* `top`;
+ * the mock's `ramp`, whose density is uniform: `between 50 75` = `0.250000` and `above 50` =
+ `0.500000` — a band's mean *is* that band's centre, so the shape predicts the number;
+ * and the two nothing-to-measure readings: `above 100` on the `ramp` and `between 50 75` on
+ `--square-harmonics 0` (whose samples are all exactly +/-1) must be `invalid`/`--`, never 0.
+
+ **Run it at a 2 s window** (the probe waits for a full one before it reads anything — the mock
+ delivers about 0.5 MS/s of the 1.3 MS/s it declares, so a freshly enabled ring needs seconds of
+ wall clock), and **not** with `UDPSCOPE_PROBE_MEASURE_MS` or `UDPSCOPE_PROBE_MEASURES_MS`: all
+ three add measurements to one pane. `UDPSCOPE_EXIT_AFTER_MS=17000` covers its sequence. The
+ command, both mock flavours and the lines are in [probe-readings.md](probe-readings.md):
+ `--square-harmonics 30` for the `0.986496` and the `0.302744`, `--square-harmonics 0` for the
+ `1.000000` and the empty band.
+
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
@@ -691,8 +723,10 @@ have run. `--spike-every`/`--spike-amp`/
The three lines and what each must read are in `probe-readings.md`.
-`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
-in the same run. The noise and measurement probes both push onto `app.measures`, and the later one would read
+`UDPSCOPE_PROBE_NOISE_MS`, `UDPSCOPE_PROBE_MEASURE_MS`, `UDPSCOPE_PROBE_MEASURES_MS`,
+`UDPSCOPE_PROBE_BANDAVG_MS` and `UDPSCOPE_PROBE_MATH_MS` must **not** be scheduled
+in the same run. The noise, measurement, measurements and band-average probes all push onto
+`app.measures`, and the later one would read
the earlier one's rows as its own; the noise and math probes both write `app.math`, so the later one's graph
lands on top of the earlier one's and both sets of readings describe a state neither probe set up. The noise probe carries
its own measurement legs (see `probe-readings.md`), so nothing is lost by keeping them apart.
diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page
index 5ae36c9..bbf5719 100644
--- a/help/C/org.udpscope.app/measurements.page
+++ b/help/C/org.udpscope.app/measurements.page
@@ -26,8 +26,8 @@
-
The pane's + adds one; clicking a row opens the dialog where the signal,
- the type, the threshold share and the on-plot marks are set, with Save and
- Delete.
+ the type, the threshold share or shares and the on-plot marks are set, with
+ Save and Delete.
@@ -35,14 +35,32 @@
Worth knowing
- The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
- duration, period and frequency. top and bottom are the waveform's
+ The types are average, top, bottom, min, max, peak to peak, amplitude, above,
+ below, between, pulse duration, period and frequency. top and bottom are the waveform's
flat high and low levels: the level the signal dwells at, taken from the densest
band of the window on each side of the split. A band-limited edge overshoots and rings —
a nominal 2 V square reaches about ±1.18 at its edges — and a ripple or an overshoot is
therefore not what these read. amplitude is the delta between those two
levels, and peak to peak is the swing between the extremes (so on a rippled
square amplitude reads 2 V where peak to peak reads 2.36 V).
+ above, below and between are the opposite kind of
+ number: the plain mean of the samples above a share, below it, or between two
+ shares. That is a different question from top/bottom even about the
+ same split, and it is the older one — on a unit sine above 50% reads 0.64 V
+ (2/π, the mean of a half cycle) where top reads 0.96 V (near the peak, which is
+ where a sine dwells most), and on a band-limited square above 50% reads 0.99 V
+ where top reads the plateau, 1.00 V: the edge's ring is inside the first and not
+ inside the second. between takes two shares — from % and
+ to % — and reports the mean of the samples inside that band, which no single
+ split can answer; the order you type them in does not matter, since a band between 80 % and
+ 20 % is the band between 20 % and 80 %.
+ A mean over a side carries whatever is on that side, so these three are the right
+ numbers when you want the energy or the offset of part of the waveform,
+ and the wrong ones when you want the level a plateau sits at. Like every other measurement,
+ one with nothing to measure reads -- rather than a made-up number: a share at
+ 100 % has no sample above it, and a band can hold none at all — on an ideal
+ (non-ringing) square every sample is exactly ±1, so a band between 50 % and 75 % is
+ empty.
top and bottom also read near the peak on a
waveform that has no flat part, which is the honest reading rather than a failure: a unit
sine has no plateau, so the densest band is at its peak and these read about ±0.96 — not
@@ -95,7 +113,9 @@
not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
mean are exact. top, bottom and amplitude read the
density of the window out of that same summary — which is what tells a flat level
- from a ripple — so those three cost a second read of the summary and never of a sample.
+ from a ripple — so those three cost a second read of the summary and never of a sample, and
+ between's second share is one more level in the same summary walk rather than a
+ walk of its own.
Statistics adds a second line to every measurement showing
n, the mean, the standard deviation and the range. Each observation is one
recompute of the pane, so n counts windows measured, not samples —
@@ -117,6 +137,13 @@
bottom=-0.999983, amplitude=1.999966 against
peak to peak=2.358701 — the levels are the plateau's and the extremes are the
edges'. Over a 2 M-sample window those passes read about 0.1% of the samples.
+ The mean-over-a-side types on the same two windows: on the sine
+ above 50=0.636620, below 50=-0.636620 and
+ between 50 75=0.255873; on the rippled square above 50=0.986496 and
+ between 50 75=0.302744, against 1.000000 for the same
+ above 50 on an ideal (non-ringing) square. A ramp, whose density is
+ uniform, is the shape that names what a band mean is: above 50=0.500000
+ and between 50 75=0.250000, each band's own centre.
diff --git a/src/layout_store.cpp b/src/layout_store.cpp
index 822a8b2..7a77fcf 100644
--- a/src/layout_store.cpp
+++ b/src/layout_store.cpp
@@ -612,6 +612,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
m.options.threshold_pct = std::stod(value);
} catch (...) {
}
+ } else if (key == "threshold2") {
+ // The band's other edge. A file written before `Between` existed has no such key, and the
+ // struct's own default is what such a measurement then reads -- which is the same value the
+ // dialog offers a new one.
+ try {
+ m.options.threshold_pct2 = std::stod(value);
+ } catch (...) {
+ }
} else if (key == "noise") {
try {
m.options.noise_pct = std::stod(value);
@@ -752,6 +760,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
}
o << "\n[[measure]]\nsignal = " << toml_escape(m.signal) << "\ntype = " << kind
<< "\nthreshold = " << m.options.threshold_pct
+ << "\nthreshold2 = " << m.options.threshold_pct2
<< "\nabove = " << (m.options.above ? "true" : "false") << "\nshow = "
<< (m.options.show_on_plot ? "true" : "false") << "\nnoise = " << m.options.noise_pct
<< "\nhf = " << m.options.hf_fc << "\n";
diff --git a/src/measure.cpp b/src/measure.cpp
index db7810a..9ef7f4e 100644
--- a/src/measure.cpp
+++ b/src/measure.cpp
@@ -342,21 +342,36 @@ MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
return out;
}
-bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
+int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]) {
+ const auto share = [](double v) { return std::clamp(v, 0.0, 100.0) / 100.0; };
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
- pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
- return true;
+ case MeasureType::Above:
+ case MeasureType::Below:
+ pct[0] = share(options.threshold_pct);
+ return 1;
+ case MeasureType::Between: {
+ // The band's two edges, clamped and *ordered*: the shares name a lower and an upper edge, so
+ // "between 80 % and 20 %" is the band between 20 % and 80 %. Normalizing rather than refusing
+ // is the honest reading of a band -- the set of samples does not depend on which edge the user
+ // typed first -- and the two accumulators the pair is answered from are keyed on these ordered
+ // values, so the same band is one walk however it was written.
+ const double a = share(options.threshold_pct);
+ const double b = share(options.threshold_pct2);
+ pct[0] = std::min(a, b);
+ pct[1] = std::max(a, b);
+ return 2;
+ }
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
- pct = 0.5;
- return true;
+ pct[0] = 0.5;
+ return 1;
default:
- return false;
+ return 0;
}
}
@@ -373,11 +388,14 @@ const char* measure_type_name(MeasureType type) {
case MeasureType::PulseDuration: return "pulse duration";
case MeasureType::Period: return "period";
case MeasureType::Frequency: return "frequency";
+ case MeasureType::Above: return "above";
+ case MeasureType::Below: return "below";
+ case MeasureType::Between: return "between";
}
return "?";
}
-int measure_type_count() { return 10; }
+int measure_type_count() { return 13; }
MeasureType measure_type_at(int index) {
switch (index) {
@@ -390,15 +408,24 @@ MeasureType measure_type_at(int index) {
case 6: return MeasureType::Amplitude;
case 7: return MeasureType::PulseDuration;
case 8: return MeasureType::Frequency;
- default: return MeasureType::Period;
+ case 9: return MeasureType::Period;
+ // Appended rather than filed beside `Top`/`Bottom`, where they read best in the dropdown:
+ // `layout_store` writes *this* index into the session file, so renumbering would turn a saved
+ // `max` into an `above` on the next start -- silently, and only for users who had one.
+ case 10: return MeasureType::Above;
+ case 11: return MeasureType::Below;
+ default: return MeasureType::Between;
}
}
bool measure_uses_threshold(MeasureType type) {
return type == MeasureType::Top || type == MeasureType::Bottom ||
- type == MeasureType::PulseDuration;
+ type == MeasureType::PulseDuration || type == MeasureType::Above ||
+ type == MeasureType::Below || type == MeasureType::Between;
}
+bool measure_uses_band(MeasureType type) { return type == MeasureType::Between; }
+
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
bool measure_uses_noise(MeasureType type) {
@@ -408,8 +435,8 @@ bool measure_uses_noise(MeasureType type) {
bool measure_can_show_on_plot(MeasureType) { return true; }
-MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
- const MeasureOptions& options) {
+MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const levels[2],
+ MeasureType type, const MeasureOptions& options) {
MeasureResult out;
if (!b.valid) return out;
@@ -475,10 +502,10 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// is the value. The band is a *value* band, so the reading does not follow the window's
// alignment, and it excludes both a plateau's ripple and an edge's overshoot -- which is the
// whole point, because those are exactly what a mean over a side reports instead.
- double pct = 0.0;
- if (!measure_level_pct(type, options, pct)) return out;
+ double pct[2] = {0.0, 0.0};
+ if (measure_level_pcts(type, options, pct) == 0) return out;
const bool want_top = type == MeasureType::Top;
- const MeasureFlatLevels flat = measure_flat_levels(b, pct);
+ const MeasureFlatLevels flat = measure_flat_levels(b, pct[0]);
if (want_top ? flat.top_ok : flat.bottom_ok) {
out.primary = want_top ? flat.top : flat.bottom;
out.marks.push_back({true, out.primary});
@@ -487,11 +514,47 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
}
// No histogram: the mean of one side of the level, straight out of the second pass' running
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
- // an HF-rejected walk, and on those the split's own means are the honest reading.
- if (level == nullptr) return out;
+ // an HF-rejected walk, and on those the split's own means are the honest reading -- and they
+ // are what `Above`/`Below` report on *every* window.
+ const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = want_top ? 1 : 0;
- if (level->count[s] == 0) return out;
- out.primary = level->sum[s] / static_cast(level->count[s]);
+ if (lv == nullptr || lv->count[s] == 0) return out;
+ out.primary = lv->sum[s] / static_cast(lv->count[s]);
+ out.marks.push_back({true, out.primary});
+ out.valid = true;
+ return out;
+ }
+ case MeasureType::Above:
+ case MeasureType::Below: {
+ // The plain mean of the samples on one side of the share -- what `Top`/`Bottom` reported
+ // before they became flat levels, and a different question from them: a mean over a side
+ // carries the ripple and the edge's overshoot (0.986496 on the rippled square where the
+ // plateau is 1), which is exactly why the flat-level types exist beside these.
+ const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
+ const std::size_t s = type == MeasureType::Above ? 1 : 0;
+ if (lv == nullptr || lv->count[s] == 0) return out;
+ out.primary = lv->sum[s] / static_cast(lv->count[s]);
+ out.marks.push_back({true, out.primary});
+ out.valid = true;
+ return out;
+ }
+ case MeasureType::Between: {
+ // The band's interior: what lies above the lower edge and *not* above the upper one. Both
+ // accumulators split the same samples the same way -- pass two walks one bucket list and one
+ // filter for the whole window -- so the band is the *difference* of the two upper sides, and
+ // it is exact rather than approximate. It needs no third walk of its own, and the bucket
+ // fast path serves it unchanged: a bucket wholly inside the band is strictly above the lower
+ // level and strictly below the upper, so both accumulators fold it whole.
+ const MeasureLevel* lower = levels != nullptr ? levels[0] : nullptr;
+ const MeasureLevel* upper = levels != nullptr ? levels[1] : nullptr;
+ if (lower == nullptr || upper == nullptr) return out;
+ // Equal counts mean an empty interior: nothing lies in the band, so there is nothing to
+ // average -- the pane reads `--`, as it does for any other type with no samples on the side
+ // it was asked about. `lower` may not count *fewer*, which would say the two accumulators
+ // saw different samples.
+ if (lower->count[1] <= upper->count[1]) return out;
+ out.primary = (lower->sum[1] - upper->sum[1]) /
+ static_cast(lower->count[1] - upper->count[1]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
@@ -499,18 +562,19 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency: {
- if (level == nullptr) return out;
+ if (levels == nullptr || levels[0] == nullptr) return out;
+ const MeasureLevel& level = *levels[0];
// The pulse is one side of the mid level: its widths run from the edge that enters it to
// the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
// per cycle, whatever else the waveform does in between.
const std::size_t s = options.above ? 1 : 0;
- const double enter_span = level->last_enter[s] - level->first_enter[s];
- const double enter_period = level->enters[s] >= 2
- ? enter_span / static_cast(level->enters[s] - 1)
+ const double enter_span = level.last_enter[s] - level.first_enter[s];
+ const double enter_period = level.enters[s] >= 2
+ ? enter_span / static_cast(level.enters[s] - 1)
: 0.0;
if (type == MeasureType::PulseDuration) {
- if (level->widths[s] == 0) return out;
- out.primary = level->width_sum[s] / static_cast(level->widths[s]);
+ if (level.widths[s] == 0) return out;
+ out.primary = level.width_sum[s] / static_cast(level.widths[s]);
// Duty as width/period, not as pulse-time over the window: a window that starts or
// ends mid-pulse holds partial cycles, and counting only the complete ones against
// the whole span would report a duty that depends on where the window happened to
@@ -518,15 +582,15 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
// them -- a window holding one pulse reports the width alone.
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
out.has_secondary = enter_period > 0.0;
- out.marks.push_back({false, level->first_enter[s]});
- out.marks.push_back({false, level->first_enter[s] + out.primary});
+ out.marks.push_back({false, level.first_enter[s]});
+ out.marks.push_back({false, level.first_enter[s] + out.primary});
out.valid = true;
return out;
}
if (!(enter_period > 0.0)) return out;
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
- out.marks.push_back({false, level->first_enter[s]});
- out.marks.push_back({false, level->first_enter[s] + enter_period});
+ out.marks.push_back({false, level.first_enter[s]});
+ out.marks.push_back({false, level.first_enter[s] + enter_period});
out.valid = true;
return out;
}
diff --git a/src/measure.hpp b/src/measure.hpp
index a786e9d..ec72944 100644
--- a/src/measure.hpp
+++ b/src/measure.hpp
@@ -38,16 +38,37 @@ enum class MeasureType {
PulseDuration, ///< how long the signal spends on one side of the threshold
Period, ///< the time between two successive crossings of the threshold
Frequency, ///< 1 / period
+ /// The mean of the samples **above** `threshold_pct` -- the split's own upper mean, which is
+ /// what `Top` reported before a flat level replaced it. `Top`/`Bottom` are the level the signal
+ /// *dwells at* (a density question, so a ringing edge does not drag them); these two are the
+ /// plain mean of the samples on one side of the share, ripple and overshoot included, which is
+ /// a *different* question about the same split and the reason the two coexist: on a unit sine
+ /// this reads `0.636620` (2/pi) where `Top` reads `0.957988`, and on the mock's band-limited
+ /// square `0.986496` where its plateau is 1.
+ Above,
+ /// The same from the split's lower side.
+ Below,
+ /// The mean of the samples **between** `threshold_pct` and `threshold_pct2`: the band's
+ /// interior, which neither of the two means above can answer (one of them gives the two sides
+ /// and the other the whole window, because the band's two edges are not one split).
+ Between,
};
/// Type-specific behaviour. Every field is meaningful only for the types named below; the
/// others ignore it (an option that does nothing for a type is left out of the dialog
/// rather than stored and ignored).
struct MeasureOptions {
- /// `Top`/`Bottom`/`PulseDuration`/`Period`/`Frequency`: where the split is, as a share of
+ /// `Top`/`Bottom`/`PulseDuration`/`Above`/`Below`/`Between`: where the split is, as a share of
/// the window's own min..max. The middle by default, which is what a symmetric waveform
- /// wants; a 3.3 V logic signal with ringing wants it nearer the top.
+ /// wants; a 3.3 V logic signal with ringing wants it nearer the top. For `Above`/`Below` it is
+ /// the level the mean is taken on one side of; for `Between` it is the band's *lower* edge.
double threshold_pct = 50.0;
+ /// `Between`: the band's other edge, the same unit. The two are read through
+ /// `measure_level_pcts()`, which clamps both and puts the pair in order, so the band between
+ /// 80 % and 20 % is the band between 20 % and 80 % -- the shares name two *edges* and the order
+ /// they were typed in carries no meaning, which is why a reversed pair is normalized rather
+ /// than refused.
+ double threshold_pct2 = 75.0;
/// `PulseDuration`: which side of the threshold is "the pulse". `Period`/`Frequency`
/// use it as the edge direction, so one cycle is counted once.
bool above = true;
@@ -268,11 +289,13 @@ struct MeasureLevel {
/// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces
/// the walk exactly to a single level.
double hysteresis = 0.0;
- /// The samples either side of `level`, exactly: `Top`/`Bottom`'s reading on a window with no
- /// bucket histogram to read a flat level from (shorter than one bucket, or an HF-rejected
- /// walk). They are folded on every window -- pass two walks it for the crossing types anyway
- /// -- but a window that has a histogram is read from that instead, because a mean over a side
- /// reports the ripple and the overshoot rather than the level the signal dwells at.
+ /// The samples either side of `level`, exactly: the reading of `Above`/`Below` is one of these
+ /// two means, `Between`'s is the difference of two *levels'* upper sides, and `Top`/`Bottom` fall
+ /// back to them on a window with no bucket histogram to read a flat level from (shorter than one
+ /// bucket, or an HF-rejected walk). They are folded on every window -- pass two walks it for the
+ /// crossing types anyway -- but a window that has a histogram is read from that instead, because
+ /// a mean over a side reports the ripple and the overshoot rather than the level the signal
+ /// dwells at.
/// Index 0 is the side below the level (a sample exactly *at* it counts here), index 1 the
/// side above it.
double sum[2] = {0.0, 0.0};
@@ -326,13 +349,18 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function& time_of,
MeasureLevel& level);
-/// The level a type reads, as a share of the window's own min..max, or false when it reads none
-/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
-/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
-/// `measure_uses_threshold()`. `Amplitude` reads no level either: its value is the delta between
-/// the two flat levels, which the histogram answers, and the split's two means are only a
-/// fallback it takes from the window's own extremes -- see `compute_measure()`.
-bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
+/// The levels a type reads, as shares of the window's own min..max, **lower first**, or 0 when it
+/// reads none (the pure statistics, answered from `MeasureStats` alone). `pct` must hold two: a
+/// level type fills `pct[0]` and answers 1, `Between` fills both and answers 2.
+///
+/// `Top`/`Bottom`/`Above`/`Below` read the measurement's own threshold; the crossing types read the
+/// mid level, deliberately -- see `measure_uses_threshold()`. `Amplitude` reads no level either: its
+/// value is the delta between the two flat levels, which the histogram answers, and the split's two
+/// means are only a fallback it takes from the window's own extremes -- see `compute_measure()`.
+///
+/// This is the one place `Between`'s pair is clamped and ordered, so the pane's dedup key, the
+/// engine's two accumulators and the dialog all describe the same band whatever was typed.
+int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]);
/// The window's two flat levels, from the bucket histogram in `stats.flat`. `top` is the densest
/// band of the bins *above* `lo + pct * (hi - lo)`, `bottom` the same below it -- for a
@@ -352,10 +380,11 @@ struct MeasureFlatLevels {
};
MeasureFlatLevels measure_flat_levels(const MeasureStats& stats, double pct);
-/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
-/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
-/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
-MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level,
+/// The engine: one measurement's answer, O(1), from the two passes' results. `levels` is the
+/// accumulators `measure_level_pcts(type, options)` asked for, in the same order -- `nullptr` for
+/// the types that read none, and either entry may be null when the caller could not resolve it.
+/// The function reads nothing else, which is what keeps the answer independent of the plot.
+MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* const levels[2],
MeasureType type, const MeasureOptions& options);
/// A measurement type's name, as the pane's row and the dialog's dropdown spell it.
@@ -368,6 +397,10 @@ MeasureType measure_type_at(int index);
/// spacing of the crossings of the *mid* level, one rising edge per cycle, and offering a
/// threshold there only invites a setting that makes the count wrong.
bool measure_uses_threshold(MeasureType type);
+/// Whether a type reads a *pair* of shares, so the dialog shows its second share only then: the
+/// two edges of a band are one measurement's two parameters, and one field of the two is a
+/// measurement of a different band than the user asked for.
+bool measure_uses_band(MeasureType type);
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
/// the waveform is the pulse).
bool measure_uses_side(MeasureType type);
diff --git a/src/ui.cpp b/src/ui.cpp
index 7449a81..43b3485 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -1374,12 +1374,8 @@ GtkWidget* measures_row(App& app, std::size_t index, const Measure& m) {
gtk_widget_set_halign(kind, GTK_ALIGN_START);
gtk_widget_set_hexpand(kind, true);
gtk_label_set_ellipsize(GTK_LABEL(kind), PANGO_ELLIPSIZE_END);
- char tip[160];
- std::snprintf(tip, sizeof(tip), "%s\n%s", m.signal.c_str(),
- measure_uses_threshold(m.type)
- ? (m.options.above ? "the above-threshold side" : "the below-threshold side")
- : "");
- gtk_widget_set_tooltip_text(kind, tip);
+ const std::string tip = m.signal + "\n" + measure_row_summary(m);
+ gtk_widget_set_tooltip_text(kind, tip.c_str());
gtk_box_append(GTK_BOX(box), kind);
GtkWidget* value = gtk_label_new("--");
gtk_widget_set_halign(value, GTK_ALIGN_END);
@@ -1684,6 +1680,17 @@ void open_measure_dialog(App& app, int edit_index) {
GtkWidget* threshold_row = labelled_row("threshold %", threshold, &threshold_label);
gtk_box_append(GTK_BOX(box), threshold_row);
+ // `Between`'s second share: the dialog offers it only for the one type that reads two (see
+ // `measure_uses_band()`), and the label is retitled per type with the field above it. The 75 is
+ // `MeasureOptions::threshold_pct2`'s default -- a default the *reader* sees, so it is seeded here
+ // as well as in the struct.
+ GtkWidget* band = spin(0.0, 100.0, 1.0, 1, 75.0);
+ gtk_widget_set_tooltip_text(band,
+ "the band's other edge, the same share of the visible range");
+ GtkWidget* band_label = nullptr;
+ GtkWidget* band_row = labelled_row("to %", band, &band_label);
+ gtk_box_append(GTK_BOX(box), band_row);
+
GtkWidget* above = gtk_switch_new();
gtk_widget_set_halign(above, GTK_ALIGN_START);
gtk_widget_set_tooltip_text(above, "which half of the waveform is the pulse");
@@ -1714,8 +1721,9 @@ void open_measure_dialog(App& app, int edit_index) {
gtk_box_append(GTK_BOX(box), show);
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
- threshold, above, show, window,
- threshold_row, threshold_label, side_row, side_label,
+ threshold, band, above, show,
+ window, threshold_row, threshold_label, band_row,
+ band_label, side_row, side_label,
noise, noise_row, hf, hf_row,
sig_names};
app.measure_dialog = window;
@@ -1739,12 +1747,14 @@ void open_measure_dialog(App& app, int edit_index) {
}
}
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), editing->options.threshold_pct);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), editing->options.threshold_pct2);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(noise), editing->options.noise_pct);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(hf), editing->options.hf_fc);
gtk_switch_set_active(GTK_SWITCH(above), editing->options.above);
gtk_check_button_set_active(GTK_CHECK_BUTTON(show), editing->options.show_on_plot);
} else {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(threshold), 50.0);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(band), MeasureOptions{}.threshold_pct2);
gtk_switch_set_active(GTK_SWITCH(above), true);
}
g_signal_connect(type_drop, "notify::selected", G_CALLBACK(on_measure_type_changed), d);
@@ -5769,6 +5779,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_MEASURES_MS")) {
g_timeout_add(static_cast(std::atoi(env)), measures_probe_step, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_BANDAVG_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), bandavg_probe_step, &app);
+ }
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) {
g_timeout_add(static_cast(std::atoi(env)), fit_probe_step, &app);
}
@@ -6379,6 +6392,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
i, m.signal.c_str(), measure_type_name(m.type), m.options.threshold_pct,
m.options.above ? 1 : 0, m.options.show_on_plot ? 1 : 0,
r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0);
+ // The band's other edge, only for the type that reads two shares: the reading of a `between`
+ // is a function of the pair, so a line that printed one of them could not say what it is.
+ if (measure_uses_band(m.type)) std::printf(" thr2=%.1f%%", m.options.threshold_pct2);
// What the row *reads*: the formatted string with its unit (so `frequency` showing Hz
// is asserted here, not inferred), and the row's chip count (two: the measurement's
// colour and the signal's).
diff --git a/src/ui_app.hpp b/src/ui_app.hpp
index 4ef119e..132719d 100644
--- a/src/ui_app.hpp
+++ b/src/ui_app.hpp
@@ -584,6 +584,10 @@ struct App {
/// it is a window of its own, so the main window's widget tree does not reach its buttons.
GtkWidget* measure_dialog = nullptr;
int dbg_measure_step = 0;
+ /// The band-average probe's own step counter. Its own rather than the measurement pane's: the two
+ /// probes add measurements, so they must not be scheduled in one run (see verification.md), and a
+ /// shared counter would make that a silent mis-step rather than a documented prohibition.
+ int dbg_bandavg_step = 0;
int cursors_pane_mode = -1;
int cursors_pane_rows = -1;
unsigned cursors_pane_generation = ~0u;
@@ -1174,6 +1178,9 @@ struct MeasureDialog {
GtkWidget* signal_drop = nullptr;
GtkWidget* type_drop = nullptr;
GtkWidget* threshold = nullptr;
+ /// `Between`'s *other* edge: the same unit as `threshold`, shown only for the one type that reads
+ /// two shares (see `measure_uses_band()`).
+ GtkWidget* band = nullptr;
GtkWidget* above = nullptr;
GtkWidget* show = nullptr;
GtkWidget* window = nullptr;
@@ -1182,6 +1189,8 @@ struct MeasureDialog {
/// and the difference has to be in words, not just in visibility.
GtkWidget* threshold_row = nullptr;
GtkWidget* threshold_label = nullptr;
+ GtkWidget* band_row = nullptr;
+ GtkWidget* band_label = nullptr;
GtkWidget* side_row = nullptr;
GtkWidget* side_label = nullptr;
/// The two noise-rejection rows. `noise` is a percentage of the window's peak-to-peak, `hf` a corner in
@@ -1502,6 +1511,10 @@ void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offs
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
plot::u32 measure_color(std::size_t index);
+/// One line naming what a measurement's type is measuring, with the shares it reads them at: the
+/// pane's row tooltip. The row's own label is the type alone (as for every type), so this is where
+/// a user sees the share an `above`/`below`/`between` row is averaging over.
+std::string measure_row_summary(const Measure& m);
bool visible_samples(App& app, std::size_t channel, std::vector& time, std::vector& value);
int measure_channel(App& app, const std::string& signal);
int measure_row(App& app, const std::string& signal);
@@ -1731,6 +1744,10 @@ void measure_dialog_click(App& a, const char* label);
void measure_dialog_report(App& a, const char* what);
void measures_probe_next(App& a, int step, guint delay_ms);
gboolean measures_probe_step(gpointer user);
+void bandavg_probe_next(App& a, int step, guint delay_ms);
+void bandavg_report(App& a, const char* what);
+int bandavg_index(App& a, MeasureType type, const char* signal);
+gboolean bandavg_probe_step(gpointer user);
gboolean zoomkeys_probe(gpointer user);
void menus_probe_next(App& a, int step, guint delay_ms);
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel);
diff --git a/src/ui_measure.cpp b/src/ui_measure.cpp
index 56eb393..626530c 100644
--- a/src/ui_measure.cpp
+++ b/src/ui_measure.cpp
@@ -7,6 +7,7 @@
#include
+#include
#include
namespace udpscope {
@@ -21,6 +22,35 @@ plot::u32 measure_color(std::size_t index) {
return kPalette[index % (sizeof(kPalette) / sizeof(kPalette[0]))];
}
+/// One line naming what a measurement's type measures, with the shares it reads them at. The pane's
+/// row shows the type alone, as it does for every type, so this is the only place a reader sees
+/// *which* side of *which* split an `above`/`below` is averaging over, or how wide a `between`
+/// band is -- and the reading of those three is entirely a function of those two numbers.
+std::string measure_row_summary(const Measure& m) {
+ const auto pct_text = [](double v) {
+ char text[32];
+ std::snprintf(text, sizeof(text), "%g%% of the range", v);
+ return std::string(text);
+ };
+ switch (m.type) {
+ case MeasureType::Above:
+ return "the mean of the samples above " + pct_text(m.options.threshold_pct);
+ case MeasureType::Below:
+ return "the mean of the samples below " + pct_text(m.options.threshold_pct);
+ case MeasureType::Between:
+ return "the mean of the samples between " + pct_text(m.options.threshold_pct) + " and " +
+ pct_text(m.options.threshold_pct2);
+ case MeasureType::Top:
+ case MeasureType::Bottom:
+ return "the flat level on one side of the split at " + pct_text(m.options.threshold_pct);
+ case MeasureType::PulseDuration:
+ return std::string(m.options.above ? "the pulse above " : "the pulse below ") +
+ pct_text(m.options.threshold_pct);
+ default:
+ return std::string("the ") + measure_type_name(m.type) + " of this window";
+ }
+}
+
/// The channel a measurement's signal resolved to, or -1 while the stream does not carry it.
int measure_channel(App& app, const std::string& signal) {
std::lock_guard lock(app.model->mutex());
@@ -94,6 +124,8 @@ void measure_dialog_sync(MeasureDialog* d) {
const bool side = measure_uses_side(type);
gtk_widget_set_visible(d->threshold_row, threshold);
gtk_widget_set_visible(d->side_row, side);
+ // The band's second share: a `Between` is the one type whose two edges are *both* parameters.
+ gtk_widget_set_visible(d->band_row, measure_uses_band(type));
// The band moves the edge decision, so it is shown for exactly the crossing types. The HF reject sits
// before both walks, so it is shown for all of them.
gtk_widget_set_visible(d->noise_row, measure_uses_noise(type));
@@ -138,6 +170,24 @@ void measure_dialog_sync(MeasureDialog* d) {
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range (picks the side)");
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)");
break;
+ case MeasureType::Above:
+ case MeasureType::Below: {
+ // These *are* a mean over one side of the split -- the reading the old `top`/`bottom` gave --
+ // so the words have to say which side of which share, and they have to say it beside the
+ // field rather than only in the docs: the same spin means a different side for each type.
+ const bool above = type == MeasureType::Above;
+ gtk_label_set_text(GTK_LABEL(d->threshold_label), above ? "above % of the range"
+ : "below % of the range");
+ gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)");
+ break;
+ }
+ case MeasureType::Between:
+ // Two shares, so two rows: the band's edges, lower first (the engine orders a reversed pair
+ // rather than refusing it, so either order the user types reads the same band).
+ gtk_label_set_text(GTK_LABEL(d->threshold_label), "from % of the range");
+ gtk_label_set_text(GTK_LABEL(d->band_label), "to % of the range");
+ gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the mean)");
+ break;
case MeasureType::PulseDuration:
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
gtk_label_set_text(GTK_LABEL(d->side_label), "pulse above the level");
@@ -177,6 +227,7 @@ void on_measure_dialog_apply(GtkButton*, gpointer user) {
m.signal = d->signals[sig];
m.type = measure_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(d->type_drop)));
m.options.threshold_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold));
+ m.options.threshold_pct2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band));
m.options.above = gtk_switch_get_active(GTK_SWITCH(d->above));
m.options.show_on_plot = gtk_check_button_get_active(GTK_CHECK_BUTTON(d->show));
m.options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
@@ -308,6 +359,10 @@ void update_measures(App& app, bool force, bool accumulate) {
std::vector chan(app.measures.size(), -1);
std::vector want_window(app.measures.size(), -1);
std::vector want_level(app.measures.size(), -1);
+ // `Between` reads two levels, and they are two ordinary entries of the same list: one walk answers
+ // every level in the pane, so a band costs the same pass two as any other measurement plus one
+ // more accumulator in it.
+ std::vector want_level2(app.measures.size(), -1);
for (std::size_t i = 0; i < app.measures.size(); ++i) {
const Measure& m = app.measures[i];
const int channel = measure_channel(app, m.signal);
@@ -335,20 +390,26 @@ void update_measures(App& app, bool force, bool accumulate) {
m.type == MeasureType::Amplitude) {
w.flat_wanted = true;
}
- double pct = 0.0;
- if (!measure_level_pct(m.type, m.options, pct)) continue;
+ double pct[2] = {0.0, 0.0};
+ const int level_count = measure_level_pcts(m.type, m.options, pct);
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
// level, so a `Top` is the same walk whatever the band is. Grouping those at the default is what keeps
// the common pane at exactly one level.
const double noise =
measure_uses_noise(m.type) ? m.options.noise_pct : MeasureOptions{}.noise_pct;
- std::size_t k = 0;
- while (k < w.pct.size() && (w.pct[k] != pct || w.noise[k] != noise)) ++k;
- if (k == w.pct.size()) {
- w.pct.push_back(pct);
- w.noise.push_back(noise);
+ for (int j = 0; j < level_count; ++j) {
+ std::size_t k = 0;
+ while (k < w.pct.size() && (w.pct[k] != pct[j] || w.noise[k] != noise)) ++k;
+ if (k == w.pct.size()) {
+ w.pct.push_back(pct[j]);
+ w.noise.push_back(noise);
+ }
+ if (j == 0) {
+ want_level[i] = static_cast(k);
+ } else {
+ want_level2[i] = static_cast(k);
+ }
}
- want_level[i] = static_cast(k);
}
// The data work: two walks per channel, whatever the pane holds. The first fixes the window's
@@ -483,11 +544,15 @@ void update_measures(App& app, bool force, bool accumulate) {
continue;
}
const App::MeasureWindow& w = *wp;
- const MeasureLevel* level =
- want_level[i] >= 0 && static_cast(want_level[i]) < w.level.size()
- ? &w.level[static_cast(want_level[i])]
- : nullptr;
- const MeasureResult r = compute_measure(w.stats, level, m.type, m.options);
+ // In the order `measure_level_pcts()` asked for them: the band's lower edge first.
+ const MeasureLevel* levels[2] = {nullptr, nullptr};
+ const int want[2] = {want_level[i], want_level2[i]};
+ for (int j = 0; j < 2; ++j) {
+ if (want[j] >= 0 && static_cast(want[j]) < w.level.size()) {
+ levels[j] = &w.level[static_cast(want[j])];
+ }
+ }
+ const MeasureResult r = compute_measure(w.stats, levels, m.type, m.options);
app.measure_results[i] = r;
if (r.valid) app.measure_last_valid[i] = r;
// Time-valued types carry seconds and a frequency carries hertz whatever the signal's
@@ -646,11 +711,20 @@ void measure_dialog_report(App& a, const char* what) {
if (dd != nullptr) {
// What the dialog is offering for the type on screen: the option rows' visibility and
// the words on them, which is the per-type behaviour a user sees.
- std::snprintf(options, sizeof(options), "type=%d thr=%d side=%d show=%d '%s'",
+ std::snprintf(options, sizeof(options), "type=%d thr=%d band=%d side=%d show=%d "
+ "shares=%.1f/%.1f '%s'",
static_cast(gtk_drop_down_get_selected(GTK_DROP_DOWN(dd->type_drop))),
dd->threshold_row != nullptr && gtk_widget_get_visible(dd->threshold_row) ? 1 : 0,
+ dd->band_row != nullptr && gtk_widget_get_visible(dd->band_row) ? 1 : 0,
dd->side_row != nullptr && gtk_widget_get_visible(dd->side_row) ? 1 : 0,
dd->show != nullptr && gtk_widget_get_visible(dd->show) ? 1 : 0,
+ // What the two share fields are *showing*: the pair a `between` reads, read back
+ // out of the widgets, so a field that is built but not seeded or not read back is
+ // visible in the line rather than only in the number it produces.
+ dd->threshold != nullptr
+ ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(dd->threshold))
+ : 0.0,
+ dd->band != nullptr ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(dd->band)) : 0.0,
dd->show != nullptr && gtk_check_button_get_child(GTK_CHECK_BUTTON(dd->show)) !=
nullptr
? gtk_label_get_text(GTK_LABEL(gtk_check_button_get_child(
diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp
index 3fa29b4..38bd1b0 100644
--- a/src/ui_probe.cpp
+++ b/src/ui_probe.cpp
@@ -2842,6 +2842,225 @@ gboolean measures_probe_step(gpointer user) {
return G_SOURCE_REMOVE;
}
+/// The band-average readings, on the shapes whose band means can be computed by hand.
+///
+/// `above`, `below` and `between` are plain means of the samples on one side of a share -- what
+/// `top`/`bottom` reported before a flat level replaced them -- and a mean over a side is a
+/// *different number* from the level the signal dwells at, so each reading is asserted where the
+/// shape predicts it. Seven measurements are added on the mock's three scalar signals at once,
+/// because the trio's point is that a *pair* of levels answers a band no single split can:
+/// * the 1 Hz unit sine: `above 50` = 2/pi = 0.636620 and `below 50` = -2/pi, the figures the old
+/// `top`/`bottom` documented (and *not* `top`/`bottom`'s 0.957988, the level a sine dwells at
+/// near its peak), and `between 50 75` = 3(2-sqrt 3)/pi = 0.255873 -- the mean of the samples in
+/// the band (0, 0.5], which is neither 0 (the whole window's mean) nor 0.5 (the band's edge);
+/// * the mock's band-limited `square` (30 harmonics, its default): `above 50` = 0.986496, the
+/// reading the commit that made `top` a flat level recorded as the *old* `top` -- the edges'
+/// ring is inside this mean, and the plateau the signal dwells at is not;
+/// * the mock's `ramp`, whose density is uniform: every band's mean is that band's own centre, so
+/// `above 50` = 0.5 and `between 50 75` = 0.25 of the range -- a pair of figures no other
+/// measurement type on this shape reports (`average` is 0 there, `top`/`bottom` its edges).
+/// The dialog leg then edits the sine's band through the real widgets -- the second share is the
+/// one field only `between` offers -- and re-reads the value, so a share that is built but not
+/// seeded or not read back shows up as a reading that did not move.
+gboolean bandavg_probe_step(gpointer user) {
+ App* a = static_cast(user);
+ if (a->window == nullptr) return G_SOURCE_REMOVE;
+ switch (a->dbg_bandavg_step) {
+ case 0: {
+ // The three shapes are enabled *here* rather than assumed: `enable_all_probe` switches a
+ // signal off two seconds after it runs, and a probe that read another probe's leftovers
+ // would measure whatever was left. `set_signals_enabled()` is one structural change and a
+ // no-op when the state already holds, so the usual case costs nothing and the ring keeps
+ // its history; when it does rebuild, the report below is far enough out for a full window.
+ std::vector wanted;
+ {
+ std::lock_guard lock(a->model->mutex());
+ const std::vector& signals = a->model->signals();
+ for (std::size_t s = 0; s < signals.size(); ++s) {
+ if (signals[s].name == "sine" || signals[s].name == "square" ||
+ signals[s].name == "ramp") {
+ wanted.push_back(s);
+ }
+ }
+ }
+ a->model->set_signals_enabled(wanted, true);
+ struct Leg {
+ const char* signal;
+ MeasureType type;
+ double lo;
+ double hi;
+ };
+ const Leg legs[] = {
+ {"sine", MeasureType::Above, 50.0, 0.0},
+ {"sine", MeasureType::Below, 50.0, 0.0},
+ {"sine", MeasureType::Between, 50.0, 75.0},
+ {"square", MeasureType::Above, 50.0, 0.0},
+ // The same band on the square: on the band-limited one it holds the two edges' transitions
+ // -- a real interior whose mean is ~0, because a transition is odd about its own midpoint --
+ // and on the ideal `--square-harmonics 0` step it holds *nothing at all*, because every
+ // sample is exactly +/-1. That pair is the empty-interior rule from both sides.
+ {"square", MeasureType::Between, 50.0, 75.0},
+ {"ramp", MeasureType::Between, 50.0, 75.0},
+ // A share at the top of the range has no sample above it, so this one must read what any
+ // other measurement with nothing to measure reads -- `invalid`/`--`, never 0. The empty
+ // *interior* is the same rule from the other side, and the ideal square below is where it
+ // shows: its samples are all exactly +/-1, so the band (0, 0.5] holds none of them.
+ {"ramp", MeasureType::Above, 100.0, 0.0},
+ };
+ for (const Leg& leg : legs) {
+ Measure m;
+ m.signal = leg.signal;
+ m.type = leg.type;
+ m.options.threshold_pct = leg.lo;
+ if (leg.hi > 0.0) m.options.threshold_pct2 = leg.hi;
+ // The marks are part of the reading for these types too: one ruler at the mean.
+ m.options.show_on_plot = measure_can_show_on_plot(m.type);
+ a->measures.push_back(std::move(m));
+ }
+ select_side_tab(*a, "measure");
+ rebuild_measures_pane(*a);
+ g_timeout_add(250, measure_trace, a);
+ bandavg_probe_next(*a, 1, 1500);
+ break;
+ }
+ case 1: {
+ // A *full* window before any reading. The window is what the pane last described -- two seconds
+ // on the documented run -- and the mock delivers about 0.5 MS/s of the 1.3 MS/s it declares, so
+ // a ring the probe has just re-enabled needs seconds of wall clock to hold a whole one. A short
+ // ring reads a *partial* window, which is a different number and keeps moving while it fills,
+ // and the ring is capacity-bound as well, so the wait is read off the store rather than guessed:
+ // `stored_slots` is how much history there is and `window / dt` is what the pane is measuring.
+ double slot_dt = 0.0;
+ {
+ std::lock_guard lock(a->model->mutex());
+ slot_dt = a->model->partition().slot_dt();
+ }
+ const double window_s = a->measure_window_valid
+ ? a->measure_window.max - a->measure_window.min
+ : 0.0;
+ const std::uint64_t want =
+ slot_dt > 0.0 && window_s > 0.0 ? static_cast(window_s / slot_dt) : 0;
+ const std::uint64_t have = a->model->stored_slots();
+ if (have < want) {
+ std::fprintf(stderr, "[bandavg-probe] %-15s stored=%llu of %llu slots for a %.3f s window\n",
+ "waiting", static_cast(have),
+ static_cast(want), window_s);
+ std::fflush(stderr);
+ bandavg_probe_next(*a, 1, 500);
+ break;
+ }
+ bandavg_report(*a, "readings");
+ bandavg_probe_next(*a, 2, 400);
+ break;
+ }
+ case 2: {
+ // The `between` on the sine, through the ordinary edit path: its dialog is the one that
+ // offers the second share, and this is where the field is read back out of the widgets.
+ const int at = bandavg_index(*a, MeasureType::Between, "sine");
+ if (at < 0) {
+ std::fprintf(stderr, "[bandavg-probe] no sine/between measurement to edit\n");
+ std::fflush(stderr);
+ break;
+ }
+ open_measure_dialog(*a, at);
+ bandavg_probe_next(*a, 3, 400);
+ break;
+ }
+ case 3: {
+ bandavg_report(*a, "edit opened");
+ auto* d = static_cast(
+ g_object_get_data(G_OBJECT(a->measure_dialog), "udpscope-measure-ctx"));
+ if (d != nullptr) {
+ // The two fields as the *stored* parameters came back into them, and the band row's
+ // visibility: a share that is stored but not seeded, or offered for a type that does not
+ // read it, is a field the user cannot edit back to what the pane is showing.
+ std::fprintf(stderr,
+ "[bandavg-probe] %-15s band_row=%d shares=%.1f/%.1f (as stored)\n",
+ "edit opened", gtk_widget_get_visible(d->band_row) ? 1 : 0,
+ gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->threshold)),
+ gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->band)));
+ // From the mid split to 90 %: a band whose mean neither of its sides reports (the readings
+ // above 50 and below 90 are 0.636620 and 0.431362), so the value after the save can only
+ // come from the pair the two fields now hold.
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->threshold), 50.0);
+ gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->band), 90.0);
+ }
+ measure_dialog_click(*a, "Save");
+ bandavg_probe_next(*a, 4, 500);
+ break;
+ }
+ case 4:
+ bandavg_report(*a, "after band edit");
+ bandavg_probe_next(*a, 5, 300);
+ break;
+ case 5: {
+ // The delete half of the cycle, through the dialog's own button: a measurement that carries
+ // two shares is shown to survive add, edit and delete like the ones that carry none.
+ const int at = bandavg_index(*a, MeasureType::Between, "sine");
+ if (at >= 0) open_measure_dialog(*a, at);
+ bandavg_probe_next(*a, 6, 400);
+ break;
+ }
+ default: {
+ const std::size_t before = a->measures.size();
+ // `measure_dialog_click()` falls through to the emit when it cannot find its button, so the
+ // dialog being there is this side's to check, not the helper's.
+ if (a->measure_dialog != nullptr) measure_dialog_click(*a, "Delete");
+ std::fprintf(stderr, "[bandavg-probe] %-15s measures=%zu -> %zu (want one fewer)\n",
+ "after delete", before, a->measures.size());
+ std::fflush(stderr);
+ break;
+ }
+ }
+ return G_SOURCE_REMOVE;
+}
+
+void bandavg_probe_next(App& a, int step, guint delay_ms) {
+ a.dbg_bandavg_step = step;
+ g_timeout_add(delay_ms, bandavg_probe_step, &a);
+}
+
+/// The index of one of the probe's measurements, by the pair that names it: a `between` on the sine
+/// and a `between` on the ramp are two measurements of one type.
+int bandavg_index(App& a, MeasureType type, const char* signal) {
+ for (std::size_t i = 0; i < a.measures.size(); ++i) {
+ if (a.measures[i].type == type && a.measures[i].signal == signal) return static_cast(i);
+ }
+ return -1;
+}
+
+/// Reads the pane out: one line per measurement the probe added, with the shares it was given and
+/// the two readings a user could compare (the value and the row's own formatted text). The numbers
+/// come from a *forced* recompute over the window on screen now, so they are what the pane says at
+/// this instant rather than whatever the last moving frame left behind.
+void bandavg_report(App& a, const char* what) {
+ update_measures(a, /*force=*/true, /*accumulate=*/false);
+ // The window each reading was taken over, beside it: a mean over a share of a *partial* window is
+ // a different number from the same share of a full one, and nothing in the value says which it
+ // was. `samples` against the ring's span is what a short ring looks like from here.
+ for (const App::MeasureWindow& w : a.measure_samples) {
+ std::fprintf(stderr,
+ "[bandavg-probe] %-15s window channel=%d lo=%.6f hi=%.6f samples=%zu levels=%zu\n",
+ what, w.channel, w.stats.lo, w.stats.hi, w.stats.count, w.level.size());
+ }
+ for (std::size_t i = 0; i < a.measures.size(); ++i) {
+ const Measure& m = a.measures[i];
+ const MeasureResult& r = i < a.measure_results.size() ? a.measure_results[i] : MeasureResult{};
+ char shares[64];
+ if (measure_uses_band(m.type)) {
+ std::snprintf(shares, sizeof(shares), "%.0f/%.0f%%", m.options.threshold_pct,
+ m.options.threshold_pct2);
+ } else {
+ std::snprintf(shares, sizeof(shares), "%.0f%%", m.options.threshold_pct);
+ }
+ std::fprintf(stderr, "[bandavg-probe] %-15s %-7s '%s' shares=%-9s -> %-8s %.9g text=[%s]\n",
+ what, measure_type_name(m.type), m.signal.c_str(), shares,
+ r.valid ? "ok" : "invalid", r.valid ? r.primary : 0.0,
+ i < a.measure_text.size() ? a.measure_text[i].c_str() : "");
+ }
+ std::fflush(stderr);
+}
+
gboolean zoomkeys_probe(gpointer user) {
App* a = static_cast(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;