7 Commits
Author SHA1 Message Date
Martino Ferrari d34bff3f03 feat: the trigger can ignore a noise band and reject HF before comparing 2026-10-13 15:04:52 +02:00
Martino FerrariandClaude Sonnet 4.6 d5a9b3c5d5 test: the mock can inject an alternating-sign impulse train on sine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-13 14:55:57 +02:00
Martino FerrariandClaude Opus 4.6 be71a1d4bb perf: a frozen frame is not re-rendered sixty times a second
The tick queued a GL render unconditionally. In `Held` and `Paused` the rows
are bound to an immutable snapshot on a pinned axis, so a frame in which none
of the picture's own terms moved is the same frame again. The gate keys on a
coarse signature of what a frozen view can be re-framed by, opens for
`kSettleRenders` frames on a change because the figure lays out per render, and
has a floor that renders anyway every few ticks. The layout editor is excluded:
its buttons are placed from the last frame's rects, so a skipped frame there is
a stale widget rather than a stale picture. `skips=` in the probe dump is the
reading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:55 +02:00
Martino FerrariandClaude Opus 4.6 84c158ff21 perf: the producer's group test is a mask, not a 64-bit division
`plan_partition()` only ever chooses powers of two for `time_stride`, and the
comment there already claimed the test was cheap -- but the stride is a runtime
value, so `tick % period` compiled to a real division that the hot loop paid
once per channel per wire sample. The per-channel masks are built where the
partition is already reacted to, and the shared clock's group end is computed
once per sample instead of three times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:48 +02:00
Martino FerrariandClaude Opus 4.6 ee660c14fd docs: record the acquisition and UI review that found the two hot paths
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:50:48 +02:00
Martino FerrariandClaude Opus 4.6 a989935bbd docs: plan the noise-rejection work as twelve testable tasks
The design spec is approved; this turns it into the task-by-task plan an
implementer with no context can follow, with the four places the plan
deliberately departs from the spec collected and argued at the end.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 14:42:13 +02:00
Martino FerrariandClaude Opus 4.6 0106aca69b docs: design for noise rejection in the trigger, the measurements and a math node
EM interference is impulsive, so each place gets an amplitude qualifier and a
frequency one, both optional and both defaulting to today's behaviour: the
documented reading set has to come out unchanged. Records why the trigger's
zero-band path stays bit-for-bit, why a measurement filter gives up the bucket
fast paths, and why the new op is appended rather than placed beside the filters.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-13 13:31:55 +02:00
20 changed files with 3181 additions and 30 deletions

No files matched your search

+37
View File
@@ -344,6 +344,43 @@ array of per-node structs was measured and bought **nothing** — at six nodes t
L1-resident either way. It was kept because a node's state belonging on one cache line is the
right shape for a graph that grows, not because it showed up.
### What the ingest filter and a still frame cost
Two costs that are paid once per *wire sample* and once per *tick* respectively, and so are the
largest two the application has that are not proportional to anything the user asked for.
**The ingest filter's group test.** Decimation at ingest is an average, never a stride, so the
filter reads every sample by design — and the test for "is this sample the last of its group"
was a modulo: `tick % period == period - 1`, once per channel per sample, plus once more for the
shared clock. `Partition::time_stride` is chosen at *runtime* (`plan_partition()` doubles it from
1 until the window fits), so that modulo is not a compile-time strength reduction but a real
64-bit `div` — and the code's own comment claimed the opposite. Both factors are powers of two
(every per-channel `stride[i]` is 1 by design), so the period minus one is a **mask** and the test
is `(tick & mask) == mask`. The masks are built where the accumulators are sized, keyed on the
partition, so a re-plan cannot leave them addressing the previous partition's groups; the
reserved-hole path got the same treatment, because a hole's samples go through the same
accumulators and the two phases have to agree sample for sample. The shared clock's test is now
computed once per sample and reused by the time ring, the channel loop and the math push — it was
three tests of the same thing.
**A frozen frame.** The tick queued a GL render unconditionally, sixty times a second, for the
whole life of the process. In `Held` and `Paused` that is the same picture again: the rows are
bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring, not the
series on screen. On llvmpipe, or on a battery, re-drawing a frozen event at 60 Hz is the entire
cost of the application. The gate is now two parts, and the second is what makes the first safe to
keep coarse: a *signature* of what a frozen view can be re-framed by — a new snapshot, a pan or
zoom (the group's range, where both the gestures and the pin write), a row added or removed —
which renders the moment it changes, and a **floor** that renders anyway every four ticks (~15 Hz),
so anything the signature does not name is late rather than lost. A gate that can only be opened by
a list of state is a gate that goes wrong the first time the list is one item short. A change opens
the gate for three frames rather than one, because the figure lays itself out per *render* and its
left gutter is a monotone floor that takes two passes to converge. The layout editor is excluded
outright: its buttons are placed from the rects the last frame was laid out with, so there a
skipped frame is a stale *widget* and not just a stale picture. Interaction needs none of this —
`PlotArea` queues its own render from the pointer, scroll and drag handlers, so a crosshair and a
cursor drag stay immediate whatever the gate decides. `[pause-probe]`'s `skips=` field reports it,
cumulative: 0 on the live line, non-zero on the paused one.
### Statistics
**Statistics** under the measurements list adds a second line to every measurement: `n`, the
+19
View File
@@ -60,6 +60,25 @@ the value is required, and the popover button uses 1000 when the flag was absent
The flag is **not** persisted in the session file: persistence of the enable state
belongs with `todo.md` item 3, alongside the relative-time toggle.
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
start of every square period from stream second `S` onwards — a runt pulse of a known
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it
stops crossing zero: the dropout trigger's input.
- `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign
impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`,
`noise`) are untouched, which is what lets a probe compare a spiked channel against a
clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is
added to `sine`; the sign alternates per period because a one-signed train is a DC offset
a median would partly track rather than reject. `N` is in **samples**, not seconds, so its
duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so
every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or
without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's
~65507-byte payload limit, and the mock dies on its first `sendto` with
`OSError: [Errno 90] Message too long` while the run still exits 0.
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
form for a new host. Every probe below passes `--server`, so none of them sees it.
+12 -1
View File
@@ -148,7 +148,7 @@ precedent, not an exception.
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
measured on a still picture. It now also asserts what a pause *is*
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
glance=[..]` on stderr):
skips=N glance=[..]` on stderr):
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
state as well as an acquisition one and the view snapshot and axis pin live in the
handler. Across the three lines **`ring_produced` must grow** (the producer never
@@ -168,6 +168,17 @@ precedent, not an exception.
stops while paused and "how long have I been looking at this" is a question about the user, not
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
instead of growing digits (see the layout rule).
**`skips=` is the assertion for the render gate** (the end of `tick()`), cumulative over the
session: the `live` line must read **0** and the `paused` line must read **more than 0**. A
frozen view is bound to an immutable snapshot on a pinned axis, so a frame whose picture cannot
have changed is not drawn again — and this is the only line that can say whether that happened,
since a skipped frame is invisible in every other reading by construction. Non-zero on the
`live` line is the gate's condition inverted, which would cost dropped samples' worth of
picture; 0 on the `paused` line is the gate not engaging and the saving not being made.
The gate cannot stall the picture (`kStaticRenderEvery` renders every few ticks regardless),
so the renderer's own baselines below are unchanged by it: a measured run read `skips=0`,
then 33, then 45, with `columns == graded`, `render blank frames: 0`, and the layout floor
still converged (`y-gutter 104..104`).
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
trace and the chip, round-trip it through the session writer, then clear it. Schedule
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
+217
View File
@@ -0,0 +1,217 @@
# Project review — 2026-10-13
A status review for performance, defects and gaps. Every claim below was read back against the
current tree; where a candidate finding turned out to be wrong it is listed as dismissed, with the
reason, rather than dropped — a false alarm that is not written down gets re-raised.
## Summary
The project is in good shape. All ten `todo.md` items are implemented, every deviation from
`SPECS.md` is documented in README's *Review findings not (yet) implemented* and *Limitations*, there
is no `TODO`/`FIXME`/`HACK` anywhere in `src/`, no dead command-line option, and no settings field
that is written but never read. The tree builds clean.
- **Defects: none confirmed.** Four candidates were investigated and all four are correct as
written (§2).
- **Performance: one real win on the ingest hot path** (§1.1) — a 64-bit `div` per sample per
channel that the code's own comment believes is already cheap. Then one real win on the idle
frame path (§1.2), and three small ones. **§1.1 and §1.2 have since been implemented**; the three
small ones (§1.3-§1.5) have not.
- **Gaps: no missing requirement.** What is missing is only what was never asked for (§4), plus one
stale sentence in README (§3).
## 1. Performance
### 1.1 A 64-bit division per sample per channel on the producer hot path
> **Implemented** (2026-10-13), as the fix below describes: `Acquisition::acc_mask_` holds the
> per-channel masks, built where the accumulators are sized; `reserve_hole()` moved with it; the
> misleading comment at `src/model.cpp:120-122` now says what the mask actually buys. Written up in
> README under *What the ingest filter and a still frame cost*.
`src/acquisition.cpp:856-857`, in the innermost loop of `on_data()`:
```cpp
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
```
Three facts make this the most expensive avoidable thing in the application:
1. **`part.stride[i]` is always 1.** `plan_partition()` assigns it (`src/model.cpp:49`) and then
explicitly re-asserts it (`src/model.cpp:70`), with the comment above it explaining why
per-channel strides were abandoned. So `period == g` for every channel, always — the load and
the multiply are provably redundant.
2. **`tick % period` is a real `div`.** `g` is a runtime `u64`, so the compiler cannot strength-
reduce it, whatever its value. A 64-bit integer division is ~20-40 cycles of latency on x86.
The intent was otherwise: `src/model.cpp:120-122` says a power-of-two stride "keeps `tick %
period` cheap", which would be true of a compile-time constant and is not true of a variable.
**`time_stride` being a power of two currently buys nothing here.**
3. **The same condition is already computed.** `tick % g == g - 1` is evaluated at
`src/acquisition.cpp:811` for the time ring, and again at `:881` for the math push — and
`:857` is the same test once more per channel.
Cost model: `(n_channels + 2)` divisions per **raw** sample, i.e. per wire sample, not per stored
one — the decimation filter deliberately reads every sample (`:850-852`). At the fast mock's
1.3 MS/s with 4 channels that is ~7.8 M divisions a second on the producer thread, which is the
thread whose budget overrun costs packets rather than frames.
Fix: compute the group boundary **once per sample** and reuse it. `g` is a power of two, so
`const u64 mask = g - 1;` hoisted out of the packet loop turns all three sites into
`(tick & mask) == mask`. To keep the per-channel generality the type still carries, precompute a
per-channel mask vector when the partition is planned rather than per sample.
The same expression appears in the hole path at `src/acquisition.cpp:966` (inside
`reserve_hole()`'s double loop). That one is cosmetic — it runs only on packet loss — but it should
move with its twin so the two paths keep matching.
How to verify: the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), reading the
exit dump's `gaps=` and the producer's CPU share before and after. Expect the win to show as
headroom rather than as a changed reading on a stream the producer already keeps up with.
### 1.2 The figure is re-rendered 60 times a second even when the picture cannot change
> **Implemented** (2026-10-13), conservatively: a signature of what a frozen view can be re-framed
> by, opening the gate for `kSettleRenders` frames, plus a `kStaticRenderEvery` floor that renders
> anyway every four ticks, and the layout editor excluded outright. `[pause-probe]`'s new `skips=`
> field reports it: a measured run read 0 live, 33 paused, with `columns == graded`, `render blank
> frames: 0` and the layout floor still converged.
`src/ui.cpp:3709` queues a GL render unconditionally at the end of every tick, and the tick is a
fixed 16 ms timeout (`src/ui_app.hpp:1023`). In `Held` and `Paused` the bound data is an immutable
snapshot and the axis is pinned, so with no pointer interaction the frame is provably identical to
the last one — and it is still fully re-rendered. On llvmpipe, or on a laptop looking at a latched
event, that is the whole GPU cost of the application for nothing.
Fix: queue the render only when something that can change the picture did — new samples acquired,
a mode/epoch/pin change, a mark/cursor/label move, an interaction, a resize. Keep it conservative:
render unconditionally while the layout is still settling, since the layout editor's buttons are
placed from the *previous* frame's rects and several probes read a value one frame after the ask.
How to verify: the exit dump's per-plot painted-frame counts and `layout over N frames` are exactly
the two readings that would catch an over-eager skip.
### 1.3 The event publish blocks the UI tick for 69-115 ms
Already documented, with measurements, at `README.md:2189-2191` — listed here only because the
README prices exactly one remedy (a second live ring) and declines it for good reasons. Two
options it does not price, both cheaper than a second budget's worth of memory:
- do the snapshot copy on the **producer** side of the handoff, so the stall lands where a stall is
already tolerated at a latch (the pause publish already blocks the producer, 22-86 ms);
- chunk the copy across frames behind a sequence number, publishing only when complete.
Worth a design note before any code — the README's own A/B (`gaps=0` either way) says this buys a
smoother frame at a latch, not recovered packets, so it is a polish item, not a correctness one.
### 1.4 The status detail string is rebuilt and re-applied every frame
`src/ui.cpp:4063` calls `format_detail(snap)` every tick and `:4082` pushes the result into
`gtk_widget_set_tooltip_text()` every tick. The popover is already guarded by its own visibility
(`:4083-4087`); the tooltip is not, and the string is long enough that the code tracks its maximum
length. Nothing reads it unless the pointer rests on the status label.
Fix: keep the last applied detail string and skip both the format and the setter when it is
unchanged. Low risk, no behaviour change.
### 1.5 Search marks are re-filtered and re-sorted every frame
`src/ui.cpp:1760-1768`. Two heap vectors, a pass over every hit and an `std::sort` per frame for as
long as a search result set exists. Correctly short-circuited when there are no hits
(`:1753`), and being recomputed every frame is load-bearing for a *live* view (the marks are stored
in stream seconds and the window rolls under them) — but in `Held`/`Paused` neither the hits nor
the view range move, so the whole result can be cached and invalidated on a view-range or hit-set
change.
### 1.6 Noted, not recommended: the model mutex spans all of `on_data()`
`src/acquisition.cpp:558` takes the lock and holds it to the end of the function (~line 913),
covering the sample loop, the math evaluation, the trigger engine, and hole reserve/backfill. The
UI tick takes the same mutex (`src/ui.cpp:3285`). This is the documented design ("taken once per
DATA frame", `docs/claude/architecture.md:15-16`) and it is the right call at today's rates, but it
is the coupling by which math-channel cost on the producer turns into UI frame jitter. The existing
`math_timed` per-slot instrumentation is the thing to watch if math graphs get heavier.
## 2. Defect candidates investigated and dismissed
Recorded so they are not re-raised.
- **`capture_edge()` loading `memory_order_relaxed`** (`src/model.hpp:492`) while its partner
`trigger_pending()` carries release/acquire. **Correct as written.** The consumer reaches
`capture_edge()` at `src/ui.cpp:3275` only *after* `display_mode()` at `:3273` has done the
acquire load of `trigger_pending_`; the acquire establishes the happens-before, so a relaxed load
afterwards cannot see the stale edge, and an acquire also forbids the later load being reordered
ahead of it. The probe path (`src/ui_probe.cpp:1129-1130`) reads the flag first too.
*One fragility worth a line of doc:* the correctness is a property of **read order at the call
site**, and `src/model.hpp:489-491` says a consumer that sees the flag sees the edge without
saying that the consumer must therefore read the flag first. Tightening that comment is the whole
of the work.
- **`math_values_.data()` passed to `math_step()` across a possible reallocation**
(`src/model.cpp:409-422`). **Safe.** `push_math_sample()` is only reached from `on_data()`
(`src/acquisition.cpp:881`, `:978`), which holds the model mutex for its whole body, and
`set_math_channels()` takes the same mutex.
- **`model_.signals()` indexed by `channels[0].signal` after the lock was released**
(`src/acquisition.cpp:593-595`). **Safe** — same reason: the lock is held from `:558` to the end
of the function, so no CONFIG can re-shape the signal list underneath it.
- **`events_thumb` left dangling across a list rebuild** (`src/ui_events.cpp:632`). **Already
handled** — nulled before the rows are removed and rebound after, with the old texture unreffed.
Nothing in the trigger/pause/file-view/replay seams, the math-channel dependency handling, the
measurement and cursor paths, or the export/session writers reproduced a wrong number or a lost
action on inspection. That is a statement about a code read, not about a test run: README's own
`Limitations` is right that these paths are checked headless, and the seams between two *user*
actions are where the probes are thinnest.
## 3. One documentation defect
`README.md:2304-2307` says the interactive paths — "drag and drop, the dialogs, the right-click
menu" — "were not driven by an automated test". That is now stale in all three particulars:
`UDPSCOPE_PROBE_DROP_MS` drives the drop handler, `UDPSCOPE_PROBE_ROWMENU_MS` walks the
right-click menu and clicks through its submenu, and `UDPSCOPE_PROBE_DEFAULTS_MS`,
`UDPSCOPE_PROBE_CONFIGS_MS`, `UDPSCOPE_PROBE_PREFS_MS` and `UDPSCOPE_PROBE_HELP_MS` all open and
read back dialogs. What remains genuinely undriven is the *gesture* layer (a real pointer drag, a
real popover dismissal) rather than the handlers behind it, which is a much narrower claim and the
one worth making.
## 4. Gaps
### Requirements: complete
`SPECS.md` was walked bullet by bullet. Everything is implemented, and the five places where the
implementation differs are each documented with a reason at `README.md:2182-2240`: one live buffer
plus an immutable `EventSnapshot` rather than two live rings; allocation at the first CONFIG rather
than at startup; no per-channel decimation (the spec's formula aliased a 2 kHz sine on a 1 MHz clock
to a flat line); pre-trigger % as a share of the event window; and a capture drawn as it fills.
`todo.md`'s ten items are all really there, including item 9's full preference list — the
light/dark/auto theme, the reset-to-defaults and the cache controls are all in `src/settings.hpp`
and `src/ui_prefs.cpp`.
### Not asked for, worth considering
Ranked by value against cost, not by ambition:
1. **Save the current view as an image.** The cheapest of these by a wide margin: the event cache
already has a sample-driven image writer (`write_preview()`, `src/event_store.cpp:266`), so a
menu item and a file chooser is most of the work. A scope user screenshots constantly.
2. **A draggable trigger level on the plot.** The trigger marker exists but is a vertical cursor
and deliberately non-selectable (`src/plot_host.cpp:250`); the level is reachable only through
the panel's spin button. A horizontal handle on the trigger channel's row is the standard
gesture on every bench scope.
3. **Per-trace vertical offset and scale.** Up to eight signals share one row's y axis, so two
signals of different magnitudes cannot both be read. This is the gap a user hits soonest.
4. **An FFT / spectrum row.** The largest of these, and the one a UDPStreamer user is most likely
to ask for next; it fits the figure's row model (one row, one derived series) rather than
fighting it.
5. **XY mode**, and **undo for layout edits** — both real scope features, both a poor fit for the
current one-x-window figure and the layout file respectively. Mentioned for completeness.
## 5. Suggested order
1. §1.1, the ingest-path division — small, local, the clearest win, and it corrects a comment that
is currently misleading about why the power of two matters.
2. §3, the stale README sentence — minutes, and it is wrong today.
3. §1.4 and §1.5, the per-frame string and sort — small and safe.
4. §2's doc tightening on `capture_edge()`.
5. §1.2, the conditional render — the bigger win of the two performance items, but the one that
needs care against the layout-settling probes, so it deserves its own change.
6. §4.1, the screenshot — the cheapest real feature on the list.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,342 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# Noise rejection: design
Noise rejection in three places — the measurements pane, the trigger, and a new math node — aimed
at one problem: **electromagnetic peaks**. EM interference is *impulsive*. It arrives as a few
samples of large excursion, not as a raised noise floor, and that distinction decides every choice
below. An impulse recrosses a threshold several times per true crossing, so it inflates an edge
count; it moves a `min`, a `max` and a peak-to-peak outright; and it is the one kind of noise a
low-pass filter handles *badly*, smearing the spike across its settling time instead of removing
it while rounding the real edge beside it.
Each of the three places therefore gets two independent, optional controls:
| | amplitude qualifier | frequency qualifier |
| --- | --- | --- |
| **Trigger** | hysteresis band about the level, in signal units | one-pole HF reject on the comparator's input, `fc` in Hz |
| **Measurements** | hysteresis band, % of the window's peak-to-peak | one-pole HF reject over the measured window, `fc` in Hz |
| **Math** | — | the `de-noise` node: median over N samples, then a one-pole at `fc` |
Nothing here changes a default. Every control is off, or set to the value the code already uses,
so the whole documented reading set must come out of this work unchanged. That is the first
verification assertion, not an afterthought.
## The decisions taken, and what each one rules out
| Decision | Alternative rejected |
| --- | --- |
| The trigger band is **absolute, in the channel's own units** | A percentage of a running peak-to-peak: the producer has no measured span, so it would need per-channel sliding-window state in the hot loop *and* a warm-up during which the band is wrong — which shows up as a missed or early latch, the one failure a trigger must not have |
| `band == 0` takes today's crossing test **verbatim**, with the latched-side comparator used only when the band is non-zero | One unified latched-side comparator: at `v == level` exactly the two are *not* equivalent (today it fires, a zero-width band decides nothing), and `feed_window`'s own comment records a ±1 square against a `[1, 1]` band firing 19 times on exact float equality. `probe-readings.md`'s `[condition-probe]` table of four exact latch times is a regression suite in disguise; unifying would require re-measuring all of it to prove a change nobody asked for |
| The band and the filter live **inside `EventDetector`** | Filtering in `Acquisition` before `engine.process()`: the buffer search deliberately runs the same evaluator (`condition.hpp:46-49`), so anything upstream of the detector has to be replicated there, and the live trigger and the search would drift apart |
| No shared hysteresis primitive extracted | A common `side_of()` header used by `measure.cpp` and `condition.hpp`: the genuinely shared part is about five lines, and the two call sites want different things from it — `measure.cpp` `continue`s on an in-band sample while counting enters, widths and folded buckets, the detector needs a side to feed four state machines. Three similar lines beat a premature abstraction; the *invariant* is stated once and pointed at |
| The measurement filter is a **real one-pole at a user `fc`**, and it disables the pyramid fast path | Measuring the pyramid's per-bucket means, which would be free and keep the fast path — but its corner is wherever the bucket size puts it (`fs/64`), so it is HF reject at one fixed, rate-dependent frequency rather than a cutoff the user can aim at the interference they actually have |
| The filter runs **inside each of the two raw walks** | Materializing a filtered copy of the window: `MeasureChunk`'s comment (`measure.hpp:108-115`) records that widening the window cost 60 % of the pane's time before a single statistic had been computed, and 17 MB at a 2.2 M-sample window |
| One combined `de-noise` node (median **then** pole) | A plain median node chained to the existing `LowPass`: two boxes for one idea, and it lets the user wire them in the wrong order — a pole *before* a median lets one spike contaminate the filter state for its whole settling time, which defeats the purpose |
| The median window is **N samples, fixed** | A duration converted to N from `dt`: N would then grow with decimation, so a modest duration at a long window becomes a large sort in the producer's hot loop and needs a hidden cap the user did not set |
| `MathOp::DeNoise` is **appended**, at index 12 | Inserting it beside the filters in `math_op_at()`, where it belongs in the dropdown: a node persists as its *index through that accessor* (`layout_store.cpp:358-365`), so inserting would silently reinterpret every node in every existing session file |
| The median's ring is **pooled in `MathRuntime`** | A `double[9]` member of `MathNodeRt`: 72 bytes added to *every* node, doubling a struct whose shape README's math-cost section records as a deliberate choice, to serve the one op whose state size is a parameter |
| A new **impulsive** mock source | Verifying against `--noise` (Gaussian): a median is specifically the wrong tool for a raised noise floor and the right one for impulses, so a Gaussian test would prove nothing about the feature |
## 1. The trigger
### Config
`EventCondition` (`condition.hpp:27`) gains two fields:
```cpp
double noise_band = 0.0; ///< hysteresis half-width about `level`, signal units. 0 = off.
double hf_fc = 0.0; ///< HF reject corner on the comparator's input, Hz. 0 = off.
```
Both **must** join `operator==` (`condition.hpp:37-41`). This is not bookkeeping:
`EventDetector::configure()` returns early when the condition compares equal
(`condition.cpp:9`) and the producer calls it on every packet, so a field omitted from the
comparison is a control that silently never takes effect.
The filter needs the sample interval to turn `fc` into a coefficient, and the detector is fed
`(value, t)` pairs rather than a rate. `configure()` therefore takes `dt` as a second argument and
keeps it as a `dt_` member, and the early return compares the new `dt` against `dt_` as well as the
condition against the stored one — a re-plan changes `dt`, and a coefficient left over from the
previous partition is a filter at the wrong corner.
### Comparator
`EventDetector` gains `int side_ = 0` (0 undecided, ±1 above/below) and one pole state word.
`crossed_up`/`crossed_down` (`condition.hpp:77-82`) stop being pure functions of `prev_` and
become flags computed **once per sample** in `feed()`:
- `noise_band == 0` → `prev_ < level && v >= level` and its mirror, bit for bit as today.
- `noise_band > 0` → `v >= level + band` latches `+1`, `v <= level - band` latches `-1`, anything
between decides nothing; a crossing *is* a change of latch.
Computing the pair once also removes a duplicated computation: `feed_edge`, `feed_pulse` and
`feed_dropout` each call both predicates today.
`feed_window` (`condition.cpp:115`) does not use those predicates, so it takes the band
explicitly on each edge of its own band: becoming *inside* requires `[lo+band, hi-band]`,
becoming *outside* requires leaving `[lo-band, hi+band]`. A band wide enough to close that inner
region is clamped to just under half the window's width — the same shape of clamp
`measure_set_level()` already applies for the same reason, and it keeps the panel's existing
refusal of a zero-width window meaningful.
`reset()` (`condition.cpp:14`) clears `side_` and the pole state.
### The filter
One pole on the comparator's input, `y += a·(x − y)`, with `a = 1 − exp(−2π·fc·dt)` built in
`configure()` and clamped below Nyquist as `biquad()` does (`math_channel.cpp:28`). `fc = 0`
gives `a = 1`, i.e. `y = x`, so "off" needs no branch in the inner loop.
Two consequences, both stated rather than discovered:
- **The latch moves later** by roughly `1/(2π·fc)` — the pole's group delay. This is inherent,
it is what a bench scope's HF reject also does, and it is why the control is off by default.
- **A NaN stays benign.** `feed()`'s comment (`condition.hpp:68-70`) records that a NaN sample
cannot fire because every comparison against it is false, and that *rejecting* one would leave
`prev_` stale and let the next sample fire a crossing that today cannot happen. The filter
preserves this: a non-finite input returns NaN (which flows into the existing comparisons, all
false) and leaves the pole state alone.
`last_value()` (`condition.hpp:73`) comes to report the comparator's input rather than the raw
sample. Its only consumer is the `v=%.4g` field of the trig-engine diagnostic
(`acquisition.cpp:918`), whose purpose is to say what the comparator saw.
### Panel
A `trigger_band_spin` and a `trigger_hf_spin` in `build_trigger_panel()` (`ui_trigger.cpp:219`),
read in `publish_trigger_fields()` (`:56`), shown for all four condition types in
`sync_trigger_rows()` (`:94`). The band takes the level spin's unit, digits and range, floored at
0.
### The search gets both for free
`EventDetector` holds no arming state precisely so the buffer search can run the same evaluator
over retained samples (`condition.hpp:46-49`). A band or a filter set in the panel therefore
changes what a search finds, consistently, with no second code path and no second thing to keep
in step.
### Not persisted
The trigger has never been persisted, so neither control is either; a restart comes up with both
off. That is also what makes every current `[condition-probe]` latch time hold unchanged.
## 2. The measurements
### Config
`MeasureOptions` (`measure.hpp:37`) gains two fields:
```cpp
double noise_pct = 10.0; ///< hysteresis half-width, % of the window's peak-to-peak. 0 = off.
double hf_fc = 0.0; ///< HF reject corner, Hz. 0 = off.
```
`noise_pct = 10.0` reproduces today's `kHysteresisFrac = 0.10` (`measure.cpp:36`) exactly, so
every current reading is identical. `measure_set_level()` (`measure.hpp:195`,
`measure.cpp:40`) already takes a `pct` — the *threshold* percent — so it gains a second,
separately named `noise_frac` argument for the band, replacing its read of the file-scope
`kHysteresisFrac`; the constant stays as `MeasureOptions::noise_pct`'s default so its
measured-value rationale — 5 % reads a 0.1 interferer correctly and fails at
0.2, 10 % holds to 0.3 and to white noise at σ=0.05 — does not become an orphan comment. The
clamp to half the room between the level and the nearer extreme survives untouched: it is what
keeps a threshold at 90 % measurable instead of reading `enters=0`.
### The band reaches the edge types only
`MeasureLevel::hysteresis`'s comment (`measure.hpp:165-169`) is explicit that the band moves the
*edge decision* only, and that the `sum`/`count` split stays at the bare level — because that
split **is** `Top`/`Bottom`, the mean of the samples above or below the threshold, and banding it
would change the documented exact `top = 2/π` on a unit sine.
So `noise_pct` is meaningful for `PulseDuration`, `Period` and `Frequency`, and does nothing for
`Top`/`Bottom`. The file's own rule (`measure.hpp:34-36`) is that an option which does nothing
for a type is left out of the dialog rather than stored and ignored, so this adds a
`measure_uses_noise()` beside `measure_uses_threshold()` and `measure_uses_side()`
(`measure.hpp:242-245`), and `measure_dialog_sync()` (`ui_measure.cpp:90`) shows the field for
those three types only.
A *raised* band also costs reads: `measure_accumulate_bucket()` already refuses a bucket where
the signal passes `level ± hysteresis` (`measure.hpp:211-216`), so a wider band opens more
buckets. The existing `measure cost:` line is where that shows up.
### HF reject reaches every type
The filter changes the samples rather than a decision, so `min`, `max`, `average`,
`peak to peak`, `amplitude`, `top`, `bottom` and every crossing type all read the filtered
signal. The control is therefore shown for all types.
It is seeded with the window's first sample, so the step transient at the left edge does not read
as a measurement. (Priming from samples *before* the window is possible — the ring holds the
history — and is deliberately not done: it would make a measurement depend on data outside the
window it reports on.)
### What it costs
`hf_fc > 0` disables **both** bucket fast paths, `measure_scan_buckets()` (`measure.hpp:147`) and
`measure_accumulate_bucket()` (`:217`): a bucket's `lo`/`hi`/`sum` describe raw samples and cannot
answer for a filtered signal. Both passes walk raw, which is the 1.2–1.6 ms → ~20 ms step at a
2.2 M-sample window that README's measurement-cost section was written about. Against the pane's
250 ms cadence that is a few percent of a core and a visible frame hitch.
This is accepted deliberately: the control is per-measurement and off by default, so nothing gets
slower unasked, and `measure cost:`'s `covered`/`buckets`/`raw` makes the cost a *reading* rather
than a mystery. `raw ≈ covered` is the signature of a filtered measurement.
The filter runs inside each of the two raw walks — three flops on a sample the walk is already
reading — rather than materializing a filtered window once. Filtering twice is deterministic and
free compared with the alternative measured in `MeasureChunk`'s comment.
NaN keeps the existing rule: the walks already skip a non-finite sample, and the filter state is
left alone, so a hole stays a hole and the filter resumes on real samples.
### Two documented deviations
1. `docs/claude/invariants-readouts-and-cursors.md` holds that a readout quotes a stored sample
and never a value between two. A filtered `max` is a value that was never stored, and with
`show_on_plot` its ruler sits at a level the drawn — raw — trace never reaches. This is
inherent to filtering, so it becomes a stated exception in that invariant rather than a silent
violation.
2. The documented exact baselines (`peak to peak = 2`, `top = 0.636620`) hold with the filter
off. With it on they move, correctly; the probe reads both.
### Persistence
Free. A measurement is written as keyed TOML (`layout_store.cpp:659-672`) and read key by key
(`:544-556`), so two new keys load in an old session at their defaults.
## 3. The `de-noise` math node
### The op, appended
`MathOp::DeNoise` goes last in the enum (`math_channel.hpp:15-28`) and at index 12 in
`math_op_at()` (`math_channel.cpp:99-114`), with `Div` given an explicit `case 11` so the current
`default` no longer swallows it; `math_op_count()` becomes 13 and `math_op_name()` gains
`"de-noise"`.
Indices 0–11 keeping their meaning is exactly what leaves every existing session file reading
correctly, because a node persists as its index through that accessor. The price is that
de-noise sits last in the dialog's dropdown rather than beside the filters, which is the right
side of that trade.
### Parameters
`MathNode` (`math_channel.hpp:48-72`) gains `int med_n = 5;` — odd, 3 to 9, rounded to odd and
clamped. `fc` is reused for the HF reject; `q` is unused. `fc = 0` means median only, and it
expresses "off" without a branch: the pole's coefficient `a = 1 − exp(−2π·fc·dt)` is exactly 1 at
`fc = 0`.
The coefficient is built in `math_prepare()` (`math_channel.cpp:140-165`) where `dt` is known and
rebuilt on a `dt` change, exactly as the biquads are, with the same sub-Nyquist clamp
(`:28`).
### Predicates
`math_op_uses_filter()` (`math_channel.cpp:135`) keeps meaning **biquad** — it gates coefficient
construction at `:162`, and a de-noise node must not be given biquad coefficients. The dialog
gets a new `math_op_uses_fc()` (filter ‖ de-noise) and a `math_op_uses_median()` for the new spin
row. `math_dialog_sync_form()` (`ui_math.cpp:889-981`) already shows and hides one widget set per
op, and `q`'s row being visible for `BandPass` alone is the precedent for a per-op subset of the
filter row.
### Node text
`math_node_text()` (`layout_store.cpp:358`) emits a 13th pipe field. `math_node_from_text()`
accepts **12 or 13** fields, defaulting `med_n` when it reads 12, so existing sessions load
unchanged. A file written by this build and read by an older one is rejected by that build's
`f.size() != 12u` check (`:385`); there is no forward-compatibility requirement here, but it is
recorded rather than left as a surprise.
### Runtime state, pooled
`MathRuntime` (`math_channel.hpp:115-120`) gains one `std::vector<double>` holding the median
rings of whichever nodes need them; `MathNodeRt` gains an offset into it, a ring position and the
effective N. Not a fixed array member, for the reason in the decisions table. One indirection on
a path already doing N compares is free, and the choice is reversible if measurement disagrees.
### Evaluation
In `math_step()` (`math_channel.cpp:167-230`), the new case is: push the input into the ring,
take the median, then one pole step. **Median first** — de-spiking before smoothing is the whole
point, and the reverse lets one spike contaminate the filter state for its entire settling time.
The median is an insertion sort of at most nine doubles in a local array: ~20 compares worst
case, ~4 at N=3, against a documented 80–100 ns per slot for a whole three-graph runtime. That
ratio is measured and reported through the existing math-cost line, not assumed.
**Warm-up:** before the ring holds `med_n` samples the node outputs the median of what it holds,
which is a correct median of a shorter window. Deliberately not NaN — in this application a NaN
output *means a hole in the data* (`math_channel.cpp:64-70`), and spending that meaning on a
start-up transient would make a gap ambiguous.
**NaN** matches `biquad_step` exactly: a non-finite input returns NaN and leaves both the median
ring and the pole state alone, so the hole stays visible and the node resumes on real samples.
## 4. Verification
There is no unit test suite; the check is the application's own headless self-check under
`xvfb-run` against `tools/mock_udpstreamer.py`, read against two baselines (an exact warning
count, and no `Gtk-CRITICAL`).
### The regression assertion, first
Every default is today's behaviour, so the documented reading set must be **unchanged**:
- `[condition-probe]`'s four exact latch times (`t0=1.710000`, `2.160100`, `1.706700`,
`4.120200`);
- `peak to peak=2`, `amplitude=1`, `average=0`, `top=0.636620`;
- `measure cost:` with `raw ≪ covered`;
- the build's warning count — adding a field to `MathNode` leaves
`ui_probe.cpp:304` and `:313` at one `-Wmissing-field-initializers` each, so four on a clean
build and two on an incremental one still holds.
### The mock needs an impulsive source
`tools/mock_udpstreamer.py` offers `--noise` (Gaussian), `--glitch-at`/`--glitch-width` (a single
glitch at one time) and `--clock-jitter`. None of them is a *stream* of EM-like peaks, and a
median is the wrong tool for Gaussian noise, so none of the three pieces could be shown to work
against the mock as it stands.
Add `--spike-every S --spike-amp A --spike-width N`: a recurring impulse of N samples at
amplitude A, every S seconds. One flag set, and it is what every reading below is measured on.
### The four readings
1. **Trigger band** — a new `[condition-probe]` leg on a slow sine plus spikes. With
`noise_band = 0` the latch lands on a spike; with the band above the spike amplitude it lands
on the sine's own crossing time. The latched `t0` is the assertion, and the four existing
shapes are re-read unchanged in the same run.
2. **Trigger HF reject** — the same leg with the band off and `hf_fc` set. The latch moves
*later* by roughly `1/(2π·fc)`, reported beside the prediction. A latch that does not move is
the filter not being in the comparator's path.
3. **Measurements** — `Frequency` on the spiky mock read three ways: `noise_pct = 0` (counts
spikes, frequency far too high), `= 10` (the true frequency), and `hf_fc` set (the true
frequency *and* `raw ≈ covered`, which is the fast-path loss as a reading). Plus `peak to
peak` with the filter off and on, since that is the number the filter is supposed to move.
4. **The de-noise node** — `math_probe` (`ui_probe.cpp:304`) gains a de-noise graph on the spiky
channel. Its output's peak-to-peak must fall to the clean waveform's while a `LowPass` at the
same `fc` does not: that contrast is the entire justification for the node existing. Plus the
per-slot cost through the existing math-cost line.
## 5. Documentation
| File | What it gains |
| --- | --- |
| `README.md` | A noise-rejection section covering the three places, and the filtered-readout deviation stated rather than quietly taken |
| `docs/claude/invariants-readouts-and-cursors.md` | The stated exception: a filtered readout is not a stored sample, and its on-plot ruler can sit where the raw trace never goes |
| `docs/claude/invariants-measurements.md` | A filter on a measurement disables both bucket fast paths, and `raw ≈ covered` is its signature |
| `docs/claude/invariants-trigger-and-pinning.md` | The band, the `band == 0` verbatim path and why, and the latch delay a filter adds |
| `docs/claude/invariants-math-channels.md` | Op indices are append-only because the session format stores them; the pooled median state |
| `docs/claude/verification.md`, `docs/claude/probe-readings.md` | The four readings above and what a wrong value names |
| `docs/claude/build-and-run.md` | The new mock flags |
## 6. Out of scope
- Persisting the trigger. The trigger has never been persisted; the band and the filter follow
that, rather than this change being the one that introduces trigger persistence.
- Any averaging acquisition mode. Noise rejection here is per-measurement, per-trigger and
per-node; nothing changes what the ingest stores, so a raw trace stays raw and every reading
remains traceable to stored samples.
- Noise rejection on the cursors or the search's own UI. The search inherits the trigger's
condition and so inherits both controls; nothing further is added.
+29 -9
View File
@@ -626,6 +626,11 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// event window out into an immutable `EventSnapshot`.
BufferStore& store = model_.live();
const u64 g = part.time_stride;
// `tick % g == g - 1` as a mask. `plan_partition()` doubles `time_stride` from 1, so `g` is
// a power of two -- but it is a *runtime* value, so the modulo compiled to a real 64-bit
// division rather than the cheap test its comment there claims. See `acc_mask_` for the
// per-channel form and for what that cost on the hot loop.
const u64 g_mask = g - 1;
// A re-plan replaced the store, so every ring's `produced()` restarted at 0 while `tick_`
// kept counting. `backfill_from()` derives a slot index from a hole's `tick_begin`, which is
@@ -655,6 +660,13 @@ void Acquisition::on_data(const udps_frame_t* frame) {
value_acc_.assign(channels.size(), 0.0);
value_acc_n_.assign(channels.size(), 0);
math_sample_.assign(channels.size(), std::nanf(""));
// The group boundaries, as masks, for the per-sample loops below. Here rather than in
// them: `g` and the strides are properties of the partition, and a partition change is
// exactly what this block already reacts to.
acc_mask_.resize(channels.size());
for (std::size_t i = 0; i < channels.size(); ++i) {
acc_mask_[i] = g * std::max<u64>(1, part.stride[i]) - 1;
}
time_acc_ = 0.0;
abs_time_acc_ = 0.0;
time_acc_n_ = 0;
@@ -716,7 +728,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
trig_channel >= 0 && trig_channel < static_cast<int>(channels.size())
? channels[static_cast<std::size_t>(trig_channel)].name
: kNoTriggerChannel;
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s);
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
model_.partition().slot_dt());
// The engine's armed state follows the panel's, both ways: without the disarm
// half, Disarm left the engine firing (its own `armed_` was only ever set), and
// re-entering trigger mode kept the stale prev/edge/hold-off state.
@@ -797,6 +810,9 @@ void Acquisition::on_data(const udps_frame_t* frame) {
for (std::uint32_t k = 0; k < n; ++k) {
const u64 tick = tick_++;
// The shared clock's group boundary, read once and reused by the time ring, the channel
// loop's own per-channel test and the math push: three tests of the same thing.
const bool group_end = (tick & g_mask) == g_mask;
const double rel = std::max(stamps_[k] - epoch, last_stamp_);
last_stamp_used = rel;
last_stamp_ = rel;
@@ -808,7 +824,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
last_abs_stamp_ = abs_stamps_[k] - epoch;
abs_time_acc_ += last_abs_stamp_;
++time_acc_n_;
if (tick % g == g - 1) {
if (group_end) {
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
time_acc_ = 0.0;
// The absolute clock on the same slot and under the same group mean, so an export can
@@ -853,8 +869,8 @@ void Acquisition::on_data(const udps_frame_t* frame) {
value_acc_[i] += raw * ch.gain + ch.offset;
++value_acc_n_[i];
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const u64 mask = acc_mask_[i];
if ((tick & mask) != mask) continue;
const f32 value =
static_cast<f32>(value_acc_[i] / static_cast<double>(value_acc_n_[i]));
value_acc_[i] = 0.0;
@@ -878,7 +894,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
// `publish_event()` clamps its snapshot to every value ring's `produced()`, so a derived
// channel pushed after it would be missing its newest sample from the capture -- the same
// one-sample clamp the deferred latch above exists for.
if (tick % g == g - 1) model_.push_math_sample(math_sample_);
if (group_end) model_.push_math_sample(math_sample_);
if (latch_here) latch(latch_edge);
}
last_stamp_ = last_stamp_used;
@@ -929,8 +945,12 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
double dt = part.base_dt;
if (!(dt > 0.0)) dt = 1.0e-3;
// The same group tests as the live loop, written the same way: a hole's samples go through
// the same accumulators, so the two phases have to agree sample for sample.
const u64 g_mask = g - 1;
for (u64 j = 0; j < raw; ++j) {
const u64 tick = tick_++;
const bool group_end = (tick & g_mask) == g_mask;
// The hole is stamped at the declared cadence from the last good stamp -- exact in
// relative mode, and in stream mode the same continuation the glitch bridge makes.
// It must be *stamped* rather than left short: the time ring is binary-searched by
@@ -945,7 +965,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
time_acc_ += rel;
abs_time_acc_ += last_abs_stamp_;
++time_acc_n_;
if (tick % g == g - 1) {
if (group_end) {
store.time().push(time_acc_ / double(time_acc_n_));
// Null in stream mode, where `time()` already is the absolute clock.
if (plot::data::RingBuffer<f64>* abs = store.abs_time(); abs != nullptr) {
@@ -963,8 +983,8 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
// pretend to. One slot per edge, against a hole thousands of slots wide.
value_acc_[i] += std::numeric_limits<double>::quiet_NaN();
++value_acc_n_[i];
const u64 period = g * part.stride[i];
if (tick % period != period - 1) continue;
const u64 mask = acc_mask_[i];
if ((tick & mask) != mask) continue;
store.values(i).push(static_cast<f32>(value_acc_[i] / double(value_acc_n_[i])));
value_acc_[i] = 0.0;
value_acc_n_[i] = 0;
@@ -973,7 +993,7 @@ void Acquisition::reserve_hole(std::uint32_t missing, const Partition& part,
// its source had. The trigger is *not* fed: a NaN comparison is false, so the engine
// could not latch here anyway, and feeding it would advance its hold-off clock over
// time nothing was sampled in.
if (tick % g == g - 1) {
if (group_end) {
std::fill(math_sample_.begin(), math_sample_.end(), std::nanf(""));
model_.push_math_sample(math_sample_);
}
+9
View File
@@ -180,6 +180,15 @@ class Acquisition {
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
std::vector<f32> math_sample_;
std::vector<std::uint64_t> value_acc_n_;
/// Per channel, `time_stride * stride[i] - 1`: the group boundary as a **mask**, so the
/// innermost loop tests `(tick & mask) == mask` instead of `tick % period == period - 1`.
/// The divisor is a runtime value, so that modulo compiled to a real 64-bit `div` once per
/// channel per *wire* sample -- the filter reads every sample by design, so it was the
/// producer's largest avoidable cost. Both factors are powers of two (`plan_partition()`
/// doubles `time_stride` from 1, and every `stride[i]` is 1), which is what makes the mask
/// equivalent; it is sized and filled with the accumulators below, so a re-plan cannot
/// leave it addressing the previous partition's groups.
std::vector<std::uint64_t> acc_mask_;
double time_acc_ = 0.0;
double abs_time_acc_ = 0.0;
std::uint64_t time_acc_n_ = 0;
+59 -5
View File
@@ -3,11 +3,22 @@
#include "condition.hpp"
#include <cmath>
namespace udpscope {
void EventDetector::configure(const EventCondition& c) noexcept {
if (c == cond_) return;
void EventDetector::configure(const EventCondition& c, double dt) noexcept {
if (c == cond_ && dt == dt_) return;
cond_ = c;
dt_ = dt;
// a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1.
// fc == 0 leaves a == 1, which is a pass-through.
pole_a_ = 1.0;
if (cond_.hf_fc > 0.0 && dt > 0.0) {
const double nyquist = 0.5 / dt;
const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99;
pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt);
}
reset();
}
@@ -20,9 +31,45 @@ void EventDetector::reset() noexcept {
inside_ = false;
last_cross_s_ = 0.0;
dropped_ = false;
pole_y_ = 0.0;
pole_have_ = false;
side_ = 0;
}
double EventDetector::filter(double v) noexcept {
// A NaN must stay a NaN and must not poison the state: a dropout is a real
// reading here, not an error.
if (!std::isfinite(v)) return v;
if (!pole_have_) {
pole_y_ = v;
pole_have_ = true;
return pole_y_;
}
pole_y_ += pole_a_ * (v - pole_y_);
return pole_y_;
}
void EventDetector::decide(double v, bool& up, bool& down) noexcept {
up = false;
down = false;
if (cond_.noise_band <= 0.0) {
up = crossed_up(v);
down = crossed_down(v);
return;
}
// A Schmitt trigger: a sample inside the band decides nothing, and a crossing
// is a change between two *decided* sides. Seeding from the undecided side
// would fire on a signal that starts inside the band and leaves it without
// ever reaching `level`.
const int was = side_;
if (v >= cond_.level + cond_.noise_band) side_ = 1;
else if (v <= cond_.level - cond_.noise_band) side_ = -1;
up = was == -1 && side_ == 1;
down = was == 1 && side_ == -1;
}
double EventDetector::feed(double value, double t) noexcept {
value = filter(value);
if (!have_prev_) {
prev_ = value;
have_prev_ = true;
@@ -57,8 +104,9 @@ double EventDetector::feed(double value, double t) noexcept {
}
double EventDetector::feed_edge(double value, double t) noexcept {
const bool up = crossed_up(value);
const bool down = crossed_down(value);
bool up = false;
bool down = false;
decide(value, up, down);
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
(cond_.edge == TriggerEdge::Falling && down) ||
(cond_.edge == TriggerEdge::Both && (up || down));
@@ -115,7 +163,13 @@ double EventDetector::feed_pulse(double value, double t) noexcept {
double EventDetector::feed_window(double value, double t) noexcept {
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
const bool inside = value >= lo && value <= hi;
// The band is applied on the edge the signal must *leave*, and clamped to just
// under half the window so `lo + band` can never pass `hi - band`.
double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0;
const double room = 0.49 * (hi - lo);
if (band > room) band = room;
const bool inside = inside_ ? (value >= lo - band && value <= hi + band)
: (value >= lo + band && value <= hi - band);
if (inside == inside_) return kNoEvent;
inside_ = inside;
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
+24 -2
View File
@@ -33,11 +33,17 @@ struct EventCondition {
double width_s = 1.0e-3; ///< PulseWidth only.
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
double timeout_s = 1.0e-3; ///< Dropout only.
/// Half-width of the amplitude band around `level` a sample must leave before
/// a crossing counts. 0 is the bare comparator. In signal units.
double noise_band = 0.0;
/// Corner of a one-pole low pass applied to the sample before any comparison.
/// 0 is off. In Hz.
double hf_fc = 0.0;
bool operator==(const EventCondition& o) const noexcept {
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
timeout_s == o.timeout_s;
timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc;
}
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
};
@@ -60,7 +66,10 @@ class EventDetector {
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
/// every packet, and resetting there would restart the machine before any pulse could
/// close. A genuinely different condition resets, because its state means nothing.
void configure(const EventCondition& c) noexcept;
///
/// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the
/// HF reject's pole. A no-op unless the condition or the interval changed.
void configure(const EventCondition& c, double dt) noexcept;
void reset() noexcept;
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
@@ -80,6 +89,10 @@ class EventDetector {
bool crossed_down(double v) const noexcept {
return prev_ > cond_.level && v <= cond_.level;
}
/// The HF reject, applied to every sample before any comparison.
double filter(double v) noexcept;
/// `up`/`down` for the edge condition, through the band if there is one.
void decide(double v, bool& up, bool& down) noexcept;
double feed_edge(double value, double t) noexcept;
double feed_pulse(double value, double t) noexcept;
double feed_window(double value, double t) noexcept;
@@ -98,6 +111,15 @@ class EventDetector {
bool inside_ = false; ///< Window: which side of the band the previous sample was.
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
/// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off"
/// costs no branch. `pole_y_` is the filtered value.
double dt_ = 0.0;
double pole_a_ = 1.0;
double pole_y_ = 0.0;
bool pole_have_ = false;
/// Which side of the band the signal was last *decided* to be on: +1 above,
/// -1 below, 0 undecided. A sample inside the band decides nothing.
int side_ = 0;
};
} // namespace udpscope
+5 -2
View File
@@ -118,8 +118,11 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
// the ring capacities (themselves powers of two) in step and lets the producer's group
// test be a mask (`Acquisition::acc_mask_`) rather than a modulo -- a modulo on a stride
// this function chose at runtime is a real 64-bit division, which the test *was* paying
// once per channel per wire sample.
// The cap stops a nonsense request -- a window far past `--max-window` -- from
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
// picture is averages of averages anyway.
u64 m = 1;
+2 -2
View File
@@ -16,11 +16,11 @@ double ms_since(const std::chrono::steady_clock::time_point& t0) {
}
} // namespace
void BufferSearch::start(const EventCondition& c, int channel) {
void BufferSearch::start(const EventCondition& c, int channel, double dt) {
clear();
channel_ = channel;
detector_.reset();
detector_.configure(c);
detector_.configure(c, dt);
// `configure()` is a no-op for an unchanged condition, so the explicit reset above is what
// makes a re-run of the *same* search start from a clean machine.
detector_.reset();
+1 -1
View File
@@ -48,7 +48,7 @@ class BufferSearch {
};
/// Begins a scan of the channel for the condition. Discards any previous result.
void start(const EventCondition& c, int channel);
void start(const EventCondition& c, int channel, double dt);
void cancel() noexcept;
void clear() noexcept;
+3 -2
View File
@@ -14,7 +14,8 @@ double TriggerConfig::post_span_s(double buffer_span_s) const {
}
void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s) {
double post_seconds, double pre_seconds, double oldest_s,
double dt) {
const bool channel_changed = channel_name != channel_name_;
if (channel_changed) channel_name_ = channel_name;
cfg_ = cfg;
@@ -22,7 +23,7 @@ void TriggerEngine::configure(const TriggerConfig& cfg, const std::string& chann
pre_seconds_ = pre_seconds > 0.0 ? pre_seconds : 0.0;
oldest_s_ = oldest_s;
// A no-op unless the condition actually changed: this runs on every packet.
detector_.configure(cfg_.condition);
detector_.configure(cfg_.condition, dt);
if (channel_changed) {
detector_.reset();
pending_ = false;
+2 -1
View File
@@ -69,7 +69,8 @@ class TriggerEngine {
/// buffer's, not a timer since arming -- the ring keeps filling with the trigger
/// off, so the history is usually already there and waiting would be wrong.
void configure(const TriggerConfig& cfg, const std::string& channel_name,
double post_seconds, double pre_seconds, double oldest_s);
double post_seconds, double pre_seconds, double oldest_s,
double dt);
/// Disarms without forgetting the configuration.
void disarm() noexcept { armed_ = false; pending_ = false; }
void arm(double now_s) noexcept;
+45 -1
View File
@@ -3706,7 +3706,51 @@ gboolean tick(gpointer user) {
window_w < kSidebarRoom);
}
}
if (app->area_widget != nullptr) gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
// The render, queued once a frame -- but not unconditionally. In `Held` and `Paused` the rows
// are bound to an immutable snapshot on a pinned axis and the producer is feeding the live ring,
// not the series on screen, so a frame in which none of the picture's own terms moved is the
// same frame again. Every other mode renders: `Live` and `Capturing` roll new samples under the
// axis, and `Waiting` moves the live epoch, so in all three the picture differs every frame.
//
// The gate is two parts, and the second is what makes the first safe to keep coarse:
// * a signature of what a frozen view *can* be re-framed by -- a new snapshot, a pan or zoom
// (the group's range, which is where the gestures and the pin both write), a row added or
// removed -- which renders the moment it changes;
// * `kStaticRenderEvery`, a floor that renders anyway every few ticks, so anything not in the
// signature is late rather than lost. A gate that can only be opened by a list of state is
// a gate that goes wrong the first time the list is one item short.
// Interaction needs neither: `PlotArea` queues its own render from the pointer, scroll and drag
// handlers, so a crosshair and a cursor drag are immediate whatever this decides.
//
// The layout editor is excluded outright: its buttons are placed from the rects the last frame
// was laid out with (`sync_layout_editor()` above), so it is the one state where a skipped frame
// is a stale *widget* and not just a stale picture.
if (app->area_widget != nullptr) {
const bool frozen = (display == DisplayMode::Held || display == DisplayMode::Paused) &&
!app->layout_editing;
const plot::Range view = app->rows->view_range();
const std::uint64_t seq = model.event_seq();
const int rows = app->rows->row_count();
const bool same = frozen && seq == app->render_event_seq && view.min == app->render_x_min &&
view.max == app->render_x_max && rows == app->render_rows;
// A change opens the gate for `kSettleRenders` frames, not one: the figure's left gutter is a
// monotone floor that converges over two layout passes (see the `layout over N frames`
// reading), and a layout pass happens per *render* -- so a single frame after a re-frame would
// leave the gutter one pass short until the floor below fired, 64 ms later.
if (!same) app->render_open = kSettleRenders;
if (app->render_open > 0 || app->render_skipped + 1 >= kStaticRenderEvery) {
if (app->render_open > 0) --app->render_open;
app->render_skipped = 0;
gtk_gl_area_queue_render(GTK_GL_AREA(app->area_widget));
} else {
++app->render_skipped;
++app->dbg_render_skips;
}
app->render_event_seq = seq;
app->render_x_min = view.min;
app->render_x_max = view.max;
app->render_rows = rows;
}
// What the side column *demands*, not what it got. A GTK box's minimum width is its
// children's, a `GtkLabel`'s minimum is its text, and a minimum larger than the screen is
// honoured by growing the toplevel -- which hands the plot an allocation wider than the window
+29
View File
@@ -503,6 +503,23 @@ struct App {
/// 0 until the first event of the session.
gint64 last_latch_us = 0;
double timed_edge = 0.0;
/// The render gate's memory (see the end of `tick()`). A `Held` or `Paused` view is bound to
/// an immutable snapshot on a pinned axis, so a frame whose picture cannot have changed does
/// not need to be drawn again -- and on llvmpipe, or on a battery, re-drawing a frozen event
/// sixty times a second is the whole cost of the application. The first four are the coarse
/// signature of what a frozen picture can be re-framed by; a change to any of them opens
/// `render_open` for `kSettleRenders` frames, so the figure's layout floor has the passes it
/// needs to converge. `render_skipped` is the floor that makes everything the signature does
/// *not* name render soon anyway (within `kStaticRenderEvery` ticks), which is what keeps the
/// gate from being able to stall the picture.
std::uint64_t render_event_seq = 0;
double render_x_min = 0.0;
double render_x_max = 0.0;
int render_rows = -1;
int render_open = 0;
int render_skipped = 0;
/// Self-check: ticks that queued no render, so the gate is a number rather than a claim.
int dbg_render_skips = 0;
double window_limit_s = -1.0; ///< the cap the window control was last clamped against
/// The rate each channel actually *stored* over the last second, and the declared rate
/// beside it. The two differ for two reasons now and both are worth seeing: the declared
@@ -1021,6 +1038,18 @@ struct RowMenuProbeState {
};
constexpr int kTickMs = 16;
/// How often a frozen view is re-drawn anyway, in ticks: the render gate's floor (see `tick()`
/// and `App::render_skipped`). Four is ~15 Hz, so anything the gate's signature does not name --
/// a recolour, a measurement's mark, a y range a fit changed -- reaches the screen within ~64 ms
/// of a still picture, which is below noticing. It is deliberately a small number: the gate's
/// purpose is to stop spending a GPU on an unchanging frame, not to shave the last frame off.
constexpr int kStaticRenderEvery = 4;
/// How many frames one change to the gate's signature is worth (see `tick()` and
/// `App::render_open`). More than one because the figure lays itself out *per render* and its left
/// gutter is a monotone floor that takes two passes to converge (the `layout over N frames`
/// reading): a re-framed frozen view drawn once would sit a pass short of its final gutter until
/// the floor above fired. Three leaves a pass in hand without being a frame budget.
constexpr int kSettleRenders = 3;
/// Below this window width the sidebar has no room beside the plot, and the split view overlays it
/// instead of squeezing it. See `tick()`.
constexpr int kSidebarRoom = 900;
+10 -3
View File
@@ -1421,12 +1421,14 @@ gboolean search_probe_run(gpointer user) {
g_search_t0 = a->model->trigger_time();
EventCondition c;
int channel = -1;
double dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
dt = a->model->partition().slot_dt();
}
a->search.start(c, channel);
a->search.start(c, channel, dt);
std::fprintf(stderr, "[search-probe] started on channel %d, armed t0=%.6f\n", channel,
g_search_t0);
std::fflush(stderr);
@@ -2030,11 +2032,16 @@ void pause_probe_report(App& a, const char* what) {
// The bar's own string, because "how long have I been paused" is a *status* field and the
// elapsed figure is the one part of it that no other line can show: the exit dump's
// `status bar:` is read live and the two `mode ...` lines are the live and triggered states.
// `skips` is the render gate (see the end of `tick()`), cumulative over the session: it must be
// 0 on the `live` report and have grown by the `paused` one, which is the gate being a number
// rather than a claim. A live view that skips frames is the gate's condition inverted; a paused
// view that skips none is the gate not engaging at all.
std::fprintf(stderr,
"[pause-probe] %-9s paused=%d ring_produced=%zu event_size=%zu "
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] glance=[%s]\n",
"frozen_x=[%.6f..%.6f] axis=[%.6f..%.6f] skips=%d glance=[%s]\n",
what, a.model->paused() ? 1 : 0, ring_produced, event_size, bounds_lo, bounds_hi,
double(axis.min), double(axis.max), a.dbg_glance_last.c_str());
double(axis.min), double(axis.max), a.dbg_render_skips,
a.dbg_glance_last.c_str());
std::fflush(stderr);
}
+3 -1
View File
@@ -341,12 +341,14 @@ GtkWidget* build_trigger_panel(App& app) {
publish_trigger_fields(a);
EventCondition c;
int channel = -1;
double dt = 0.0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
dt = a->model->partition().slot_dt();
}
a->search.start(c, channel);
a->search.start(c, channel, dt);
update_search_panel(*a);
}),
&app);
+22
View File
@@ -82,6 +82,9 @@ def build_data(
glitch_at=-1.0,
glitch_width=0.0,
quiet_at=-1.0,
spike_every=0.0,
spike_amp=0.0,
spike_width=0,
):
stamps = [t0 + i * dt for i in range(batch)]
# `time_spike` models a device whose clock glitches for one packet: the stamps jump
@@ -103,6 +106,16 @@ def build_data(
]
else:
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
if spike_every > 0.0 and spike_amp != 0.0 and spike_width > 0:
# An impulse of `spike_width` samples at the start of every `spike_every`
# window, the sign alternating window to window so the train carries no
# DC a median could track. `stamps` are absolute stream seconds.
stamps_arr = np.array(stamps)
period = np.floor(stamps_arr / spike_every)
into = stamps_arr - period * spike_every
hit = into < (spike_width * dt)
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0)
sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0)
square = [
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
]
@@ -240,6 +253,12 @@ def main():
"opposite side of zero, so --quiet-at is its last zero crossing: the shape a "
"dropout trigger is read against. -1 = never",
)
ap.add_argument("--spike-every", type=float, default=0.0,
help="seconds between impulses on sine (0 disables)")
ap.add_argument("--spike-amp", type=float, default=0.0,
help="impulse amplitude, alternating sign window to window")
ap.add_argument("--spike-width", type=int, default=0,
help="impulse length in samples")
ap.add_argument(
"--blackout-at",
type=float,
@@ -435,6 +454,9 @@ def main():
args.glitch_at,
args.glitch_width,
args.quiet_at,
args.spike_every,
args.spike_amp,
args.spike_width,
)
burst = max(1, args.drop_burst)
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0: