2 Commits
Author SHA1 Message Date
Martino Ferrari 27c9b34277 fix: a claimed gap must be corroborated before it is believed, and its hole must fit
The user's real host reported "connected and live" with one missing-data band over the whole window
and no trace in it. The counter is the only thing that claims a packet existed, and a reservation
was booked on its word alone: a source whose DATA counter counts samples, or frames of an inner
layer, advances by more than 1 per datagram, the client cannot check that claim (it has no clock of
its own to compare against), so it holds each packet, saturates its reorder ring, delivers it in
order and reports the skipped counters as lost. Every frame therefore reported a phantom gap, and
`reserve_hole()` wrote `lost * batch_` NaN samples with no bound but the 256-PACKET cap -- the
sample form of that rule ("the number to weigh against the ring is 256 * batch_, not the 256") was
never implemented. With relative time on -- which that session carries -- each phantom gap is
materialised as NaN slots by construction: the window filled with reservations, there was never
anything missing to repair them, and the gaps also stretched the relative clock, which is why the
status bar read live at ten times the true rate.

`Acquisition::gap_plausible()` now gates every reservation on two independent tests: the arrival
clock must corroborate the claim (that many missing packets would take that many frame intervals;
deliberately permissive, because a false refusal costs a shading and never data), and the ring must
be able to hold the hole. The verdict is taken before the stamps, and relative mode's slot advance
is skipped with it, since the advance and the reservation are the same claim. Refusals are counted
in `StreamStats::gaps_refused` and said once per connection with the numbers, naming the wire shape
they describe.

tools/mock_udpstreamer.py gains `--counter-step N`: a counter that is not a packet counter on an
intact link, which is the one shape the mock could not produce and the shape the report came from.

Verified in both directions. A counter stepping 1000 per datagram reads `holes=0 hole_slots=0
backfilled=0` with the traces present (3 traces, 1.4 M samples) against 7.9 M claimed gaps, and the
warning above names the cause. Every other packet withheld -- a real loss -- still reads
`holes=3579`, so genuine gaps are still reserved: a fix that stopped believing real losses would
have traded one wrong picture for another. The user's control read as "the math channel is the
problem" and was confounded -- disabling it is a structural change, which rebuilds the buffer and
clears the accumulated NaN.
2026-10-15 08:13:28 +02:00
Martino Ferrari 22e7a8efcc measure: the split's own means are back as above/below/between
`top`, `bottom` and `amplitude` became the *flat levels* in e83ad86 -- the level the
signal dwells at, read from the density of the window's buckets -- and that change kept
the split's own two means as its internal fallback for a window the pyramid cannot
summarize. Those means are now three types of their own, so the two different questions
about one split can be asked side by side: `above %` and `below %` are the plain mean of
the samples on one side of a share -- the reading the old `top`/`bottom` gave -- and
`between % %` is the mean of the band between two shares, which no single split can
answer, because a band's two edges are two thresholds.

Nothing about the arithmetic is new, and nothing is a new pass. `Above`/`Below` are the
`sum[]`/`count[]` split `measure_accumulate()` already folds -- the very figures
`Top`/`Bottom` fall back to -- and `Between` is the **difference of two levels' upper
sides**, both of which come out of one pass two over one window. 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, and only the
buckets that straddle either edge are opened. `measure_level_pcts()` is the one place the
shares are read, and it clamps both and puts `between`'s pair **in order**, so "between
80 % and 20 %" is the band between 20 % and 80 % -- normalized rather than refused,
because the shares name two edges rather than a direction. Nothing to measure reads `--`,
as for every other type: a share at 100 % has no sample above it, and a band can hold
none at all.

Measured, `[bandavg-probe]`, fast mock at a 2 s window (`--rate 1300000 --batch 1000
--freq 1`):

  sine   `above 50` 0.636620 (2/pi), `below 50` -0.636620, `between 50 75` 0.255873
  square `above 50` 0.986496 (30 harmonics) / 1.000000 (--square-harmonics 0),
         `between 50 75` 0.302744 / --
  ramp   `between 50 75` 0.250000 and `above 50` 0.500000, `above 100` --

The first three are the figures the old `top`/`bottom` documented; the ramp's are what a
uniform density predicts (a band's mean is its own centre); the ideal square's empty band
is the nothing-to-measure rule. The pane offers the types and edits their percentages
through the dialog's second share -- which only `between` shows -- and a measurement's
pair survives add/edit/delete and the session file (`threshold2`), where a file written
before it existed loads at the default.

No raw pass: with the measurements probe adding one of every type at a 2 s window, `raw`
went from 113966 of 240719031 covered (0.0%) to 134978 of 255372033 (0.1%), 0.8 -> 1.1
ns a covered sample.

Docs: the band-average invariant (and the two assertions the flat-level change moved),
the probe readings for the new probe, the verification recipe, the manual's measurements
page and the README.
2026-10-15 07:56:37 +02:00
19 changed files with 1018 additions and 113 deletions

No files matched your search

+1 -1
View File
@@ -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. |
+60 -6
View File
@@ -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
@@ -511,6 +554,17 @@ missing shades solidly, even if all but one slot are already repaired. And the t
yet configurable; the preferences dialog in `todo.md` is where that would land. Do not use a
command-line flag for either; none exists.
**The counter is a claim, and the wire is checked against it.** A gap is only booked when the
arrival times corroborate it: `lost` packets would have taken that many packet intervals to
arrive, and a claim of less than half that is refused, as is a hole bigger than the ring could
hold. The status bar's `gaps` counts what the *stream* says either way; the refused ones are
counted separately and said once on stderr. That matters for one kind of source: a device whose
counter counts something other than packets — samples, or frames of an inner layer — reports a
forward jump on every datagram. Believing it reserved a NaN run per packet, which on an intact
link reads as one missing-data band over the whole window with the samples washed out and nothing
lost to backfill it, while the status bar says `live`. The samples are now kept and the gap shows
as the x discontinuity it is.
**`--relative-time` makes the x axis follow the sample count and the declared rate**, rather than
the stream's own clock. The packet counter says exactly how many samples each gap spans, so a
reserved hole is a precise arithmetic range. In stream mode the same hole is estimated from the
+30 -1
View File
@@ -169,7 +169,36 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
- **A reservation is weighed against the wire, and never booked on the counter's word alone.**
The counter is a *claim* about the stream; the arrival clock is a *measurement* of it, and only
the two together justify writing NaN into the ring (`Acquisition::gap_plausible()`, called
before anything is booked *and* before relative mode's `slot_base_` advance, because the advance
and the reservation are the same claim). Two tests, either one refusing is enough:
- **The arrival clock.** `missing` packets would have taken `missing` frame intervals to arrive,
so a gap whose elapsed arrival time is under half of that is refused. Half and not one because
the socket clock jitters and a drain can compress two frames' arrival times -- and a refused
gap costs a *shading*, never data, so the test is deliberately permissive.
- **The ring.** `missing × batch_` samples become `missing × batch_ / time_stride` slots, and the
hole may not lap `time_capacity`. This is the *sample* form of the rule the 256-packet cap
states in packets -- the cap's own comment says 256 packet spans in one go is an outage, and
the invariant above says the number to weigh against the ring is `256 × batch_` and not the
256 -- and it has to exist in samples because a hole that laps the ring erases the history it
is meant to annotate, can never be backfilled (its slots are already overwritten) and is never
forgotten (`forget_holes_before()` waits for slots the ring has passed), so it leaves a
*permanent* band over a buffer that is all NaN.
What its absence looks like, which is why it is written down: a device whose counter counts
*samples*, or frames of an inner layer, reports a forward jump on every datagram while the wire
delivers on time. Every frame then reserves, and with `relative_time` every frame books NaN
slots by construction -- one band over the whole window with the samples washed out,
`backfilled=0` (no packet was ever missing, so nothing can repair it), and the status bar reading
`live` at the right rate the whole time. The refusal is counted in `StreamStats::gaps_refused`
and said once per connection with the numbers, because the status bar's `gaps` climbs either way
and the state is otherwise invisible; `tools/mock_udpstreamer.py --counter-step N` is the mock
shape (its counter is otherwise always +1, so this is the one wire shape it could not produce),
and `gaps_refused` rising while `nan_slots` stays 0 is the reading. A refused gap is not a silent
one: the timeline is not stretched over loss that did not happen, so it shows as the x
discontinuity it is.
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
describe a buffer that is gone; it therefore clears `holes_` and bumps
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
+65 -9
View File
@@ -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
+8 -3
View File
@@ -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
+103 -5
View File
@@ -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
@@ -862,10 +872,11 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
gaps_refused=N open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D`
(stderr,
`UDPSCOPE_PROBE_RESEQ_MS`) —
the resequencing path's mid-run state; the measured readings and the per-case command are in
[verification.md](verification.md). Eight things to read carefully:
[verification.md](verification.md). Nine things to read carefully:
1. **`backfilled` and `hole_slots` are not comparable directly.** `hole_slots` counts
*time-ring* slots once per hole; `backfilled` counts *value-ring* slots repaired and
@@ -931,6 +942,29 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
slots. A non-zero reading beside a healthy `backfilled` is a note about the ring's size,
not a defect.
9. **`gaps_refused` counts reservations the wire contradicted, and it is the field that reads
a counter which is not counting packets.** A gap is booked only if it passes two tests
(`Acquisition::gap_plausible()`): the *arrival clock* has to corroborate the elapsed time
(`missing` packets would have taken `missing` frame intervals; anything under half of that
is refused) and the *ring* has to hold the hole (`missing * batch_` samples must not lap
`time_capacity`). The shape this exists for is a device whose counter advances by samples,
or by frames of an inner layer: every datagram reports a forward jump while the wire
delivers on time, so *every* frame reserves -- and, with `relative_time`, every frame
books NaN slots, which is a band over the whole window with the samples washed out and no
packet ever missing to backfill it. `tools/mock_udpstreamer.py --counter-step N` produces
exactly that on an intact link. The reading's two sides, both measured with
`--relative-time` and no session: a *fixed* run reads
`holes=1 hole_slots=900 gaps_refused=419 open_holes=0 bands=0 nan_slots=0 scanned=21700`
with the data channels full and a once-per-connection stderr warning naming the numbers,
while the unfixed one read `holes=420 hole_slots=378000 open_holes=64 bands=64
bands_repaired=0 nan_slots=198000 scanned=220000` -- `nan_slots` at 90% of `scanned`, which
is the whole-window band. The refusal is **not relative-mode-only**: the same command without
`--relative-time` reads `holes=1 gaps_refused=419 nan_slots=0 scanned=21800`, because a
counter that lies is not a mode. On the honest shapes
(`--drop-every`, `--drop-burst 200`, `--reorder-delay-ms`) this field must stay **0**:
a non-zero `gaps_refused` there means the arrival test is refusing real loss, and the cost
is a gap with no shading (never lost data).
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
@@ -1040,6 +1074,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
+73 -4
View File
@@ -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
@@ -558,7 +590,7 @@ as distinct from the fault injection below, which exercises ingest:
at 64. The `mixed` case's `open_holes=43` against `bands=43` has no such spread because
nothing re-planned. The per-field reference is in [probe-readings.md](probe-readings.md).
The mock also carries three **fault injections** that no probe schedules, because what they exercise
The mock also carries four **fault injections** that no probe schedules, because what they exercise
is the ingest rather than the UI. The first: `--time-glitch-at <stream seconds> --time-glitch-by <seconds>` makes
**one** packet's time signal jump ahead and then carries on from where the true timeline was, which
is what a device whose clock glitches puts on the wire. Run it with the trigger armed, e.g.
@@ -593,6 +625,41 @@ arrives within the 20 ms reorder window is resequenced with no hole, and one tha
leaves a committed hole that it then backfills (`UDPSCOPE_PROBE_RESEQ_MS` exercises both paths).
There is **no interpolation** anywhere below the ring.
The fourth is the counter itself: **`--counter-step N`** advances the header counter by `N` per
datagram instead of one -- a device whose counter counts *samples*, or frames of an inner layer,
rather than packets. Nothing is withheld and every datagram goes out on schedule, so the link is
intact and the status bar is right to say `live`; what is wrong is the counter's claim, which the
client cannot check (it has no clock) and only the app can (it has the arrival times). It is the
one wire shape the mock could not otherwise produce, and the run that reads it is the reseq probe
with relative time on (the mode that frames a counter gap as NaN by construction):
```sh
# mock: --counter-step 10
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=600 \
UDPSCOPE_PROBE_RESEQ_MS=5000 UDPSCOPE_EXIT_DUMP=1 \
./build/udpscope --server 127.0.0.1 --port 8000 --relative-time
```
```
# fixed: [reseq-probe] … holes=1 hole_slots=900 backfilled=0 gaps_refused=419 open_holes=0
# bands=0 bands_repaired=0 nan_slots=0 scanned=21700 trigger_in_hole=0
# status bar: glance=[● live · 9.97 kS/s · 2 s window]
# broken: [reseq-probe] … holes=420 hole_slots=378000 backfilled=0 open_holes=64 bands=64
# bands_repaired=0 nan_slots=198000 scanned=220000 trigger_in_hole=0
# status bar: glance=[● live · 101 kS/s · 2 s window]
```
**`gaps_refused` above 0 with `nan_slots` at 0 is the assertion** — the gap was refused and the
samples were kept. The broken reading is `nan_slots` at 90% of `scanned` with `backfilled=0`: a
reserved hole per datagram, `relative_time` framing each one as NaN, and nothing ever missing to
backfill it, i.e. one missing-data band over the whole window with no trace in it, under a status
bar reading `live` at ten times the stream's real rate. The same field
must be **0** on every honest shape above (`--drop-every`, `--drop-burst 200`,
`--reorder-delay-ms 50`, at 10 kS/s: `holes=210 hole_slots=21000 nan_slots=11000 scanned=22000
gaps_refused=0` for `--drop-every 2`, `holes=3 hole_slots=60000 nan_slots=20000` for the 200-packet
burst at 100 kS/s) — a non-zero reading there means the arrival test is refusing real loss, whose
only cost is a gap with no shading.
All of the readings below are off `UDPSCOPE_EXIT_DUMP`'s `[udpscope] frames= packets= gaps=` line,
over an 8 s run against `--rate 10000 --batch 100 --freq 5` (100 packets a second) except where the
burst needs the faster stream. **`gaps` is the exact reading** and repeats to the packet; `frames`
@@ -691,8 +758,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.
+32 -5
View File
@@ -26,8 +26,8 @@
</item>
<item>
<p>The pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
the type, the threshold share and the on-plot marks are set, with <gui>Save</gui> and
<gui>Delete</gui>.</p>
the type, the threshold share or shares and the on-plot marks are set, with
<gui>Save</gui> and <gui>Delete</gui>.</p>
</item>
</steps>
</section>
@@ -35,14 +35,32 @@
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, above,
below, between, pulse duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
<em>flat</em> 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 <em>not</em> what these read. <gui>amplitude</gui> is the delta between those two
levels, and <gui>peak to peak</gui> is the swing between the extremes (so on a rippled
square <gui>amplitude</gui> reads 2 V where <gui>peak to peak</gui> reads 2.36 V).</p></item>
<item><p><gui>above</gui>, <gui>below</gui> and <gui>between</gui> are the opposite kind of
number: the plain <em>mean of the samples</em> above a share, below it, or between two
shares. That is a different question from <gui>top</gui>/<gui>bottom</gui> even about the
same split, and it is the older one — on a unit sine <gui>above 50%</gui> reads 0.64 V
(2/π, the mean of a half cycle) where <gui>top</gui> reads 0.96 V (near the peak, which is
where a sine dwells most), and on a band-limited square <gui>above 50%</gui> reads 0.99 V
where <gui>top</gui> reads the plateau, 1.00 V: the edge's ring is inside the first and not
inside the second. <gui>between</gui> takes <em>two</em> shares — <gui>from %</gui> and
<gui>to %</gui> — 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 %.</p></item>
<item><p>A mean over a side carries whatever is on that side, so these three are the right
numbers when you want the <em>energy</em> or the <em>offset</em> 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 <code>--</code> 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.</p></item>
<item><p><gui>top</gui> and <gui>bottom</gui> also read <em>near the peak</em> 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. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
<em>density</em> 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.</p></item>
from a ripple — so those three cost a second read of the summary and never of a sample, and
<gui>between</gui>'s second share is one more level in the same summary walk rather than a
walk of its own.</p></item>
<item><p><gui>Statistics</gui> adds a second line to every measurement showing
<code>n</code>, the mean, the standard deviation and the range. Each observation is one
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
@@ -117,6 +137,13 @@
<code>bottom=-0.999983</code>, <code>amplitude=1.999966</code> against
<code>peak to peak=2.358701</code> — 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.</p>
<p>The mean-over-a-side types on the same two windows: on the sine
<code>above 50=0.636620</code>, <code>below 50=-0.636620</code> and
<code>between 50 75=0.255873</code>; on the rippled square <code>above 50=0.986496</code> and
<code>between 50 75=0.302744</code>, against <code>1.000000</code> for the same
<code>above 50</code> on an ideal (non-ringing) square. A <code>ramp</code>, whose density is
uniform, is the shape that names what a band mean <em>is</em>: <code>above 50=0.500000</code>
and <code>between 50 75=0.250000</code>, each band's own centre.</p>
</section>
</page>
+96 -6
View File
@@ -203,6 +203,7 @@ void Acquisition::on_config(const udps_signal_t* signals, std::uint32_t count,
// next session is the failure mode worth avoiding in a feature whose whole job is diagnosis.
warned_batch_ = false;
warned_outage_ = false;
warned_phantom_gap_ = false;
warned_bridge_ = false;
warned_time_source_ = false;
// The clock choice is per connection too: a reconnect re-derives it from the frames
@@ -243,9 +244,61 @@ void Acquisition::reset_session_state() noexcept {
have_arrival_interval_ = false;
arrival_interval_ = 0.0;
arrival_prev_ = 0.0;
have_prev_frame_recv_ = false;
prev_frame_recv_ = 0.0;
}
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal) {
void Acquisition::note_frame_arrival(double recv_time) noexcept {
const bool first = !have_prev_frame_recv_;
const double elapsed = first ? 0.0 : recv_time - prev_frame_recv_;
prev_frame_recv_ = recv_time;
have_prev_frame_recv_ = true;
// The arrival *cadence* belongs to the arrival clock, and `build_stamps()`'s arrival branch keeps
// it whenever that clock is the one in play (`time_source_ == 2`, decided once per connection and
// sticky). Every other case needs it here: relative mode returns before that branch, and a stream
// whose declared time signal checks out never reaches it either -- while the reservation test
// below weighs the counter's claim against a measured cadence whatever clock the display uses,
// because a counter that does not count packets lies in all of them. The two writers are therefore
// exclusive by `time_source_`, and this is the branch's own update (0.8/0.2) so the estimate does
// not jump at the one frame where the clock changes hands.
if (first || time_source_ == 2) return;
if (elapsed > 0.0 && std::isfinite(elapsed)) {
arrival_interval_ =
have_arrival_interval_ ? 0.8 * arrival_interval_ + 0.2 * elapsed : elapsed;
have_arrival_interval_ = true;
}
}
bool Acquisition::gap_plausible(std::uint32_t missing, double elapsed, const Partition& part,
u64 g) const {
if (missing == 0) return true;
// The ring cannot hold a hole bigger than itself, and this test needs no measurement: the
// invariant's own rule is that the reservation's *sample* cost -- `missing * batch_`, not the
// packet cap -- is what has to be weighed against the ring. A hole that laps the ring erases the
// history it is meant to annotate, can never be backfilled (its slots are already overwritten),
// and is never forgotten (`forget_holes_before()` waits for slots the ring has passed), so it
// leaves a band over a buffer that is all NaN.
if (g == 0 || part.time_capacity == 0) return false;
if (u64(missing) * u64(batch_) / g >= part.time_capacity) return false;
// And the counter has to agree with the wire about how long the loss took. The counter is a claim
// about the stream; the arrival clock is a *measurement* of it: `missing` packets would have taken
// `missing` frame intervals to arrive, and the frame before this one is the witness of how long
// actually passed. A device whose counter counts samples, or frames of an inner layer, reports a
// forward jump on *every* datagram while the wire delivers on time -- and believing that booked a
// NaN hole per packet, i.e. a band over the whole window filled with samples that were never
// missing, which no backfill can repair because no packet was ever lost. The factor is a half and
// not one: the socket clock jitters and a drain can compress two frames' arrival times, so only a
// claim the wire flatly contradicts (half the time it needs, or less) is refused. A refused gap
// costs a shading, never data.
if (have_arrival_interval_ && arrival_interval_ > 0.0 && elapsed > 0.0 &&
elapsed < 0.5 * double(missing) * arrival_interval_) {
return false;
}
return true;
}
std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_signal,
bool counter_gap_refused) {
// The frame's span in base ticks: a scalar signal carries `num_samples` values
// (one per batched cycle), a block signal (rows*cols > 1) carries its whole
// element block. The largest of those is how many base ticks this frame spans --
@@ -294,8 +347,11 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
if (!(dt > 0.0)) dt = 1.0e-3;
// The reserved hole for the missing packets advances `slot_base_` so this frame's
// own stamps land *after* the hole rather than on top of it. The same conditions
// the reservation call uses: not late, gap present, batch known, within the cap.
if (!frame->late && frame->lost > 0 && batch_ > 0 &&
// the reservation call uses: not late, gap present, batch known, within the cap --
// and not one the arrival clock or the ring refused, because the advance and the
// reservation are the *same* claim about the wire: believing a phantom gap here
// would stretch the timeline by exactly the loss that did not happen.
if (!frame->late && frame->lost > 0 && batch_ > 0 && !counter_gap_refused &&
double(frame->lost) <= kGlitchPackets) {
slot_base_ += u64(frame->lost) * batch_;
}
@@ -615,7 +671,20 @@ void Acquisition::on_data(const udps_frame_t* frame) {
}
}
}
const std::uint32_t n = build_stamps(frame, time_signal);
// The wire's own cadence, and the verdict on the counter's claim, both taken *before* the
// stamps: relative mode's `slot_base_` advance and the reservation below are the same claim
// about the wire (see `build_stamps`), so they have to agree about it. The verdict is taken
// before the cadence is updated, too: folding this frame's own arrival into the estimate would
// let one burst raise the very number that is supposed to corroborate it -- a 200-packet outage
// smooths the interval to 40 ms and then refuses itself.
const bool first_frame = !have_prev_frame_recv_;
const double since_prev = first_frame ? 0.0 : frame->recv_time - prev_frame_recv_;
const std::uint32_t missing = frame->lost;
const bool gap_refused =
missing > 0 && !gap_plausible(missing, since_prev, part, part.time_stride);
note_frame_arrival(frame->recv_time);
const std::uint32_t n = build_stamps(frame, time_signal, gap_refused);
if (n == 0) return;
if (model_.epoch() == 0.0) {
@@ -695,8 +764,29 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// through the same decimation accumulators (value_acc_, value_acc_n_, math_sample_),
// which must already be sized to the current channel count and stride before any
// per-sample write.
const std::uint32_t missing = frame->lost;
if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
if (missing > 0 && gap_refused) {
// A claim the wire contradicted, or a hole the ring cannot hold: framing it as NaN is what
// turns one phantom gap into a band over the whole window (and, at a batch big enough, into
// minutes of the producer's time under this lock). The samples are kept, no hole is booked,
// and the timeline above was not advanced either -- so the picture shows the data and the gap
// shows as the x discontinuity it is. Said once per connection with the numbers, because it is
// otherwise invisible: the status bar's `gaps` climbs either way.
model_.stats().gaps_refused.fetch_add(1, std::memory_order_relaxed);
if (!warned_phantom_gap_) {
warned_phantom_gap_ = true;
std::fprintf(stderr,
"[udpscope] warning: the counter reports %u packet(s) missing (%llu samples, "
"%.3f s at the measured cadence of %.6f s/frame) but only %.3f s passed since "
"the previous frame, or the hole is larger than the ring (%llu slots); "
"refusing the reservation and keeping the samples. A counter that does not "
"count packets -- samples, or frames of an inner layer -- reports a jump on "
"every datagram and reads exactly like this.\n",
missing, static_cast<unsigned long long>(std::uint64_t(missing) * batch_),
double(missing) * arrival_interval_, arrival_interval_, since_prev,
static_cast<unsigned long long>(u64(missing) * u64(batch_) / part.time_stride));
std::fflush(stderr);
}
} else if (missing > 0 && batch_ > 0 && double(missing) <= kGlitchPackets) {
reserve_hole(missing, part, channels, store, g);
} else if (missing > 0 && !warned_outage_) {
warned_outage_ = true;
+25 -2
View File
@@ -77,7 +77,8 @@ class Acquisition {
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
/// and returns how many were written (`frame->num_samples` unless the frame is
/// malformed).
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal,
bool counter_gap_refused);
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
/// advances the decimation accumulators and tick_, records the hole in the model, and
@@ -92,6 +93,15 @@ class Acquisition {
void backfill_from(const udps_frame_t* frame, const Partition& part,
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
/// `build_stamps()`'s arrival branch does not (see the definition).
void note_frame_arrival(double recv_time) noexcept;
/// Whether the counter's claim of `missing` lost packets can be believed *and* booked: the
/// arrival clock has to corroborate it and the ring has to be able to hold it. See the
/// definition for both tests and for what believing a claim that fails them looks like.
bool gap_plausible(std::uint32_t missing, double elapsed, const Partition& part, u64 g) const;
ScopeModel& model_;
Config cfg_;
udps_client* client_ = nullptr;
@@ -125,10 +135,20 @@ class Acquisition {
double arrival_epoch_ = 0.0;
bool have_arrival_epoch_ = false;
/// Smoothed interval between two frames' arrivals, and the previous arrival: the
/// spine of the arrival-anchored clock (see `build_stamps`).
/// spine of the arrival-anchored clock (see `build_stamps`). In relative mode no frame
/// reaches that branch, so `on_data` keeps the same pair up to date itself -- the
/// reservation's plausibility test below needs a measured cadence whatever clock the
/// user chose, and the two writers are mutually exclusive by mode.
double arrival_interval_ = 0.0;
double arrival_prev_ = 0.0;
bool have_arrival_interval_ = false;
/// The previous *accepted* frame's receive time, for the one measurement the reservation
/// test needs: how much wall time actually passed. Kept apart from `arrival_prev_` on
/// purpose -- that one is the arrival clock's own bookkeeping and is re-anchored by the
/// clock-choice code, while this is a bare witness of the wire and must never be moved by
/// a mode change.
double prev_frame_recv_ = 0.0;
bool have_prev_frame_recv_ = false;
/// The stream's time source, chosen once and sticky: 0 undecided, 1 the declared
/// time signal, 2 arrival. Re-evaluated only on a CONFIG. A per-frame choice
/// interleaved two timelines in one ring -- some frames stamped from a declared
@@ -158,6 +178,9 @@ class Acquisition {
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
/// `static`, matching the latch Task 2 added for the same reason.
bool warned_outage_ = false;
/// Warn-once latch for a reservation the arrival clock did not corroborate, or that the ring
/// could not hold (see `gap_plausible()`). Once per connection, like the two above.
bool warned_phantom_gap_ = false;
/// Warn-once latch for the one bridged packet whose time signal glitched.
bool warned_bridge_ = false;
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
+9
View File
@@ -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";
+92 -28
View File
@@ -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<double>(level->count[s]);
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(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<double>(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<double>(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<double>(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<double>(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<double>(level->widths[s]);
if (level.widths[s] == 0) return out;
out.primary = level.width_sum[s] / static_cast<double>(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;
}
+51 -18
View File
@@ -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<double(std::size_t)>& 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);
+9
View File
@@ -268,6 +268,15 @@ struct StreamStats {
/// a reading of 0 beside a non-zero `holes` is exactly the honest answer until the
/// backfill lands.
std::atomic<std::uint64_t> backfilled{0};
/// Reservations refused because the counter's claim was not corroborated by the arrival
/// cadence, or because the hole it would book is bigger than the ring can hold. A counter
/// is a claim about the wire and the arrival clock is the measurement of it: a stream whose
/// counter does not count packets (a sample counter, say) reports a forward jump on every
/// datagram, and believing it booked a NaN hole per packet -- a band over the whole window
/// filled with samples that were never missing, which the backfill cannot repair because no
/// packet was ever lost. Counted here because it is otherwise *invisible*: the status bar's
/// `gaps` climbs either way, and only this field says the gap was refused.
std::atomic<std::uint64_t> gaps_refused{0};
};
/// What the plot is showing, which is one decision made once per frame: the bind, the
+24 -8
View File
@@ -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<guint>(std::atoi(env)), measures_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BANDAVG_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), bandavg_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_FIT_MS")) {
g_timeout_add(static_cast<guint>(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).
+17
View File
@@ -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<double>& time, std::vector<double>& 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);
+88 -14
View File
@@ -7,6 +7,7 @@
#include <gtk/gtk.h>
#include <cstdio>
#include <string>
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<std::mutex> 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<int> chan(app.measures.size(), -1);
std::vector<int> want_window(app.measures.size(), -1);
std::vector<int> 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<int> 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<int>(k);
} else {
want_level2[i] = static_cast<int>(k);
}
}
want_level[i] = static_cast<int>(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<std::size_t>(want_level[i]) < w.level.size()
? &w.level[static_cast<std::size_t>(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<std::size_t>(want[j]) < w.level.size()) {
levels[j] = &w.level[static_cast<std::size_t>(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<int>(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(
+223 -2
View File
@@ -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<App*>(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<std::size_t> wanted;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
const std::vector<SignalInfo>& 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<std::mutex> 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<std::uint64_t>(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<unsigned long long>(have),
static_cast<unsigned long long>(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<MeasureDialog*>(
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<int>(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<App*>(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;
@@ -3192,7 +3411,8 @@ gboolean reseq_probe(gpointer user) {
std::fprintf(stderr,
"[reseq-probe] reseq=%llu late=%llu stale=%llu overflows=%llu holes=%llu "
"hole_slots=%llu "
"backfilled=%llu open_holes=%zu bands=%zu bands_repaired=%zu nan_slots=%llu "
"backfilled=%llu gaps_refused=%llu open_holes=%zu bands=%zu bands_repaired=%zu "
"nan_slots=%llu "
"scanned=%llu trigger_in_hole=%d\n",
static_cast<unsigned long long>(m.stats().resequenced.load()),
static_cast<unsigned long long>(m.stats().late.load()),
@@ -3200,7 +3420,8 @@ gboolean reseq_probe(gpointer user) {
static_cast<unsigned long long>(m.stats().overflows.load()),
static_cast<unsigned long long>(m.stats().holes.load()),
static_cast<unsigned long long>(m.stats().hole_slots.load()),
static_cast<unsigned long long>(m.stats().backfilled.load()), open_holes, bands,
static_cast<unsigned long long>(m.stats().backfilled.load()),
static_cast<unsigned long long>(m.stats().gaps_refused.load()), open_holes, bands,
bands_repaired, static_cast<unsigned long long>(nan_slots),
static_cast<unsigned long long>(scanned), trigger_in_hole);
std::fflush(stderr);
+12 -1
View File
@@ -337,6 +337,17 @@ def main():
"or the client reconnects and the mock restarts its clock, which is a different "
"test",
)
ap.add_argument(
"--counter-step",
type=int,
default=1,
help="advance the header counter by this many per datagram instead of one: a device "
"whose counter is not a packet counter (it counts samples, or frames of an inner "
"layer). Nothing is withheld -- every datagram is sent on schedule -- but every one "
"of them reports a forward jump, which is the *phantom* gap the receiver has to "
"refuse: 1 is a per-packet counter, 2 and up is the shape that reads as loss on an "
"intact link",
)
ap.add_argument(
"--reorder-every",
type=int,
@@ -537,7 +548,7 @@ def main():
# The counter and the stream clock advance whatever became of the datagram. That
# is what makes a withheld packet a *lost* one and not a slower producer: nothing
# in a payload says which packet it is, only the header's counter does.
counter += 1
counter += max(1, args.counter_step)
t_stream += args.batch * pdt
next_send += period
if (