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;