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>
2312 lines
103 KiB
Markdown
2312 lines
103 KiB
Markdown
# Noise Rejection Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Give the trigger and every statistical measurement an optional amplitude-hysteresis band and an optional one-pole HF reject, and add a `de-noise` math node (running median followed by a one-pole) that rejects impulsive EM peaks.
|
||
|
||
**Architecture:** Three independent consumers of the same two ideas. `EventDetector` (shared by the live trigger and by `BufferSearch`) gains a latched comparator driven by a band and a one-pole prefilter; it needs the sample interval, so `dt` is plumbed into `configure()` from its three call sites. The measurement engine gains a per-measurement `MeasureFilter` that the two walks step, which forces the measurement pane's shared per-channel window cache to be keyed by `(channel, fc)` instead of by channel alone, and forces `measure_accumulate` to loop samples-outer/levels-inner. The math engine gains one appended `MathOp::DeNoise` whose runtime state is a pooled ring in `MathRuntime` plus a reused `b0`/`z1` pair for its pole.
|
||
|
||
**Tech Stack:** C++20, GTK4 + libadwaita, GtkBuilder `.ui` files compiled by `blueprint-compiler` into a GResource, CMake + Ninja, `xvfb-run` headless self-check against `tools/mock_udpstreamer.py` (Python 3).
|
||
|
||
## Global Constraints
|
||
|
||
- Every new source file carries the SPDX MIT header used by its neighbours. Copy the header from the file you are next to.
|
||
- **There is no unit test suite and you must not add one.** Every check in this plan is either a compile, or the application's own headless self-check. Extend the probes in `src/ui_probe.cpp` and the dumps in `src/ui.cpp`.
|
||
- **Build-warning baseline:** a clean build emits exactly **four** warnings (two `-Wmissing-field-initializers` on `MathNode::source` at `src/ui_probe.cpp:304` and `:313`, two `-Woverlength-strings` on the generated GResource `.c` files); an incremental build emits **two**. Any new warning is a defect. If you add a brace-initialised `MathNode` to a probe, you will add a warning — name the fields instead.
|
||
- **`Gtk-CRITICAL` baseline: zero.** Any `Gtk-CRITICAL` in a probe run is a defect.
|
||
- **No `gtk_widget_*` call may appear in a dump function.** Dumps read model and cached state only.
|
||
- Verification runs are **short and serial**: 8–14 s, one `xvfb-run` at a time, a **fresh UDP port per run**.
|
||
- Before and after every probe run, in **their own tool calls**, `pkill -x udpscope; pkill -x Xvfb` and `pkill -f 'mock_udpstream[e]r'`. An unbracketed pattern, or a `pkill` in the same command as the run, matches the invoking shell and kills it (exit 144, no output, a stale `/tmp/*.log` that reads like a real run).
|
||
- The mock's documented `--batch 2000` **does not work on this machine** (`OSError: [Errno 90] Message too long`, 80 kB > UDP's 65507). Use `--rate 1300000 --batch 1000`.
|
||
- `MathOp` is **append-only** and `math_op_at()` is its serialisation: a session file stores the op as its integer index. Never reorder.
|
||
- Measurement TOML keys are **named**, so an old session file loads new keys at their defaults. Keep it that way.
|
||
- A control that cannot affect the selected measurement or trigger type **is hidden**, not disabled.
|
||
- Decimation at ingest is a boxcar **average**, never a stride. `slot_dt()` is the **stored** interval and is the dt every filter in this plan uses.
|
||
- Exactly two threads. The producer owns `TriggerEngine` and `MathRuntime`; the GTK main thread owns every widget. A regression in `math_step` costs packets, not frames.
|
||
|
||
## File Structure
|
||
|
||
**Engine, producer side**
|
||
|
||
| File | Responsibility after this plan |
|
||
| --- | --- |
|
||
| `src/condition.hpp` / `.cpp` | `EventCondition` gains `noise_band` and `hf_fc`. `EventDetector` gains the one-pole prefilter, the latched side comparator, and a `dt_` it is configured with. |
|
||
| `src/trigger.hpp` / `.cpp` | Passes `dt` through `configure()` into the detector. No behaviour of its own. |
|
||
| `src/search.hpp` / `.cpp` | Passes `dt` through `start()` into the detector. No behaviour of its own. |
|
||
| `src/acquisition.cpp` | One call site: supplies `model_.partition().slot_dt()` to `engine.configure()`. |
|
||
| `src/math_channel.hpp` / `.cpp` | `MathOp::DeNoise` at index 12, `MathNode::med_n`, `math_op_uses_fc()`, `math_op_uses_median()`, the pooled median ring in `MathRuntime`, the `pole_a()` helper, the `DeNoise` case in `math_step`. |
|
||
| `src/measure.hpp` / `.cpp` | `MeasureOptions` gains `noise_pct` and `hf_fc`. New `MeasureFilter`. `measure_set_level()` takes the band fraction as an argument. Both walks take a filter by reference. `measure_accumulate` loops samples-outer. |
|
||
|
||
**UI, main thread**
|
||
|
||
| File | Responsibility after this plan |
|
||
| --- | --- |
|
||
| `src/ui/trigger-panel.ui` | Two new label-only rows, `band_row` and `hf_row`. |
|
||
| `src/ui_trigger.cpp` | Builds and reads the two new spins; shows/hides them per trigger type. |
|
||
| `src/ui_app.hpp` | Two new trigger widget pointers; `MeasureWindow` keyed by `(channel, fc)` in a vector; two new `MeasureDialog` fields; two new `MathDialog` rows; the new probe declarations. |
|
||
| `src/ui_measure.cpp` | The pane's window cache becomes a vector keyed by `(channel, fc)`; the level dedupe key becomes `(pct, noise)`; the two walks build and pass a filter. |
|
||
| `src/ui_math.cpp` | The de-noise rows in the math dialog form. |
|
||
| `src/ui.cpp` | The measurement dialog's two new spins; the math dialog's two new rows; the trigger dump's two new terms; the noise probe's registration. |
|
||
| `src/layout_store.cpp` | Two new measurement TOML keys; the math node text grows from 12 to 13 fields and reads either width. |
|
||
|
||
**Verification**
|
||
|
||
| File | Responsibility after this plan |
|
||
| --- | --- |
|
||
| `tools/mock_udpstreamer.py` | `--spike-every`, `--spike-amp`, `--spike-width`: an alternating-sign impulse train on `sine`. |
|
||
| `src/ui_probe.cpp` | A new `noise_probe` carrying every new reading. `math_probe` gains a de-noise node. |
|
||
| `docs/claude/verification.md` | The noise probe's invocation and its readings. |
|
||
| `docs/claude/probe-readings.md` | The per-line reference for `[noise-probe]`; the two moved readings. |
|
||
| `docs/claude/build-and-run.md` | The three new mock options. |
|
||
|
||
**Documentation**
|
||
|
||
`README.md` and the six invariant docs that own the touched areas: `invariants-trigger-and-pinning.md`, `invariants-readouts-and-cursors.md`, `invariants-measurements.md`, `invariants-math-channels.md`, `invariants-session-and-export.md`, `invariants-gtk-and-chrome.md`.
|
||
|
||
---
|
||
|
||
## Task 1: Mock spike train
|
||
|
||
The verification for all three features needs a stimulus whose impulses a band or a median rejects but a bare comparator does not. The mock gains an alternating-sign impulse train on `sine`: an impulse of `--spike-width` samples at `±--spike-amp` starting at every exact multiple of `--spike-every` seconds, positive on even periods and negative on odd. Alternating sign matters — a one-signed train is a DC offset that a median would partly track.
|
||
|
||
**Files:**
|
||
- Modify: `tools/mock_udpstreamer.py`
|
||
- Modify: `docs/claude/build-and-run.md`
|
||
|
||
**Interfaces:**
|
||
- Produces: `build_data(..., spike_every=0.0, spike_amp=0.0, spike_width=0)` and the three command-line options `--spike-every S --spike-amp A --spike-width N`. Later tasks invoke the mock with `--spike-every 0.005 --spike-amp 0.5 --spike-width 2`.
|
||
|
||
- [ ] **Step 1: Read the signature and the body you are extending**
|
||
|
||
Read `tools/mock_udpstreamer.py` lines 60–150 and 400–440. Note three things: the five signals are built in the fixed order `time, sine, square, ramp, noise`; `build_data` is called **positionally** at the bottom of the file; the glitch at lines 118–126 is the pattern to copy (a time mask applied to one array).
|
||
|
||
- [ ] **Step 2: Add the three parameters to `build_data`**
|
||
|
||
Append them to the signature, after `quiet_at`:
|
||
|
||
```python
|
||
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY,
|
||
noise=0.0, time_spike=0.0, glitch_at=-1.0, glitch_width=0.0,
|
||
quiet_at=-1.0, spike_every=0.0, spike_amp=0.0, spike_width=0):
|
||
```
|
||
|
||
- [ ] **Step 3: Inject the train into `sine`**
|
||
|
||
Immediately after `sine` is built and before `square`, add:
|
||
|
||
```python
|
||
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.
|
||
period = np.floor(stamps / spike_every)
|
||
into = stamps - period * spike_every
|
||
hit = into < (spike_width * dt)
|
||
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0)
|
||
sine = sine + np.where(hit, sign * spike_amp, 0.0)
|
||
```
|
||
|
||
- [ ] **Step 4: Add the three command-line options**
|
||
|
||
Beside `--quiet-at` in the argument parser:
|
||
|
||
```python
|
||
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")
|
||
```
|
||
|
||
- [ ] **Step 5: Pass them at the call site**
|
||
|
||
The `build_data(...)` call is positional. Append the three new arguments **in signature order** after the `quiet_at` argument:
|
||
|
||
```python
|
||
args.quiet_at,
|
||
args.spike_every,
|
||
args.spike_amp,
|
||
args.spike_width)
|
||
```
|
||
|
||
- [ ] **Step 6: Verify the train is there, without the scope**
|
||
|
||
Run a 2 s capture and count sign changes in the `sine` column with a one-off reader. There is no existing dump tool, so check it the cheap way — the mock's own log must show `gaps=0` and a non-zero rate:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
timeout 4 python3 tools/mock_udpstreamer.py --port 8101 --rate 1300000 --batch 1000 \
|
||
--duration 2 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/spike-mock.log 2>&1; \
|
||
cat /tmp/spike-mock.log
|
||
```
|
||
|
||
Expected: a final line reporting a rate near `580000` S/s and `gaps=0`. **A traceback mentioning `Message too long` means you changed the batch size** — it must stay at 1000. A traceback mentioning `np.where` or broadcasting means `stamps` is not the array you assumed; re-read how `sine` is built.
|
||
|
||
- [ ] **Step 7: Clean up the mock**
|
||
|
||
In its own tool call:
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 8: Document the three options**
|
||
|
||
`docs/claude/build-and-run.md` carries the mock's option reference. Add the three beside `--glitch-at`/`--glitch-width`:
|
||
|
||
- `--spike-every S` — seconds between impulses; `0` (the default) injects none, so every existing invocation is unchanged.
|
||
- `--spike-amp A` — the impulse amplitude, added to `sine` on top of the waveform. Alternating sign per period.
|
||
- `--spike-width N` — the impulse length in **samples**, not seconds, so its duration follows `--rate`.
|
||
|
||
State the two things a reader needs and cannot infer: the train lands on **`sine` only** (the other four signals are untouched, which is what lets a probe compare a spiked channel against a clean one in the same run), and the sign **alternates** per period because a one-signed train is a DC offset a median would partly track rather than reject. Also record that `--batch` must stay at or below 1000 with these options as without them: 2000 samples x 5 signals x `f64` is 80 kB and exceeds UDP's ~65507-byte payload, and the mock dies on its first `sendto` with `OSError: [Errno 90] Message too long` while the run still exits 0.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add tools/mock_udpstreamer.py docs/claude/build-and-run.md
|
||
git commit -m "test: the mock can inject an alternating-sign impulse train on sine"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: The trigger's two controls
|
||
|
||
`EventCondition` gains `noise_band` and `hf_fc`. `EventDetector` gains a one-pole prefilter and a latched side comparator. Because the pole's coefficient depends on the sample interval, `configure()` takes `dt`, and the three call sites supply it.
|
||
|
||
**Files:**
|
||
- Modify: `src/condition.hpp`, `src/condition.cpp`
|
||
- Modify: `src/trigger.hpp:71-72`, `src/trigger.cpp:16-31`
|
||
- Modify: `src/search.hpp:51`, `src/search.cpp:19-30`
|
||
- Modify: `src/acquisition.cpp:731`
|
||
- Modify: `src/ui_trigger.cpp:349` (call site only; the panel is Task 3)
|
||
- Modify: `src/ui_probe.cpp:1429` (call site only; the probe is Task 4)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing from earlier tasks.
|
||
- Produces:
|
||
- `double EventCondition::noise_band` and `double EventCondition::hf_fc`, both in `operator==`.
|
||
- `void EventDetector::configure(const EventCondition& c, double dt) noexcept`
|
||
- `void TriggerEngine::configure(const TriggerConfig&, const std::string&, double post_seconds, double pre_seconds, double oldest_s, double dt)`
|
||
- `void BufferSearch::start(const EventCondition&, int channel, double dt)`
|
||
|
||
- [ ] **Step 1: Add the two fields**
|
||
|
||
In `src/condition.hpp`, inside `EventCondition`, after the existing level fields:
|
||
|
||
```cpp
|
||
/// 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;
|
||
```
|
||
|
||
Add both to `operator==`, which must now compare ten members:
|
||
|
||
```cpp
|
||
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 && noise_band == o.noise_band && hf_fc == o.hf_fc;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Declare the new detector members**
|
||
|
||
In `src/condition.hpp`, change the `configure` declaration and add the state. Replace
|
||
|
||
```cpp
|
||
void configure(const EventCondition& c) noexcept;
|
||
```
|
||
|
||
with
|
||
|
||
```cpp
|
||
/// `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;
|
||
```
|
||
|
||
and in the private section, after `dropped_`:
|
||
|
||
```cpp
|
||
/// 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;
|
||
```
|
||
|
||
Also declare the two helpers beside `crossed_up`/`crossed_down`:
|
||
|
||
```cpp
|
||
/// 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;
|
||
```
|
||
|
||
- [ ] **Step 3: Write `configure` and extend `reset`**
|
||
|
||
In `src/condition.cpp`, replace `configure`:
|
||
|
||
```cpp
|
||
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();
|
||
}
|
||
```
|
||
|
||
Add `#include <cmath>` if it is not already there. In `reset()`, add the three new state members (not `dt_`, not `pole_a_` — those are configuration):
|
||
|
||
```cpp
|
||
pole_y_ = 0.0;
|
||
pole_have_ = false;
|
||
side_ = 0;
|
||
```
|
||
|
||
- [ ] **Step 4: Write `filter` and `decide`**
|
||
|
||
In `src/condition.cpp`, above `feed`:
|
||
|
||
```cpp
|
||
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;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run both through `feed`**
|
||
|
||
In `src/condition.cpp`, `feed()` takes `(value, t)` in that order and seeds `prev_` on the first sample before dispatching. Filter on the first line so every branch — edge, pulse, window, dropout — and the first-sample seeding all see the same filtered sample:
|
||
|
||
```cpp
|
||
double EventDetector::feed(double value, double t) noexcept {
|
||
value = filter(value);
|
||
if (!have_prev_) {
|
||
...unchanged...
|
||
```
|
||
|
||
Then replace the first two lines of `feed_edge` only; the `fired` dispatch below them is unchanged:
|
||
|
||
```cpp
|
||
double EventDetector::feed_edge(double value, double t) noexcept {
|
||
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));
|
||
return fired ? t : kNoEvent;
|
||
}
|
||
```
|
||
|
||
Filtering into the parameter rather than into a new local is deliberate on a second count: `feed` ends with `prev_ = value`, and `last_value()` returns `prev_`, so `last_value()` comes to report **the comparator's input** with no further change. That is what its one consumer wants — the `v=%.4g` field of the trig-engine diagnostic (`acquisition.cpp:918`), whose whole purpose is to say what the comparator saw. Do not introduce a separate raw `prev_` to "preserve" the old reading; a diagnostic quoting a sample the comparator never looked at is the bug, not the fix.
|
||
|
||
**`feed_pulse` and `feed_dropout` keep `crossed_up`/`crossed_down`.** A pulse is measured between a level crossing and its return, and widening that with a band would change the measured width, not just reject noise; a dropout is an absence, which a band cannot describe. Only the edge gets the latched comparator.
|
||
|
||
- [ ] **Step 6: Give the window type a latched band**
|
||
|
||
In `src/condition.cpp`, `feed_window` computes `lo`/`hi` and an `inside` flag. Widen the band it must leave, clamped so a band can never swallow the window. Replace the first three lines of the function with:
|
||
|
||
```cpp
|
||
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||
// 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;
|
||
```
|
||
|
||
At `band == 0` both arms reduce to today's `value >= lo && value <= hi`, so the documented reading that a ±1 square against a `[1,1]` band fires 19 times is unchanged. The `fired` dispatch below is untouched, and so is `feed()`'s first-sample seeding of `inside_` — a latch needs a side to start from, and the un-banded test is the right one for that.
|
||
|
||
- [ ] **Step 7: Plumb `dt` through the trigger engine**
|
||
|
||
`src/trigger.hpp:71-72` — append the parameter:
|
||
|
||
```cpp
|
||
void configure(const TriggerConfig& cfg, const std::string& channel_name,
|
||
double post_seconds, double pre_seconds, double oldest_s,
|
||
double dt);
|
||
```
|
||
|
||
`src/trigger.cpp` — take it and pass it on. The existing call becomes:
|
||
|
||
```cpp
|
||
detector_.configure(cfg_.condition, dt);
|
||
```
|
||
|
||
- [ ] **Step 8: Plumb `dt` through the search**
|
||
|
||
`src/search.hpp:51`:
|
||
|
||
```cpp
|
||
void start(const EventCondition& c, int channel, double dt);
|
||
```
|
||
|
||
`src/search.cpp` — the body already resets twice around the configure; keep that and pass `dt`:
|
||
|
||
```cpp
|
||
detector_.reset();
|
||
detector_.configure(c, dt);
|
||
detector_.reset();
|
||
```
|
||
|
||
- [ ] **Step 9: Supply `dt` at the three call sites**
|
||
|
||
`src/acquisition.cpp:731` — `model_.partition().slot_dt()` is already read into a local earlier in this function (around line 320). Use the same expression:
|
||
|
||
```cpp
|
||
engine.configure(tc, trig_name, post_want, event_span - post_want, oldest_s,
|
||
model_.partition().slot_dt());
|
||
```
|
||
|
||
`src/ui_trigger.cpp` — the Run handler already holds the model lock to read the condition and channel. Read the interval in the same critical section:
|
||
|
||
```cpp
|
||
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, dt);
|
||
```
|
||
|
||
`src/ui_probe.cpp:1429` — `search_probe_run` calls `a->search.start(c, channel)`. Read the interval under the lock the same way and pass it.
|
||
|
||
- [ ] **Step 10: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -30
|
||
```
|
||
|
||
Expected: links cleanly, **two** warnings (the incremental baseline: the two `MathNode::source` `-Wmissing-field-initializers` in `ui_probe.cpp`). A third warning, or any error about `configure` taking 5 arguments, means a call site was missed — grep for `\.configure(` and `search.start(`.
|
||
|
||
- [ ] **Step 11: One short probe run to confirm nothing regressed**
|
||
|
||
Clean up first, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8102 --rate 1300000 --batch 1000 --duration 12 > /tmp/t2-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_CONDITION_MS=4000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8102 2>&1 | tail -40
|
||
```
|
||
|
||
Expected: the four `[condition-probe]` legs report the same `hits`/`type` lines as `docs/claude/probe-readings.md` records, and no `Gtk-CRITICAL`. **`t0` will differ from the documented value and that is not a failure** — `probe-readings.md` states `t0` is a stream stamp rebased by whichever rebuild last dropped history, and reads a different value on every run.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add src/condition.hpp src/condition.cpp src/trigger.hpp src/trigger.cpp \
|
||
src/search.hpp src/search.cpp src/acquisition.cpp src/ui_trigger.cpp src/ui_probe.cpp
|
||
git commit -m "feat: the trigger can ignore a noise band and reject HF before comparing"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: The trigger panel's two rows
|
||
|
||
The two new fields need widgets. The panel's rows are label-only in the `.ui` file and the value widget is appended by `row_for()`; the rows are shown or hidden per trigger type by `sync_trigger_rows()`, and read back by `publish_trigger_fields()`.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui/trigger-panel.ui` (two new rows after `holdoff_row`)
|
||
- Modify: `src/ui_app.hpp:155-173` (two widget pointers)
|
||
- Modify: `src/ui_trigger.cpp` (build, read, show/hide, connect)
|
||
- Modify: `src/ui.cpp:5814-5833` (the dump's control count)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EventCondition::noise_band`, `EventCondition::hf_fc` from Task 2.
|
||
- Produces: `App::trigger_band_spin`, `App::trigger_hf_spin`; the trigger dump line now reads `controls_inside=15/15`.
|
||
|
||
- [ ] **Step 1: Add the two label-only rows**
|
||
|
||
In `src/ui/trigger-panel.ui`, after the `holdoff_row` child, copying the `level_row` shape exactly:
|
||
|
||
```xml
|
||
<child>
|
||
<object class="GtkBox" id="band_row">
|
||
<property name="orientation">horizontal</property>
|
||
<property name="spacing">6</property>
|
||
<child>
|
||
<object class="GtkLabel">
|
||
<property name="label" translatable="yes">noise band</property>
|
||
<property name="halign">start</property>
|
||
</object>
|
||
</child>
|
||
</object>
|
||
</child>
|
||
<child>
|
||
<object class="GtkBox" id="hf_row">
|
||
<property name="orientation">horizontal</property>
|
||
<property name="spacing">6</property>
|
||
<child>
|
||
<object class="GtkLabel">
|
||
<property name="label" translatable="yes">HF reject Hz</property>
|
||
<property name="halign">start</property>
|
||
</object>
|
||
</child>
|
||
</object>
|
||
</child>
|
||
```
|
||
|
||
- [ ] **Step 2: Round-trip the `.ui` file**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build --target test-ui 2>&1 | tail -15
|
||
```
|
||
|
||
Expected: every `.ui` file round-trips through `blueprint-compiler` against the GIR with no diff. A failure here is a malformed property or a duplicate id — the ids `band_row` and `hf_row` must be unique in the file.
|
||
|
||
- [ ] **Step 3: Add the two widget pointers**
|
||
|
||
In `src/ui_app.hpp`, beside `trigger_holdoff_spin`:
|
||
|
||
```cpp
|
||
GtkWidget* trigger_band_spin = nullptr; ///< EventCondition::noise_band
|
||
GtkWidget* trigger_hf_spin = nullptr; ///< EventCondition::hf_fc, 0 is off
|
||
```
|
||
|
||
- [ ] **Step 4: Build the two spins**
|
||
|
||
In `src/ui_trigger.cpp`'s `build_trigger_panel()`, beside the `holdoff` spin and its `row_for`:
|
||
|
||
```cpp
|
||
// A band in signal units, on the same scale as the level spins.
|
||
app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0);
|
||
row_for(ui, "band_row", app.trigger_band_spin);
|
||
// 0 is off. The upper bound is generous; configure() clamps to just under
|
||
// the stream's Nyquist, which the panel cannot know.
|
||
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||
row_for(ui, "hf_row", app.trigger_hf_spin);
|
||
```
|
||
|
||
- [ ] **Step 5: Publish them**
|
||
|
||
In `publish_trigger_fields()`, read both widgets with the others **before** taking the lock, then assign inside it:
|
||
|
||
```cpp
|
||
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(a->trigger_band_spin));
|
||
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(a->trigger_hf_spin));
|
||
```
|
||
|
||
and in the critical section, beside the other condition assignments:
|
||
|
||
```cpp
|
||
c.noise_band = band;
|
||
c.hf_fc = hf;
|
||
```
|
||
|
||
- [ ] **Step 6: Show them per type**
|
||
|
||
In `sync_trigger_rows()`, beside the existing five `show(...)` calls:
|
||
|
||
```cpp
|
||
// A band widens the comparator, which every type but Dropout compares with;
|
||
// a dropout is an absence and a band cannot describe one.
|
||
show("band_row", type != TriggerType::Dropout);
|
||
// The HF reject sits before every comparison, so it applies to all types.
|
||
show("hf_row", true);
|
||
```
|
||
|
||
`type` is whatever local `sync_trigger_rows()` already derives from the type dropdown — read the function and reuse it rather than re-reading the widget.
|
||
|
||
- [ ] **Step 7: Connect them**
|
||
|
||
In `build_trigger_panel()`'s `value-changed` connect loop (seven spins today), add the two new spins to the list so they call `on_trigger_field_changed`.
|
||
|
||
- [ ] **Step 8: Extend the dump**
|
||
|
||
`src/ui.cpp:5814-5833` is in the **exit dump**, not a probe, so it prints on every run. It sums 13 `inside(w, root)` terms. Change the format string's `controls_inside=%d/13` to `/15` and add two terms after `inside(app.trigger_holdoff_spin, app.trigger_panel)`:
|
||
|
||
```cpp
|
||
inside(app.trigger_band_spin, app.trigger_panel) +
|
||
inside(app.trigger_hf_spin, app.trigger_panel) +
|
||
```
|
||
|
||
`inside()` is the block's own local lambda and takes the widget *and* the root. Add nothing else to this block.
|
||
|
||
- [ ] **Step 9: Build and probe**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors.
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8103 --rate 1300000 --batch 1000 --duration 12 > /tmp/t3-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_FIELDS_MS=4000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8103 2>&1 | grep -E 'trigger panel|fields-probe|CRITICAL'
|
||
```
|
||
|
||
Expected: `controls_inside=15/15 search_widgets=4/4 search_inside=4/4`, one `[fields-probe]` line, and no `Gtk-CRITICAL`. `14/15` means one new spin is built but outside the panel — `row_for` silently appends nothing when the id does not resolve, so check the id spelling in the `.ui` against the string passed to `row_for`.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 10: Move the documented reading**
|
||
|
||
In `docs/claude/probe-readings.md`, the `[trigger-probe]` entry records `controls_inside=13/13`. Change it to `15/15` and add the two new spins to the list of what it counts.
|
||
|
||
- [ ] **Step 11: Commit**
|
||
|
||
```bash
|
||
git add src/ui/trigger-panel.ui src/ui_app.hpp src/ui_trigger.cpp src/ui.cpp docs/claude/probe-readings.md
|
||
git commit -m "ui: the trigger panel offers a noise band and an HF reject"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: The noise probe's trigger legs
|
||
|
||
A reading for each of the trigger's two controls. **This deviates from the spec** in two ways, both forced:
|
||
|
||
1. The spec asserts four exact `[condition-probe]` `t0` values. `docs/claude/probe-readings.md` states that **`t0` is not an assertion** — it is a stream stamp rebased by whichever rebuild last dropped history, and the same leg read `1.7103`, `1.6975` and `1.8719` on three consecutive runs. The controls are therefore read through `BufferSearch`, whose **hit counts** over one retained buffer are run-independent.
|
||
2. The spec measures the HF reject's group delay between a banded leg and a banded-plus-filtered leg on `sine`. That does not work: a `+0.5` impulse arriving while the sine sits at `0.3` already reaches the `0.8` threshold, so the band leg fires up to 20 ms **early**, quantised to the 5 ms spike grid. The delay is instead measured on **`square`**, which carries no spikes, where a one-pole turns a `-1 → +1` step into an exponential crossing zero at exactly `tau * ln2` with `tau = 1/(2*pi*fc)`.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_probe.cpp` (the new `noise_probe`)
|
||
- Modify: `src/ui_app.hpp` (declare it)
|
||
- Modify: `src/ui.cpp` (register `UDPSCOPE_PROBE_NOISE_MS`)
|
||
- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `EventCondition::noise_band`, `EventCondition::hf_fc`, `BufferSearch::start(c, channel, dt)` from Task 2.
|
||
- Produces: `gboolean noise_probe(gpointer user);` and the `[noise-probe]` dump lines. Tasks 8 and 11 append to the same function.
|
||
|
||
- [ ] **Step 1: Write the leg helper**
|
||
|
||
In `src/ui_probe.cpp`, above where you will put `noise_probe`:
|
||
|
||
```cpp
|
||
namespace {
|
||
|
||
struct NoiseLeg {
|
||
std::size_t hits = 0;
|
||
double first = std::numeric_limits<double>::quiet_NaN();
|
||
int state = 0;
|
||
};
|
||
|
||
/// One rising-edge search of the whole retained buffer at level 0.
|
||
///
|
||
/// Synchronous on purpose. Every leg must see the *same* buffer, and yielding to
|
||
/// the main loop between legs would let the producer append between them, so the
|
||
/// counts would no longer be comparable. The bound is a safety net: a chunk is
|
||
/// `BufferSearch::kChunk` samples, so 20000 pumps cover 160 M of them, far more
|
||
/// than the ring holds.
|
||
NoiseLeg noise_leg(App* a, int channel, double dt, double band, double hf) {
|
||
EventCondition c;
|
||
c.type = TriggerType::Edge;
|
||
c.edge = TriggerEdge::Rising;
|
||
c.level = 0.0;
|
||
c.noise_band = band;
|
||
c.hf_fc = hf;
|
||
a->search.start(c, channel, dt);
|
||
for (int i = 0; i < 20000 && a->search.pump(*a->model); ++i) {
|
||
}
|
||
NoiseLeg r;
|
||
r.hits = a->search.hits().size();
|
||
if (r.hits > 0) r.first = a->search.hits().front();
|
||
r.state = static_cast<int>(a->search.state());
|
||
return r;
|
||
}
|
||
|
||
/// The difference of two first-hit stamps, folded into +/- half a period, so a
|
||
/// pair whose first hits landed on different cycles still reads as a delay.
|
||
double noise_fold(double d, double period) {
|
||
const double half = 0.5 * period;
|
||
d = std::fmod(d, period);
|
||
if (d > half) d -= period;
|
||
if (d < -half) d += period;
|
||
return d;
|
||
}
|
||
|
||
int noise_channel(App* a, const char* name) {
|
||
const std::vector<ChannelInfo>& channels = a->model->channels();
|
||
for (std::size_t i = 0; i < channels.size(); ++i) {
|
||
if (channels[i].name == name) return static_cast<int>(i);
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
} // namespace
|
||
```
|
||
|
||
`<cmath>`, `<limits>` and `<vector>` are already included by this file; check and add only what is missing.
|
||
|
||
- [ ] **Step 2: Write the probe**
|
||
|
||
```cpp
|
||
gboolean noise_probe(gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||
const int sine = noise_channel(a, "sine");
|
||
const int square = noise_channel(a, "square");
|
||
double dt = 0.0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||
dt = a->model->partition().slot_dt();
|
||
}
|
||
if (sine < 0 || square < 0 || !(dt > 0.0)) {
|
||
std::fprintf(stderr, "[noise-probe] no stream: sine=%d square=%d dt=%g\n", sine, square,
|
||
dt);
|
||
std::fflush(stderr);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
|
||
// A: the bare comparator on a spiked sine. Every impulse landing where the
|
||
// sine is within the impulse's own amplitude of zero fires a crossing.
|
||
const NoiseLeg a_leg = noise_leg(a, sine, dt, 0.0, 0.0);
|
||
// B: a 0.8 band. A +/-0.5 impulse cannot carry the signal across a 1.6-wide
|
||
// band, so only the sine's own cycles are left.
|
||
const NoiseLeg b_leg = noise_leg(a, sine, dt, 0.8, 0.0);
|
||
// C: no band, a 50 Hz reject. A 2-sample 0.5 impulse moves a pole with
|
||
// a = 1 - exp(-2*pi*50*dt) by about 5e-4, which cannot reach the level.
|
||
const NoiseLeg c_leg = noise_leg(a, sine, dt, 0.0, 50.0);
|
||
// D: both. The reject attenuates a 5 Hz sine by only 1/sqrt(1 + (5/50)^2),
|
||
// so its peaks still clear the band and the count is unchanged.
|
||
const NoiseLeg d_leg = noise_leg(a, sine, dt, 0.8, 50.0);
|
||
// E/F: the reject's group delay, on `square`, which carries no impulses. A
|
||
// one-pole turns a -1 -> +1 step into -1 + 2*(1 - exp(-t/tau)), which crosses
|
||
// zero at tau*ln2 -- exact, and the same on every edge.
|
||
const NoiseLeg e_leg = noise_leg(a, square, dt, 0.0, 0.0);
|
||
const NoiseLeg f_leg = noise_leg(a, square, dt, 0.0, 50.0);
|
||
|
||
const double period = 0.2; // the mock's default --freq is 5 Hz.
|
||
const double delay = noise_fold(f_leg.first - e_leg.first, period);
|
||
const double want = std::log(2.0) / (2.0 * 3.14159265358979323846 * 50.0);
|
||
|
||
std::fprintf(stderr,
|
||
"[noise-probe] trigger band: bare=%zu/%d band=%zu/%d ratio=%.2f\n",
|
||
a_leg.hits, a_leg.state, b_leg.hits, b_leg.state,
|
||
b_leg.hits > 0 ? static_cast<double>(a_leg.hits) /
|
||
static_cast<double>(b_leg.hits)
|
||
: 0.0);
|
||
std::fprintf(stderr,
|
||
"[noise-probe] trigger hf: hf=%zu/%d band+hf=%zu/%d band=%zu\n", c_leg.hits,
|
||
c_leg.state, d_leg.hits, d_leg.state, b_leg.hits);
|
||
std::fprintf(stderr,
|
||
"[noise-probe] trigger delay: clean=%zu filtered=%zu delay=%.6f want=%.6f "
|
||
"ratio=%.3f\n",
|
||
e_leg.hits, f_leg.hits, delay, want, want > 0.0 ? delay / want : 0.0);
|
||
std::fflush(stderr);
|
||
a->search.clear();
|
||
update_search_panel(*a);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Declare and register it**
|
||
|
||
In `src/ui_app.hpp`, beside the other probe declarations:
|
||
|
||
```cpp
|
||
gboolean noise_probe(gpointer user);
|
||
```
|
||
|
||
In `src/ui.cpp`, in the registration block, matching its neighbours exactly:
|
||
|
||
```cpp
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
|
||
g_timeout_add(static_cast<guint>(std::atoi(env)), noise_probe, &app);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors.
|
||
|
||
- [ ] **Step 5: Run the probe**
|
||
|
||
Clean up first, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 16 python3 tools/mock_udpstreamer.py --port 8104 --rate 1300000 --batch 1000 \
|
||
--duration 14 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t4-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=12000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_NOISE_MS=8000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8104 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||
```
|
||
|
||
Expected, with `N` the number of 5 Hz cycles in the retained buffer (about 30 for a 6 s buffer):
|
||
|
||
- `trigger band:` — `bare` is many times `band`; **`ratio` at or above 5**. `band` is about `N`. A `ratio` near 1 means the band is not being applied — check that `decide()` is reached, i.e. that `feed_edge` calls it.
|
||
- `trigger hf:` — `hf` and `band+hf` are both within 2 of `band`. `hf` near `bare` means `pole_a_` stayed at 1; check `configure()` actually recomputes it and that `hf_fc` survived `publish_trigger_fields` (it is set directly on the condition here, so a failure is in `configure`). `band+hf` near 0 means the reject is attenuating the sine away — check the Nyquist clamp is not clamping 50 Hz.
|
||
- `trigger delay:` — `clean` and `filtered` within 2 of each other, `want=0.002206`, and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 means the filter is a pass-through; a large one means `a` is far too small, i.e. `dt` did not arrive.
|
||
- `state` is `2` (`Done`) or `4` (`Capped`) on every leg. `3` (`Truncated`) on more than one leg means the pump loop is being overtaken; nothing to fix in the feature, but say so in the reading.
|
||
|
||
No `Gtk-CRITICAL`.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 6: Document the probe**
|
||
|
||
In `docs/claude/verification.md`, add the noise probe beside the others: the mock invocation above (including the three spike options and why they are needed), the `UDPSCOPE_PROBE_NOISE_MS=8000` schedule, and the note that this probe needs a **spiked** mock while every other probe uses the plain one.
|
||
|
||
In `docs/claude/probe-readings.md`, add a `[noise-probe]` section with one entry per line above, giving for each field the value it must read and the defect a wrong reading names — the text from Step 5, written as the per-line reference the rest of that file is. Record explicitly that `bare` and `band` are proportional to the retained buffer's length and so are **not** fixed numbers; only the ratio and the agreement between legs are assertions.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp docs/claude/verification.md docs/claude/probe-readings.md
|
||
git commit -m "test: the noise probe reads the trigger's band and HF reject"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: The measurement engine's two controls
|
||
|
||
The hysteresis band stops being a constant and becomes `MeasureOptions::noise_pct`, and a one-pole prefilter becomes something both walks step. Stepping a filter forces `measure_accumulate` to loop samples-outer, because the filter has one state for the whole walk and the present loop would advance it once per level per sample.
|
||
|
||
**Files:**
|
||
- Modify: `src/measure.hpp`, `src/measure.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing from earlier tasks.
|
||
- Produces:
|
||
- `double MeasureOptions::noise_pct` (default `10.0`, a percentage) and `double MeasureOptions::hf_fc` (default `0.0`, Hz).
|
||
- `struct MeasureFilter { double a; double y; bool have; double step(double) noexcept; void reset() noexcept; };`
|
||
- `void measure_filter_set(MeasureFilter& f, double fc, double dt);`
|
||
- `void measure_set_level(MeasureLevel&, const MeasureStats&, double pct, double noise_frac);`
|
||
- `void measure_scan(const MeasureChunk&, MeasureStats&, MeasureFilter&);`
|
||
- `void measure_accumulate(const MeasureChunk&, const std::function<double(std::size_t)>&, MeasureLevel*, std::size_t, MeasureFilter&);`
|
||
- `bool measure_uses_noise(MeasureType);`
|
||
|
||
- [ ] **Step 1: Add the two options**
|
||
|
||
In `src/measure.hpp`, in `MeasureOptions` after `show_on_plot`. The band's rationale is `kHysteresisFrac`'s comment, moved here verbatim because the constant is going away:
|
||
|
||
```cpp
|
||
/// `PulseDuration`/`Period`/`Frequency`: the half-width of the hysteresis band about the
|
||
/// threshold, as a percentage of the window's own peak-to-peak. A crossing counts only once
|
||
/// the signal has passed the band, so an edge count is a count of the *waveform's* cycles
|
||
/// rather than of its noise.
|
||
///
|
||
/// The default is measured rather than chosen: 5 % reads the mock's 0.1 interferer correctly
|
||
/// and fails at 0.2 (`period=0.075` for a true 0.2), while 10 % holds to an interferer
|
||
/// amplitude of 0.3 -- a third of the waveform -- and to white noise at sigma=0.05. Above
|
||
/// that the band starts eating a real waveform's own extremes on anything less square than a
|
||
/// sine. 0 reduces the walk exactly to a single level.
|
||
double noise_pct = 10.0;
|
||
/// Every type: the corner of a one-pole low pass applied to each sample before *either*
|
||
/// walk reads it, so the extremes, the mean and the crossings all describe the filtered
|
||
/// signal. 0 is off. In Hz.
|
||
///
|
||
/// It rejects what a band cannot: an impulse large enough to cross the band on its own.
|
||
/// It is not free -- a filtered window cannot use the LOD pyramid, because a bucket
|
||
/// aggregates unfiltered samples -- so a non-zero corner costs a full raw walk.
|
||
double hf_fc = 0.0;
|
||
```
|
||
|
||
- [ ] **Step 2: Add `MeasureFilter`**
|
||
|
||
In `src/measure.hpp`, above `MeasureChunk`:
|
||
|
||
```cpp
|
||
/// The HF reject, as a walk carries it: one pole, one state word.
|
||
///
|
||
/// `a == 1` is a pass-through and is the default, so an unfiltered walk pays one multiply and
|
||
/// no branch, and every caller can step it unconditionally. A non-finite sample passes through
|
||
/// unchanged and leaves the state alone -- a dropout is a real reading, and letting it poison
|
||
/// `y` would make every sample after it non-finite too.
|
||
struct MeasureFilter {
|
||
double a = 1.0;
|
||
double y = 0.0;
|
||
bool have = false;
|
||
|
||
double step(double v) noexcept {
|
||
if (!std::isfinite(v)) return v;
|
||
if (!have) {
|
||
y = v;
|
||
have = true;
|
||
return y;
|
||
}
|
||
y += a * (v - y);
|
||
return y;
|
||
}
|
||
void reset() noexcept {
|
||
y = 0.0;
|
||
have = false;
|
||
}
|
||
};
|
||
|
||
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0`
|
||
/// or `dt == 0` leaves it at 1, which is the pass-through. Resets the state.
|
||
void measure_filter_set(MeasureFilter& f, double fc, double dt);
|
||
```
|
||
|
||
`measure.hpp` already includes `<cmath>` for `MeasureStatsAccum::sd()`; confirm it and add the include if not.
|
||
|
||
- [ ] **Step 3: Change the three engine signatures**
|
||
|
||
In `src/measure.hpp`:
|
||
|
||
```cpp
|
||
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter& filter);
|
||
```
|
||
|
||
```cpp
|
||
/// `noise_frac` is `MeasureOptions::noise_pct / 100`. It is the caller's because the band is a
|
||
/// per-measurement setting and this is called once per *level*, which several measurements may
|
||
/// share; the clamp to half the room to the nearer extreme stays here, so every caller gets it.
|
||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct,
|
||
double noise_frac);
|
||
```
|
||
|
||
```cpp
|
||
void measure_accumulate(const MeasureChunk& chunk,
|
||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||
std::size_t level_count, MeasureFilter& filter);
|
||
```
|
||
|
||
Leave `measure_scan_buckets` and `measure_accumulate_bucket` alone: a bucket aggregates unfiltered samples, so a filtered walk must not use them at all. Add that sentence to both of their comments.
|
||
|
||
- [ ] **Step 4: Add `measure_uses_noise`**
|
||
|
||
In `src/measure.hpp`, beside `measure_uses_side`:
|
||
|
||
```cpp
|
||
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The
|
||
/// band moves the *edge* decision and nothing else, so it is exactly the crossing types:
|
||
/// `Top`/`Bottom` split their sums at the bare level and read the same answer at any band.
|
||
bool measure_uses_noise(MeasureType type);
|
||
```
|
||
|
||
In `src/measure.cpp`, beside `measure_uses_side`:
|
||
|
||
```cpp
|
||
bool measure_uses_noise(MeasureType type) {
|
||
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
|
||
type == MeasureType::Frequency;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Replace the constant with the argument**
|
||
|
||
In `src/measure.cpp`, delete `kHysteresisFrac` and its comment block from the anonymous namespace (the comment has moved to `MeasureOptions::noise_pct`), and add a pi for the pole:
|
||
|
||
```cpp
|
||
constexpr double kPi = 3.14159265358979323846;
|
||
```
|
||
|
||
Then:
|
||
|
||
```cpp
|
||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct,
|
||
double noise_frac) {
|
||
const double span = s.hi - s.lo;
|
||
acc.level = s.lo + pct * span;
|
||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
|
||
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
|
||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
|
||
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Write `measure_filter_set`**
|
||
|
||
In `src/measure.cpp`, beside `measure_set_level`:
|
||
|
||
```cpp
|
||
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
|
||
f.reset();
|
||
f.a = 1.0;
|
||
if (fc > 0.0 && dt > 0.0) {
|
||
const double nyquist = 0.5 / dt;
|
||
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Filter in pass one**
|
||
|
||
In `src/measure.cpp`, `measure_scan` takes the filter and steps it before the finite test. Only the signature and the two lines at the top of the loop change:
|
||
|
||
```cpp
|
||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
|
||
...unchanged locals...
|
||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||
if (!std::isfinite(v)) continue;
|
||
...unchanged body...
|
||
```
|
||
|
||
- [ ] **Step 8: Filter in pass two, and swap the loops**
|
||
|
||
In `src/measure.cpp`, replace `measure_accumulate` entirely:
|
||
|
||
```cpp
|
||
void measure_accumulate(const MeasureChunk& chunk,
|
||
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
|
||
std::size_t level_count, MeasureFilter& filter) {
|
||
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately.
|
||
// The filter holds one state for the whole walk, so a levels-outer loop would step it once per
|
||
// level per sample and every level after the first would read a signal filtered `k` times over.
|
||
// Each level's own state is independent of the others, so the swap costs nothing else.
|
||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||
const double v = filter.step(static_cast<double>(chunk.value[i]));
|
||
if (!std::isfinite(v)) continue;
|
||
for (std::size_t k = 0; k < level_count; ++k) {
|
||
MeasureLevel& acc = levels[k];
|
||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||
// below the threshold, and a band here would move a documented exact reading.
|
||
const int s = v > acc.level ? 1 : 0;
|
||
acc.sum[static_cast<std::size_t>(s)] += v;
|
||
++acc.count[static_cast<std::size_t>(s)];
|
||
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
|
||
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
|
||
// signal that merely wobbles about the level from counting every wobble as an edge. With
|
||
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
|
||
int d;
|
||
if (v > acc.level + acc.hysteresis) {
|
||
d = 1;
|
||
} else if (v < acc.level - acc.hysteresis) {
|
||
d = 0;
|
||
} else {
|
||
continue;
|
||
}
|
||
if (acc.have_side && d != (acc.side ? 1 : 0)) {
|
||
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
|
||
}
|
||
acc.side = d != 0;
|
||
acc.have_side = true;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
The inner `continue` now skips to the next *level*, which is what it must do: a sample inside one level's band may be outside another's.
|
||
|
||
- [ ] **Step 9: Build**
|
||
|
||
The callers in `src/ui_measure.cpp` are Task 6, so this build **will** fail there. Compile just this translation unit:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build --target udpscope 2>&1 | grep -E 'measure\.cpp|error' | head -20
|
||
```
|
||
|
||
Expected: no line mentioning `measure.cpp`. Errors in `ui_measure.cpp` are expected and are Task 6's. If `measure.cpp` itself errors, fix it before going on.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/measure.hpp src/measure.cpp
|
||
git commit -m "feat: a measurement's band is a setting and its walks can reject HF"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: The measurement pane's per-filter windows
|
||
|
||
The pane shares its two walks **per channel**, but `hf_fc` is **per measurement**: two measurements on one signal at different corners are two different windows. The cache therefore stops being keyed by channel and becomes a vector keyed by `(channel, fc)`. A filtered window must also stop using the LOD pyramid, which one line does — leaving `buckets` empty makes `bulk_from == bulk_to == n`, so the raw walk covers the whole window *and* `lo_search`/`hi_search` stay 0, which makes `pin()` a no-op.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_app.hpp:281` (`MeasureWindow` and `measure_samples`)
|
||
- Modify: `src/ui_measure.cpp` (five sites in `update_measures`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MeasureFilter`, `measure_filter_set`, `measure_uses_noise`, the new `measure_scan`/`measure_accumulate`/`measure_set_level` signatures from Task 5.
|
||
- Produces: `App::MeasureWindow` with `channel`, `fc`, `noise`, `scan_filter`, `acc_filter`; `std::vector<App::MeasureWindow> App::measure_samples`.
|
||
|
||
- [ ] **Step 1: Re-key the window**
|
||
|
||
In `src/ui_app.hpp`, replace `MeasureWindow` and its container. `measure_samples` is referenced **only** in `ui_measure.cpp` — verify with a grep before changing it, and if another file appears, fix it too.
|
||
|
||
```cpp
|
||
/// One channel's window, at one HF corner, with every level accumulator the pane asked for.
|
||
///
|
||
/// Keyed by `(channel, fc)` and not by channel alone: the corner is a per-measurement
|
||
/// setting, so two measurements on one signal at different corners are reading two
|
||
/// different signals and cannot share a walk.
|
||
struct MeasureWindow {
|
||
int channel = -1;
|
||
double fc = 0.0; ///< the HF corner this window was walked at; 0 is unfiltered
|
||
MeasureStats stats;
|
||
std::vector<double> pct; ///< the level shares asked for, deduplicated
|
||
std::vector<double> noise; ///< each entry's band, as a percentage; parallel to `pct`
|
||
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
|
||
MeasureFilter scan_filter; ///< pass one's state
|
||
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
|
||
bool ok = false;
|
||
};
|
||
```
|
||
|
||
and
|
||
|
||
```cpp
|
||
std::vector<MeasureWindow> measure_samples;
|
||
```
|
||
|
||
- [ ] **Step 2: Read the sample interval once**
|
||
|
||
In `src/ui_measure.cpp`'s `update_measures`, just after `app.measure_samples.clear();`:
|
||
|
||
```cpp
|
||
// Before any `MeasureWindowReader` exists: the reader holds `model->mutex()` for a live
|
||
// window, and asking the model for anything while one is alive would deadlock.
|
||
double slot_dt = 0.0;
|
||
{
|
||
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||
slot_dt = app.model->partition().slot_dt();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Rework the grouping loop**
|
||
|
||
Replace the loop at `src/ui_measure.cpp:286-300` with:
|
||
|
||
```cpp
|
||
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);
|
||
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);
|
||
chan[i] = channel;
|
||
if (channel < 0) continue; // unresolvable this pass: a rebuild has emptied the channel list
|
||
const double fc = m.options.hf_fc > 0.0 ? m.options.hf_fc : 0.0;
|
||
std::size_t wi = 0;
|
||
while (wi < app.measure_samples.size() &&
|
||
(app.measure_samples[wi].channel != channel || app.measure_samples[wi].fc != fc)) {
|
||
++wi;
|
||
}
|
||
if (wi == app.measure_samples.size()) {
|
||
App::MeasureWindow fresh;
|
||
fresh.channel = channel;
|
||
fresh.fc = fc;
|
||
measure_filter_set(fresh.scan_filter, fc, slot_dt);
|
||
measure_filter_set(fresh.acc_filter, fc, slot_dt);
|
||
app.measure_samples.push_back(fresh);
|
||
}
|
||
want_window[i] = static_cast<int>(wi);
|
||
App::MeasureWindow& w = app.measure_samples[wi];
|
||
double pct = 0.0;
|
||
if (!measure_level_pct(m.type, m.options, pct)) continue;
|
||
// 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);
|
||
}
|
||
want_level[i] = static_cast<int>(k);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Walk each window**
|
||
|
||
In the data loop, replace the `for (auto& entry : app.measure_samples)` header and the reader construction:
|
||
|
||
```cpp
|
||
for (App::MeasureWindow& w : app.measure_samples) {
|
||
const gint64 fetch_start_us = g_get_monotonic_time();
|
||
MeasureWindowReader reader(app, static_cast<std::size_t>(w.channel));
|
||
```
|
||
|
||
Delete the `App::MeasureWindow& w = entry.second;` line.
|
||
|
||
Gate the bucket fetch on the filter:
|
||
|
||
```cpp
|
||
// A filtered window cannot use the pyramid: a bucket aggregates *unfiltered* samples, so its
|
||
// extremes and its sum describe a different signal. Leaving `buckets` empty is the whole
|
||
// disable -- `bulk_from == bulk_to == n` makes the raw walk cover the window, and
|
||
// `lo_search`/`hi_search` stay 0, which makes `pin()` below a no-op. One `if` turns off both
|
||
// fast paths, and nothing downstream needs to know.
|
||
const bool filtered = w.fc > 0.0;
|
||
if (!filtered && reader.bucket_range(kb, ke)) {
|
||
```
|
||
|
||
Thread the filters through the three calls:
|
||
|
||
```cpp
|
||
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
|
||
```
|
||
|
||
```cpp
|
||
const auto pin = [&](std::size_t& index, std::size_t& search, bool want_hi) {
|
||
if (search == 0) return;
|
||
// Only the bucket path leaves `search` non-zero, and a filtered window has no buckets,
|
||
// so this is reached pass-through only: a fresh filter here is the *same* filter.
|
||
MeasureFilter unfiltered;
|
||
MeasureStats one;
|
||
measure_scan(MeasureChunk{reader.values(index, search), search, index}, one, unfiltered);
|
||
```
|
||
|
||
```cpp
|
||
for (std::size_t k = 0; k < w.pct.size(); ++k) {
|
||
measure_set_level(w.level[k], w.stats, w.pct[k], 0.01 * w.noise[k]);
|
||
}
|
||
const auto accumulate = [&](const MeasureChunk& c) {
|
||
measure_accumulate(c, time_of, w.level.data(), w.level.size(), w.acc_filter);
|
||
};
|
||
```
|
||
|
||
and in the straddling-bucket branch, which a filtered window never reaches:
|
||
|
||
```cpp
|
||
measure_accumulate(MeasureChunk{values, b.span, b.offset}, time_of, &level, 1,
|
||
w.acc_filter);
|
||
```
|
||
|
||
- [ ] **Step 5: Look the window up by index in the readout loop**
|
||
|
||
Replace the `find` at `src/ui_measure.cpp:405-414`:
|
||
|
||
```cpp
|
||
const App::MeasureWindow* wp =
|
||
want_window[i] >= 0 && static_cast<std::size_t>(want_window[i]) < app.measure_samples.size()
|
||
? &app.measure_samples[static_cast<std::size_t>(want_window[i])]
|
||
: nullptr;
|
||
if (wp == nullptr || !wp->ok) {
|
||
// Its signal is momentarily unresolvable, or the window holds nothing -- so there is nothing
|
||
// to measure *this* pass. The marks still fall back (below); `continue`ing out of the marks
|
||
// was the other half of the rulers blinking off.
|
||
app.measure_text[i] = "--";
|
||
if (label != nullptr) gtk_label_set_text(GTK_LABEL(label), "--");
|
||
continue;
|
||
}
|
||
const App::MeasureWindow& w = *wp;
|
||
```
|
||
|
||
`chan[i]` is still used elsewhere in this loop — leave it in place.
|
||
|
||
- [ ] **Step 6: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: links cleanly, two warnings. An error about `entry` means a site in Step 4 was missed; an error about `find` means Step 5 was.
|
||
|
||
- [ ] **Step 7: Confirm the baseline has not moved**
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then the documented measurement probe, unchanged, on the plain mock:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8106 --rate 1300000 --batch 1000 --duration 12 > /tmp/t6-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_MEASURE_MS=5000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8106 2>&1 | grep -E 'measure-probe|measure-trace|measure cost|CRITICAL'
|
||
```
|
||
|
||
Expected, all unchanged from `docs/claude/verification.md`: `[measure-trace]` reads `peak to peak=2`, `amplitude=1`, `average=0`, **`top=0.636620`** (2/pi — the one exact reading that a band leaking into the sums would move), and `measure cost:` still shows `raw` at a small fraction of `covered`. A `top` that is no longer 2/pi means the band reached the sums; `raw` near `covered` means `filtered` is reading true on an unfiltered window.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ui_app.hpp src/ui_measure.cpp
|
||
git commit -m "perf: the measurements pane keys its shared walks by channel and HF corner"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: The measurement dialog and the session file
|
||
|
||
Two spins in the dialog, shown only where they do something, and two named TOML keys.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_app.hpp:885-907` (`MeasureDialog`)
|
||
- Modify: `src/ui.cpp:1609-1723` (`open_measure_dialog`)
|
||
- Modify: `src/ui_measure.cpp:90-176` (`measure_dialog_sync`, `on_measure_dialog_apply`)
|
||
- Modify: `src/layout_store.cpp:544-563`, `:659-672`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MeasureOptions::noise_pct`, `MeasureOptions::hf_fc`, `measure_uses_noise()` from Task 5.
|
||
- Produces: `MeasureDialog::noise`, `MeasureDialog::noise_row`, `MeasureDialog::hf`, `MeasureDialog::hf_row`; the TOML keys `noise` and `hf`.
|
||
|
||
- [ ] **Step 1: Add the four dialog fields**
|
||
|
||
`MeasureDialog` is brace-initialised **positionally** in `src/ui.cpp`, so where you add the fields decides where the arguments go. Add them after `side_label` and before `signals`:
|
||
|
||
```cpp
|
||
GtkWidget* noise = nullptr; ///< MeasureOptions::noise_pct
|
||
GtkWidget* noise_row = nullptr;
|
||
GtkWidget* hf = nullptr; ///< MeasureOptions::hf_fc
|
||
GtkWidget* hf_row = nullptr;
|
||
```
|
||
|
||
- [ ] **Step 2: Build the two rows**
|
||
|
||
In `src/ui.cpp`'s `open_measure_dialog`, beside where `threshold` and `side_row` are built, using the same `spin()` and `labelled_row()` helpers:
|
||
|
||
```cpp
|
||
// A percentage of the window's own peak-to-peak, which is the unit the engine takes.
|
||
GtkWidget* noise = spin(0.0, 50.0, 1.0, 1, 10.0);
|
||
GtkWidget* noise_label = nullptr;
|
||
GtkWidget* noise_row = labelled_row("noise band %", noise, &noise_label);
|
||
```
|
||
|
||
```cpp
|
||
// 0 is off. The engine clamps to just under the stream's Nyquist, which the dialog
|
||
// cannot know.
|
||
GtkWidget* hf = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||
GtkWidget* hf_label = nullptr;
|
||
GtkWidget* hf_row = labelled_row("HF reject Hz", hf, &hf_label);
|
||
```
|
||
|
||
Append both rows to the dialog's content box where `side_row` is appended. `labelled_row` wants a label out-parameter; these two labels never retitle, so the locals are unused — pass `nullptr` if `labelled_row` accepts it, and if it does not, keep the locals and mark them `(void)noise_label; (void)hf_label;`. Read `src/ui.cpp:1589` before choosing.
|
||
|
||
Then add the four arguments to the brace-initialiser in the position Step 1 put them:
|
||
|
||
```cpp
|
||
auto* d = new MeasureDialog{&app, edit_index, signal_drop, type_drop,
|
||
threshold, above, show, window,
|
||
threshold_row, threshold_label, side_row, side_label,
|
||
noise, noise_row, hf, hf_row,
|
||
sig_names};
|
||
```
|
||
|
||
- [ ] **Step 3: Seed them when editing**
|
||
|
||
In `open_measure_dialog`'s editing branch (`src/ui.cpp:1711-1713`), beside the existing seeds:
|
||
|
||
```cpp
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->noise), m.options.noise_pct);
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->hf), m.options.hf_fc);
|
||
```
|
||
|
||
- [ ] **Step 4: Show them per type**
|
||
|
||
In `src/ui_measure.cpp`'s `measure_dialog_sync`, beside the two existing `gtk_widget_set_visible` calls:
|
||
|
||
```cpp
|
||
// 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));
|
||
gtk_widget_set_visible(d->hf_row, TRUE);
|
||
```
|
||
|
||
`type` is the local `measure_dialog_sync` already derives from the type dropdown — reuse it.
|
||
|
||
- [ ] **Step 5: Read them on apply**
|
||
|
||
In `src/ui_measure.cpp`'s `on_measure_dialog_apply`, beside the existing reads:
|
||
|
||
```cpp
|
||
options.noise_pct = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->noise));
|
||
options.hf_fc = gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->hf));
|
||
```
|
||
|
||
- [ ] **Step 6: Persist them**
|
||
|
||
In `src/layout_store.cpp`'s measurement write (`:659-672`), append two keys to the `[[measure]]` table:
|
||
|
||
```cpp
|
||
o << "\nnoise = " << m.options.noise_pct << "\nhf = " << m.options.hf_fc;
|
||
```
|
||
|
||
Match the surrounding statement's exact style — read it first; if each key is its own `o <<` line, write two of those instead.
|
||
|
||
In the keyed read (`:544-563`), add the two keys beside `threshold`, following whatever helper the existing keys use. The keys are **named**, so a session file written before this change loads with `noise_pct = 10.0` and `hf_fc = 0.0` — the defaults, which reproduce today's behaviour exactly. Do not add a version check.
|
||
|
||
- [ ] **Step 7: Build and round-trip a session**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors. An error naming a `GtkWidget*` conversion in the brace-initialiser means Step 1 and Step 2 disagree about the field order.
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then the existing session probe, which saves and reloads:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8107 --rate 1300000 --batch 1000 --duration 12 > /tmp/t7-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_CONFIGS_MS=5000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8107 2>&1 | grep -E 'configs|CRITICAL'
|
||
```
|
||
|
||
Expected: the `[configs]` readings unchanged from `docs/claude/probe-readings.md`, and no `Gtk-CRITICAL`.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ui_app.hpp src/ui.cpp src/ui_measure.cpp src/layout_store.cpp
|
||
git commit -m "ui: a measurement's noise band and HF reject are settable and persisted"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: The noise probe's measurement legs
|
||
|
||
Five measurements in one pane on the spiked `sine`, chosen so that three of them share a channel at **three different corners** — which is what exercises the `(channel, fc)` keying — and so that each control has one legible reading.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_probe.cpp` (extend `noise_probe`)
|
||
- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: everything from Tasks 5–7, and `noise_probe`/`noise_channel` from Task 4.
|
||
- Produces: the `[noise-probe] measure ...` dump lines.
|
||
|
||
- [ ] **Step 1: Add the five measurements and read them back**
|
||
|
||
In `src/ui_probe.cpp`, in `noise_probe`, after the trigger legs and before `a->search.clear()`:
|
||
|
||
```cpp
|
||
// Five measurements on the spiked sine, as *two* windows: three legs at `hf=0` and two at
|
||
// `hf=50`. The pane keys its walks by (channel, corner), so this is also what checks that a
|
||
// second corner does not reuse the first one's walk.
|
||
std::string name;
|
||
{
|
||
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||
if (a->model->channels().empty()) return G_SOURCE_REMOVE;
|
||
name = a->model->channels().front().name; // `sine`; signal 0 is the timestamp
|
||
}
|
||
struct Leg {
|
||
const char* label;
|
||
MeasureType type;
|
||
double noise;
|
||
double hf;
|
||
};
|
||
// The impulses dominate the window's extremes, so `peak to peak` reads the *stimulus*
|
||
// (about 3) unfiltered and the *waveform* (about 2) filtered -- the one reading that says
|
||
// the filter reached pass one and not only pass two.
|
||
const Leg legs[] = {
|
||
{"pkpk bare", MeasureType::PeakToPeak, 10.0, 0.0},
|
||
{"pkpk hf", MeasureType::PeakToPeak, 10.0, 50.0},
|
||
{"period noband", MeasureType::Period, 0.0, 0.0},
|
||
{"period band", MeasureType::Period, 10.0, 0.0},
|
||
{"period hf", MeasureType::Period, 0.0, 50.0},
|
||
};
|
||
const std::size_t base = a->measures.size();
|
||
for (const Leg& leg : legs) {
|
||
MeasureOptions options;
|
||
options.noise_pct = leg.noise;
|
||
options.hf_fc = leg.hf;
|
||
a->measures.push_back({name, leg.type, options});
|
||
}
|
||
rebuild_measures_pane(*a);
|
||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||
for (std::size_t i = 0; i < std::size(legs); ++i) {
|
||
const std::size_t at = base + i;
|
||
// A *value*, not a reference: the conditional's other arm is a temporary.
|
||
const MeasureResult r =
|
||
at < a->measure_results.size() ? a->measure_results[at] : MeasureResult{};
|
||
std::fprintf(stderr, "[noise-probe] measure %-14s noise=%.1f hf=%.1f valid=%d value=%.6f\n",
|
||
legs[i].label, legs[i].noise, legs[i].hf, int(r.valid), r.primary);
|
||
}
|
||
std::fprintf(stderr, "[noise-probe] measure windows=%zu\n", a->measure_samples.size());
|
||
std::fflush(stderr);
|
||
```
|
||
|
||
`<iterator>` provides `std::size`; it is already pulled in by this file's includes, but check and add it if not.
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors. A third `-Wmissing-field-initializers` means a brace-initialiser here left a field out — name every field of `Leg`.
|
||
|
||
- [ ] **Step 3: Run the probe**
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 16 python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 \
|
||
--duration 14 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t8-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=12000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_NOISE_MS=8000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||
```
|
||
|
||
Expected:
|
||
|
||
- `pkpk bare` — about **3.0** (the sine's 2 plus an impulse either way). `valid=1`.
|
||
- `pkpk hf` — about **1.99** (`2 / sqrt(1 + (5/50)^2)`). `valid=1`. A reading near 3 means the filter never reached pass one — check `measure_scan` is being given `w.scan_filter` and that `filtered` disabled the buckets, since a bucket's aggregate is unfiltered.
|
||
- `period noband` — far **below** 0.2, around 0.014: a 5 ms impulse train recrossing the mid level is counted as the waveform. This is the defect the band exists to fix, and it must read wrong here.
|
||
- `period band` — about **0.2**. A reading near `period noband` means `noise_pct` is not reaching `measure_set_level`.
|
||
- `period hf` — about **0.2**, with the band off. A reading near `period noband` means the pole coefficient is 1; check `measure_filter_set` got a non-zero `dt`.
|
||
- `measure windows=2`. The five legs ask for corners `0, 50, 0, 0, 50` on one channel, which is two distinct windows. **`windows=1` means the corner is not part of the key** — the five measurements collapsed onto one walk and two of them are reading a signal they did not ask for.
|
||
|
||
No `Gtk-CRITICAL`.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 4: Document the readings**
|
||
|
||
Add the six lines above to the `[noise-probe]` section of `docs/claude/probe-readings.md`, each with its value and the defect a wrong reading names. State plainly that `period noband` is **supposed** to read wrong — it is the control reading the other two are compared against, and a future reader who "fixes" it has removed the check.
|
||
|
||
Note in `docs/claude/verification.md` that `UDPSCOPE_PROBE_NOISE_MS` and `UDPSCOPE_PROBE_MEASURE_MS` must not be scheduled in the same run: both push onto `app.measures`, and the second would read the first's rows.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/ui_probe.cpp docs/claude/verification.md docs/claude/probe-readings.md
|
||
git commit -m "test: the noise probe reads a measurement's band and HF reject"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: The de-noise math node
|
||
|
||
A running median followed by a one-pole, as one op. The median rejects an impulse outright — a peak shorter than half the window cannot move it at all — and the pole then takes the broadband hash the median leaves behind.
|
||
|
||
`MathOp` is **append-only**: a session file stores an op as its `math_op_at()` index, so `DeNoise` goes at index 12 and `Div` gets an explicit `case 11`.
|
||
|
||
**Files:**
|
||
- Modify: `src/math_channel.hpp`, `src/math_channel.cpp`
|
||
|
||
**Interfaces:**
|
||
- Consumes: nothing from earlier tasks.
|
||
- Produces:
|
||
- `MathOp::DeNoise` at `math_op_at(12)`, `math_op_count() == 13`, `math_op_name() == "de-noise"`.
|
||
- `int MathNode::med_n = 5;` in `operator==`.
|
||
- `bool math_op_uses_fc(MathOp);` and `bool math_op_uses_median(MathOp);`
|
||
- `MathNodeRt::med_at`, `::med_n`, `::med_pos`, `::med_have`; `MathRuntime::median`.
|
||
|
||
- [ ] **Step 1: Append the op**
|
||
|
||
In `src/math_channel.hpp`, after `Div`:
|
||
|
||
```cpp
|
||
/// A running median of `med_n` samples, then a one-pole at `fc` Hz. For impulsive
|
||
/// interference -- an EM peak a few samples wide -- which a linear filter can only smear
|
||
/// over its own settling time. `fc = 0` is the median alone.
|
||
DeNoise,
|
||
```
|
||
|
||
Also widen the enum's own leading comment: it currently says "the rest take one", which stays true.
|
||
|
||
- [ ] **Step 2: Add the window length to the node**
|
||
|
||
In `src/math_channel.hpp`, in `MathNode` after `hi`:
|
||
|
||
```cpp
|
||
/// `DeNoise`: the median window, in samples. Odd and between 3 and 9 -- `median_n()` in the
|
||
/// implementation is what enforces that, so a session file holding anything else still loads.
|
||
/// An impulse up to `med_n / 2` samples wide is rejected outright.
|
||
int med_n = 5;
|
||
```
|
||
|
||
and add it to `operator==`:
|
||
|
||
```cpp
|
||
lo == other.lo && hi == other.hi && med_n == other.med_n;
|
||
```
|
||
|
||
- [ ] **Step 3: Declare the two new predicates**
|
||
|
||
In `src/math_channel.hpp`, after `math_op_uses_clamp`:
|
||
|
||
```cpp
|
||
/// Whether an op reads `fc`. Wider than `math_op_uses_filter()`, which keeps its present meaning
|
||
/// of *a biquad*: `DeNoise` reads `fc` for a one-pole and has no `q`.
|
||
bool math_op_uses_fc(MathOp op);
|
||
/// Whether an op reads `med_n`.
|
||
bool math_op_uses_median(MathOp op);
|
||
```
|
||
|
||
- [ ] **Step 4: Add the runtime state**
|
||
|
||
In `src/math_channel.hpp`, in `MathNodeRt` after `src`:
|
||
|
||
```cpp
|
||
/// `DeNoise`: where this node's median window starts in `MathRuntime::median`, how long it is,
|
||
/// the ring's write position, and how many samples it holds. The window is *pooled* rather than
|
||
/// held here because a node's state belongs on one cache line, and nine doubles would push this
|
||
/// struct across two for every node in the graph, filter or not.
|
||
///
|
||
/// The one-pole reuses `b0` for its coefficient and `z1` for its value, which is not a
|
||
/// shortcut: `math_prepare()` deliberately carries `z1` across a re-prepare, so the pole keeps
|
||
/// its history through a decimation change exactly as the biquads keep theirs.
|
||
std::size_t med_at = 0;
|
||
int med_n = 0;
|
||
std::size_t med_pos = 0;
|
||
std::size_t med_have = 0;
|
||
```
|
||
|
||
In `MathRuntime`, after `node`:
|
||
|
||
```cpp
|
||
/// Every `DeNoise` node's median window, end to end: one allocation for the channel, sized by
|
||
/// `math_prepare()`, a node's slice starting at its own `med_at`.
|
||
std::vector<double> median;
|
||
```
|
||
|
||
- [ ] **Step 5: Register the op in the four tables**
|
||
|
||
In `src/math_channel.cpp`:
|
||
|
||
```cpp
|
||
case MathOp::Div: return "ratio";
|
||
case MathOp::DeNoise: return "de-noise";
|
||
```
|
||
|
||
```cpp
|
||
int math_op_count() { return 13; }
|
||
```
|
||
|
||
```cpp
|
||
case 10: return MathOp::Mul;
|
||
case 11: return MathOp::Div;
|
||
default: return MathOp::DeNoise;
|
||
```
|
||
|
||
`math_op_arity` needs no change: `DeNoise` is unary and falls into its `default: return 1`.
|
||
|
||
```cpp
|
||
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
|
||
bool math_op_uses_fc(MathOp op) { return math_op_uses_filter(op) || op == MathOp::DeNoise; }
|
||
bool math_op_uses_median(MathOp op) { return op == MathOp::DeNoise; }
|
||
```
|
||
|
||
- [ ] **Step 6: Add the two helpers**
|
||
|
||
In `src/math_channel.cpp`'s anonymous namespace, beside `biquad`:
|
||
|
||
```cpp
|
||
/// One pole's coefficient for a corner of `fc` Hz at `dt` seconds a sample:
|
||
/// `a = 1 - exp(-2*pi*fc*dt)`, clamped just under Nyquist so `a` cannot exceed 1. `fc == 0`
|
||
/// returns 1, which is a pass-through -- so "no pole" costs the step no branch.
|
||
double pole_a(double fc, double dt) {
|
||
if (!(fc > 0.0) || !(dt > 0.0)) return 1.0;
|
||
const double nyquist = 0.5 / dt;
|
||
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
|
||
return 1.0 - std::exp(-2.0 * kPi * use * dt);
|
||
}
|
||
|
||
/// The median window's length: odd, and between 3 and 9.
|
||
///
|
||
/// Odd because an even window's median is the average of two samples, which is a value the
|
||
/// signal never took -- and the whole point of a median here is that its output is a sample that
|
||
/// was really there. Rounded *up*, so a request for 4 gets 5 rather than 3. The ceiling of 9 is
|
||
/// what `math_step`'s fixed `double sorted[9]` is sized for.
|
||
int median_n(int want) {
|
||
int n = want < 3 ? 3 : (want > 9 ? 9 : want);
|
||
if ((n & 1) == 0) ++n;
|
||
return n;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Size the pool and the pole in `math_prepare`**
|
||
|
||
In `src/math_channel.cpp`, between `rt.ready = true;` and the per-node loop:
|
||
|
||
```cpp
|
||
// The median windows, pooled: one pass to size the pool, then the node loop hands out the
|
||
// slices. A window starts from rest after a re-prepare -- the same choice the biquads' `z2`
|
||
// makes -- while the pole's `z1` is carried, so a decimation change does not make the trace
|
||
// jump.
|
||
std::size_t med_total = 0;
|
||
for (std::size_t i = 0; i < n; ++i) {
|
||
if (channel.nodes[i].op == MathOp::DeNoise) {
|
||
med_total += static_cast<std::size_t>(median_n(channel.nodes[i].med_n));
|
||
}
|
||
}
|
||
rt.median.assign(med_total, 0.0);
|
||
std::size_t med_next = 0;
|
||
```
|
||
|
||
and in the per-node loop, between the `Source` block and the `math_op_uses_filter` guard:
|
||
|
||
```cpp
|
||
if (node.op == MathOp::DeNoise) {
|
||
out.med_n = median_n(node.med_n);
|
||
out.med_at = med_next;
|
||
out.med_pos = 0;
|
||
out.med_have = 0;
|
||
med_next += static_cast<std::size_t>(out.med_n);
|
||
out.b0 = pole_a(node.fc, dt);
|
||
continue;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Add the step**
|
||
|
||
In `src/math_channel.cpp`, in `math_step`'s switch, after the `Div` case:
|
||
|
||
```cpp
|
||
case MathOp::DeNoise: {
|
||
const double in = at(node.in_a);
|
||
// A hole stays a hole, and the window is left alone: folding a NaN into the median
|
||
// would smear one gap across `med_n` samples, and the whole trace after it in the
|
||
// pole. `med_n <= 0` is a node the runtime has not prepared -- the window pointer
|
||
// below would be a read past the pool, so it is checked rather than assumed.
|
||
if (!std::isfinite(in) || self.med_n <= 0) {
|
||
x = kNaN;
|
||
break;
|
||
}
|
||
const std::size_t len = static_cast<std::size_t>(self.med_n);
|
||
double* win = rt.median.data() + self.med_at;
|
||
win[self.med_pos] = in;
|
||
self.med_pos = self.med_pos + 1 == len ? 0 : self.med_pos + 1;
|
||
if (self.med_have < len) ++self.med_have;
|
||
// An insertion sort of at most nine doubles into a local. The window is tiny and
|
||
// fixed, so this beats anything that allocates, and it leaves the ring itself in
|
||
// arrival order -- which is what lets the oldest sample be overwritten in place.
|
||
// Before the window has filled, the median is taken over what there is: a shorter
|
||
// window, deliberately, rather than NaN. A derived trace that reads "no data" for
|
||
// its first few samples after every rebuild looks like a broken graph.
|
||
double sorted[9];
|
||
const std::size_t m = self.med_have;
|
||
for (std::size_t j = 0; j < m; ++j) {
|
||
const double v = win[j];
|
||
std::size_t p = j;
|
||
while (p > 0 && sorted[p - 1] > v) {
|
||
sorted[p] = sorted[p - 1];
|
||
--p;
|
||
}
|
||
sorted[p] = v;
|
||
}
|
||
const double med = sorted[m / 2];
|
||
// Median *then* pole, and the order is the design. The median kills an impulse
|
||
// outright; the pole then takes the broadband hash it leaves behind. The other way
|
||
// round feeds the pole the impulse, which smears it over the pole's whole settling
|
||
// time and leaves the median a wide bump it cannot reject.
|
||
self.z1 += self.b0 * (med - self.z1);
|
||
x = self.z1;
|
||
break;
|
||
}
|
||
```
|
||
|
||
The pole starts from rest (`z1 == 0`), exactly as the biquads do, so the first few samples of a new node ramp up to the signal. That is the same honest-starting-point choice `math_prepare`'s comment already records; do not prime it.
|
||
|
||
- [ ] **Step 9: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -25
|
||
```
|
||
|
||
Expected: links cleanly, two warnings. A `-Wswitch` error names a `switch (op)` that has not been given a `DeNoise` case — `math_op_name` is the one with no default. Any *other* switch over `MathOp` the compiler names must also be handled; add the case rather than a default, which is what makes the next appended op fail loudly here too.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/math_channel.hpp src/math_channel.cpp
|
||
git commit -m "feat: a de-noise math node rejects impulses with a median and a one-pole"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: The de-noise node in the editor and the session file
|
||
|
||
The node editor's form gains a median-length row and starts showing the corner row for `DeNoise` as well as for the biquads. The node's pipe string grows a thirteenth field and must still read a twelve-field one.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_app.hpp:914-986` (`MathDialog`)
|
||
- Modify: `src/ui.cpp:2042-2166` (the math form), `src/ui.cpp:2143` (the comment)
|
||
- Modify: `src/ui_math.cpp:823-847`, `:888-981`
|
||
- Modify: `src/layout_store.cpp:357-371`, `:373-407`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `MathOp::DeNoise`, `MathNode::med_n`, `math_op_uses_fc()`, `math_op_uses_median()` from Task 9.
|
||
- Produces: `MathDialog::med` and `MathDialog::rows[6]`; a 13-field math node string.
|
||
|
||
- [ ] **Step 1: Widen the dialog's rows**
|
||
|
||
In `src/ui_app.hpp`, in `MathDialog`, change `GtkWidget* rows[6]` to `GtkWidget* rows[7]` and add the spin beside `d->q`:
|
||
|
||
```cpp
|
||
GtkWidget* med = nullptr; ///< MathNode::med_n
|
||
```
|
||
|
||
Read how `rows[6]` is initialised in the existing `= {...}` and extend it with one more `nullptr`.
|
||
|
||
- [ ] **Step 2: Build the median row**
|
||
|
||
In `src/ui.cpp`'s math form, after the clamp row (`d->rows[4]`) and before the wiring row, build `rows[6]` the same way its neighbours are built. The spin is an integer count:
|
||
|
||
```cpp
|
||
// Odd, 3..9. The engine rounds up to odd anyway, but a step of 2 means the widget never
|
||
// offers an even one.
|
||
d->med = gtk_spin_button_new_with_range(3.0, 9.0, 2.0);
|
||
gtk_spin_button_set_digits(GTK_SPIN_BUTTON(d->med), 0);
|
||
```
|
||
|
||
Wrap it in a row with the label `median samples` exactly as the `corner Hz` row is wrapped, assign the row to `d->rows[6]`, and append it to the form's box in the same place the other rows are appended. Read the clamp row's construction and copy its shape; do not invent a different one.
|
||
|
||
Keep `rows[5]` as the wiring row — it is the last row the form shows for every op, and moving it would change the form's order for every existing op.
|
||
|
||
- [ ] **Step 3: Fix the op-menu comment**
|
||
|
||
`src/ui.cpp:2143` says "A menu of the twelve ops". It is thirteen. The loop itself runs to `math_op_count()`, so only the comment changes.
|
||
|
||
- [ ] **Step 4: Show the two rows per op**
|
||
|
||
In `src/ui_math.cpp`'s `math_dialog_sync_form`, replace the visibility block's use of `filter` for the corner row:
|
||
|
||
```cpp
|
||
const bool gain = math_op_uses_gain(op);
|
||
const bool filter = math_op_uses_filter(op);
|
||
const bool clamp = math_op_uses_clamp(op);
|
||
const bool source = op == MathOp::Source;
|
||
gtk_widget_set_visible(d->rows[0], source);
|
||
gtk_widget_set_visible(d->rows[1], gain);
|
||
// The corner row is shown for anything that reads `fc`, which is now the three biquads *and*
|
||
// the de-noise node; `q` stays a band-pass-only row.
|
||
gtk_widget_set_visible(d->rows[2], math_op_uses_fc(op));
|
||
gtk_widget_set_visible(d->rows[3], filter && op == MathOp::BandPass);
|
||
gtk_widget_set_visible(d->rows[4], clamp);
|
||
gtk_widget_set_visible(d->rows[5], true);
|
||
gtk_widget_set_visible(d->rows[6], math_op_uses_median(op));
|
||
```
|
||
|
||
and seed the new spin with the others:
|
||
|
||
```cpp
|
||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(d->med), static_cast<double>(n.med_n));
|
||
```
|
||
|
||
- [ ] **Step 5: Read it back**
|
||
|
||
In `src/ui_math.cpp`'s `on_math_param_changed`, beside the other reads:
|
||
|
||
```cpp
|
||
n.med_n = static_cast<int>(gtk_spin_button_get_value(GTK_SPIN_BUTTON(d->med)));
|
||
```
|
||
|
||
- [ ] **Step 6: Persist it**
|
||
|
||
In `src/layout_store.cpp`'s `math_node_text`, append `med_n` as the **thirteenth** field, after `y`. The op is written as its `math_op_at()` index, which is already how field 0 works and needs no change.
|
||
|
||
In `math_node_from_text`, accept either width and read the new field only when it is there:
|
||
|
||
```cpp
|
||
// Twelve fields is a session file written before the de-noise node existed; its `med_n`
|
||
// stays at the struct's default, which is what that file meant.
|
||
if (f.size() != 12u && f.size() != 13u) return false;
|
||
```
|
||
|
||
and after the existing `number(...)` chain:
|
||
|
||
```cpp
|
||
if (f.size() == 13u) {
|
||
double med = 0.0;
|
||
if (!number(12, med)) return false;
|
||
out.med_n = static_cast<int>(med);
|
||
}
|
||
return true;
|
||
```
|
||
|
||
The existing chain `return number(4, out.gain) && ... && number(11, out.y);` must therefore become a guarded sequence rather than one `return`. Rewrite it as:
|
||
|
||
```cpp
|
||
if (!(number(4, out.gain) && number(5, out.offset) && number(6, out.fc) &&
|
||
number(7, out.q) && number(8, out.lo) && number(9, out.hi) && number(10, out.x) &&
|
||
number(11, out.y))) {
|
||
return false;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Build and round-trip the editor**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors. An out-of-bounds warning on `rows` means Step 1 was skipped.
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then the existing math probe, which opens the editor and clicks through it:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8110 --rate 1300000 --batch 1000 --duration 12 > /tmp/t10-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_MATH_MS=4000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8110 2>&1 | grep -E 'math-probe|CRITICAL'
|
||
```
|
||
|
||
Expected: every `[math-probe]` line as `docs/claude/probe-readings.md` records it, including `graph edit:`, and no `Gtk-CRITICAL`. A `Gtk-CRITICAL` naming a NULL widget means `d->med` is read before it is built — `math_dialog_sync_form` runs during construction.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/ui_app.hpp src/ui.cpp src/ui_math.cpp src/layout_store.cpp
|
||
git commit -m "ui: the node editor offers the de-noise node and persists its window"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: The noise probe's de-noise leg
|
||
|
||
One reading that says the node rejects what it is for: the peak-to-peak of a de-noised `sine` against the peak-to-peak of the raw one. The impulses are 2 samples wide and the median is 5, so the median rejects them outright — about **2** against about **3**.
|
||
|
||
The node must have been producing for a while before it can be measured: a math channel is a channel, and it starts at the moment it is applied.
|
||
|
||
**The graph therefore cannot be applied inside `noise_probe` itself.** `apply_math_channels()` calls `set_math_channels()`, whose comment records that it is a *structural rebuild* that **replaces the buffer and drops its history** — and every reading in Tasks 4 and 8 is taken over retained history. Applying the graph at the top of `noise_probe` would leave the six search legs scanning a buffer a few milliseconds old (`bare=0`, `band=0`, reading exactly like a broken comparator) and the five measurement legs reading a window that is mostly unfilled. So the probe gains a **setup stage** that runs five seconds earlier, does nothing but apply the graph, and then schedules `noise_probe` — which lands at the same 8000 ms it does today, over a buffer that has refilled since the rebuild, with five seconds of de-noised history behind it.
|
||
|
||
**Files:**
|
||
- Modify: `src/ui_probe.cpp` (a new `noise_probe_setup`, a new `noise_probe_math`, and a de-noise node in `math_probe`)
|
||
- Modify: `src/ui_app.hpp` (declare both new functions)
|
||
- Modify: `src/ui.cpp` (register `noise_probe_setup` instead of `noise_probe`)
|
||
- Modify: `docs/claude/verification.md`, `docs/claude/probe-readings.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 4, 8 and 9.
|
||
- Produces: `gboolean noise_probe_setup(gpointer user);`, `gboolean noise_probe_math(gpointer user);` and the `[noise-probe] denoise ...` line.
|
||
|
||
- [ ] **Step 1: Apply the graph in a setup stage**
|
||
|
||
In `src/ui_probe.cpp`, immediately **above** `noise_probe`:
|
||
|
||
```cpp
|
||
// Stage one of two. `apply_math_channels()` is a structural rebuild that drops the buffer's
|
||
// history, and every reading in `noise_probe` is taken over retained history -- so the rebuild
|
||
// happens here, five seconds before the first leg runs, and the buffer refills in between. The
|
||
// de-noise channel also needs that time: a math channel starts producing when it is applied.
|
||
//
|
||
// Fields assigned rather than brace-initialised: a partial brace-initialiser on `MathNode`
|
||
// is what the two `-Wmissing-field-initializers` in this file already are, and the build
|
||
// baseline is exactly four warnings.
|
||
gboolean noise_probe_setup(gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||
MathChannel dn;
|
||
dn.name = "math denoise";
|
||
dn.enabled = true;
|
||
MathNode src_node;
|
||
src_node.op = MathOp::Source;
|
||
src_node.source = "sine";
|
||
MathNode dn_node;
|
||
dn_node.op = MathOp::DeNoise;
|
||
dn_node.in_a = 0;
|
||
dn_node.med_n = 5;
|
||
// The median alone: a 2-sample impulse is shorter than half a 5-sample window, so it is
|
||
// rejected outright and the pole has nothing left to do. A corner here would attenuate the
|
||
// 5 Hz sine as well and make the peak-to-peak reading a function of two things.
|
||
dn_node.fc = 0.0;
|
||
dn.nodes = {src_node, dn_node};
|
||
dn.output = 1;
|
||
a->math.push_back(dn);
|
||
apply_math_channels(*a);
|
||
std::fprintf(stderr, "[noise-probe] setup channels=%zu\n", a->math.size());
|
||
std::fflush(stderr);
|
||
g_timeout_add(5000, noise_probe, a);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
```
|
||
|
||
`noise_probe` itself is unchanged by this step — it keeps the `sine < 0 || square < 0` guard at its top, and the channel lookup there now also has to resolve over a channel list one entry longer, which it does by name and so does not care.
|
||
|
||
- [ ] **Step 2: Register the setup stage instead, and schedule the follow-up**
|
||
|
||
In `src/ui.cpp`, change the registration added in Task 4 to call the setup stage. `UDPSCOPE_PROBE_NOISE_MS` keeps its name and now times the *setup*, so every run command drops from 8000 to **3000** and the legs still land at 8000 ms:
|
||
|
||
```cpp
|
||
if (const char* env = g_getenv("UDPSCOPE_PROBE_NOISE_MS")) {
|
||
const int ms = std::atoi(env);
|
||
// Times the *setup* stage; `noise_probe` runs 5000 ms after it, so 3000 puts the legs at
|
||
// the same 8000 ms they were measured at.
|
||
if (ms > 0) g_timeout_add(static_cast<guint>(ms), noise_probe_setup, &app);
|
||
}
|
||
```
|
||
|
||
At the end of `noise_probe`, replace `return G_SOURCE_REMOVE;` with:
|
||
|
||
```cpp
|
||
g_timeout_add(2500, noise_probe_math, a);
|
||
return G_SOURCE_REMOVE;
|
||
```
|
||
|
||
Keep the `a->search.clear(); update_search_panel(*a);` lines before it.
|
||
|
||
- [ ] **Step 3: Write the follow-up**
|
||
|
||
In `src/ui_probe.cpp`, after `noise_probe`:
|
||
|
||
```cpp
|
||
gboolean noise_probe_math(gpointer user) {
|
||
App* a = static_cast<App*>(user);
|
||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||
const std::size_t base = a->measures.size();
|
||
// Both unfiltered and at no band: the only difference between the two readings is the node.
|
||
MeasureOptions options;
|
||
a->measures.push_back({std::string("sine"), MeasureType::PeakToPeak, options});
|
||
a->measures.push_back({std::string("math denoise"), MeasureType::PeakToPeak, options});
|
||
rebuild_measures_pane(*a);
|
||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||
const MeasureResult raw =
|
||
base < a->measure_results.size() ? a->measure_results[base] : MeasureResult{};
|
||
const MeasureResult den =
|
||
base + 1 < a->measure_results.size() ? a->measure_results[base + 1] : MeasureResult{};
|
||
std::fprintf(stderr,
|
||
"[noise-probe] denoise raw=%.6f/%d denoised=%.6f/%d ratio=%.3f channels=%zu\n",
|
||
raw.primary, int(raw.valid), den.primary, int(den.valid),
|
||
raw.primary > 0.0 ? den.primary / raw.primary : 0.0, a->math.size());
|
||
std::fflush(stderr);
|
||
return G_SOURCE_REMOVE;
|
||
}
|
||
```
|
||
|
||
Declare both in `src/ui_app.hpp` beside `noise_probe`:
|
||
|
||
```cpp
|
||
gboolean noise_probe_setup(gpointer user);
|
||
gboolean noise_probe_math(gpointer user);
|
||
```
|
||
|
||
- [ ] **Step 4: Give `math_probe` a de-noise node too**
|
||
|
||
`math_probe` is the construction crash test for the editor: it opens the dialog on a graph and clicks the nodes. A de-noise node there is what exercises the new form row on every baseline run. In `src/ui_probe.cpp`'s `math_probe`, after the `lp` channel is pushed:
|
||
|
||
```cpp
|
||
// A third channel so the editor's new form row is built and synced on a baseline run.
|
||
// Assigned field by field: a partial brace-initialiser would add a fifth build warning.
|
||
MathChannel dn;
|
||
dn.name = "math denoise";
|
||
dn.enabled = true;
|
||
MathNode dn_src;
|
||
dn_src.op = MathOp::Source;
|
||
dn_src.source = name;
|
||
MathNode dn_op;
|
||
dn_op.op = MathOp::DeNoise;
|
||
dn_op.in_a = 0;
|
||
dn_op.med_n = 5;
|
||
dn_op.fc = 500.0;
|
||
dn.nodes = {dn_src, dn_op};
|
||
dn.output = 1;
|
||
channels.push_back(dn);
|
||
```
|
||
|
||
and change its own report line from `2 math channels` to `3 math channels`, keeping the two names it prints.
|
||
|
||
- [ ] **Step 5: Build**
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope && cmake --build build 2>&1 | tail -20
|
||
```
|
||
|
||
Expected: two warnings, no errors. **A clean build must still be four** — check that with `rm -rf build` only if a warning count looks wrong, since a clean build is slow.
|
||
|
||
- [ ] **Step 6: Run the noise probe**
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 18 python3 tools/mock_udpstreamer.py --port 8111 --rate 1300000 --batch 1000 \
|
||
--duration 16 --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/t11-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_NOISE_MS=3000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8111 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||
```
|
||
|
||
`3000` and not `8000`: the variable now times the setup stage, and `noise_probe` runs 5000 ms after it, so the legs land at the same 8000 ms Tasks 4 and 8 measured them at. `noise_probe_math` then reports at 10500 ms, inside the 14000 ms exit.
|
||
|
||
Expected, in addition to Tasks 4 and 8's lines:
|
||
|
||
- `setup channels=1`, printed first.
|
||
- `denoise raw=` about **3.0**, `denoised=` about **2.0**, **`ratio` between 0.6 and 0.72**, both `valid=1`, `channels=1`.
|
||
- **Tasks 4 and 8's own readings must be unchanged from their runs.** If `bare` and `band` have collapsed to 0, or the measurement legs read `valid=0`, the setup stage is not actually running five seconds ahead of the legs — the buffer was rebuilt under them. Check `UDPSCOPE_PROBE_NOISE_MS` is `3000` and that the registration calls `noise_probe_setup`.
|
||
- `denoised` near 3 means the median is not rejecting: check `med_n` arrived as 5 and not 0, since a `med_n <= 0` node returns NaN and `peak to peak` would then read `--` rather than 3.
|
||
- `denoised=0` with `valid=0` means the channel never produced — check the source name `sine` resolved, and that `apply_math_channels` was called.
|
||
|
||
No `Gtk-CRITICAL`.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 7: Run the math probe, whose baseline moved**
|
||
|
||
Clean up, in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
Then:
|
||
|
||
```bash
|
||
cd /home/martino/Projects/tools/udpscope
|
||
(timeout 14 python3 tools/mock_udpstreamer.py --port 8112 --rate 1300000 --batch 1000 --duration 12 > /tmp/t11b-mock.log 2>&1 &) ; sleep 1 ; \
|
||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=10000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||
UDPSCOPE_PROBE_MATH_MS=4000 \
|
||
./build/udpscope --server 127.0.0.1 --port 8112 2>&1 | grep -E 'math-probe|math cost|CRITICAL'
|
||
```
|
||
|
||
Expected: `[math-probe] 3 math channels`, every other `[math-probe]` line as before, and no `Gtk-CRITICAL`. `math cost:` will be higher than the recorded figure — a third channel with a median per sample — so note the new figure.
|
||
|
||
Clean up, each in its own tool call:
|
||
|
||
```bash
|
||
pkill -x udpscope; pkill -x Xvfb
|
||
```
|
||
|
||
```bash
|
||
pkill -f 'mock_udpstream[e]r'
|
||
```
|
||
|
||
- [ ] **Step 8: Document the readings**
|
||
|
||
In `docs/claude/probe-readings.md`: the `setup` and `denoise` lines in the `[noise-probe]` section, with the figures and the defects above; and update the `[math-probe]` entry from two math channels to three, including the new `math cost:` figure measured in Step 7.
|
||
|
||
In `docs/claude/verification.md`: correct the noise probe's schedule written in Task 4 from `UDPSCOPE_PROBE_NOISE_MS=8000` to **`=3000`**, and state why it is not simply the time the readings are taken — the variable times a setup stage that applies a math graph, `apply_math_channels()` drops the buffer's history, and the five seconds between the two stages are what let it refill before any leg scans it. A reader who "simplifies" the two stages into one gets `bare=0` on every search leg.
|
||
|
||
Also note that `UDPSCOPE_PROBE_NOISE_MS` and `UDPSCOPE_PROBE_MATH_MS` must not be scheduled together either, since both write `a->math`.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp docs/claude/verification.md docs/claude/probe-readings.md
|
||
git commit -m "test: the noise probe reads the de-noise node, and the math probe builds one"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Documentation
|
||
|
||
`README.md` documents what was *built*; the invariant docs carry the rules a future change must not break. Both are part of the feature, not an afterthought.
|
||
|
||
**Files:**
|
||
- Modify: `README.md`
|
||
- Modify: `docs/claude/invariants-trigger-and-pinning.md`, `invariants-readouts-and-cursors.md`, `invariants-measurements.md`, `invariants-math-channels.md`, `invariants-session-and-export.md`, `invariants-gtk-and-chrome.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: every earlier task.
|
||
- Produces: nothing code depends on.
|
||
|
||
- [ ] **Step 1: README**
|
||
|
||
Add the three controls where the README describes the trigger, the measurements and the math channels. For each: what it does, its unit, what "off" is, and the cost. Name the two deliberate deviations from `SPECS.md`/the design spec in whatever section the README already keeps deviations in:
|
||
|
||
- The trigger's band applies to `Edge` and `Window` and **not** to `PulseWidth` or `Dropout`. A pulse's width is measured between a crossing and its return, so widening it with a band would change the measured width rather than reject noise; a dropout is an absence, which a band cannot describe.
|
||
- The noise probe verifies the trigger through `BufferSearch` hit counts rather than through `[condition-probe]` latch times, because a latch time is a stream stamp and is explicitly not an assertion.
|
||
|
||
- [ ] **Step 2: `invariants-trigger-and-pinning.md`**
|
||
|
||
Add: `EventCondition` now carries `noise_band` and `hf_fc`, both in `operator==`, and `EventDetector::configure()` takes `dt` and early-returns only when **both** the condition and the interval are unchanged — it runs on every packet, so a configure that resets would restart the machine before any pulse could close. `dt` is `Partition::slot_dt()`, the *stored* interval, and comes from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`). A crossing is a change between two *decided* sides, never a change from the undecided one: seeding from `side_ == 0` fires on a signal that starts inside the band and leaves it without ever reaching the level.
|
||
|
||
- [ ] **Step 3: `invariants-readouts-and-cursors.md`**
|
||
|
||
That file's first rule is that **a readout quotes a stored sample and never a value between two**. A measurement with `hf_fc > 0` breaks it, deliberately and inherently. Add the exception next to the rule rather than leaving the rule reading as though it were still unconditional:
|
||
|
||
> A measurement with a non-zero `hf_fc` is the one exception. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered copy of the window, so the quoted value need not equal any stored sample — and with `show_on_plot` its ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it is not a rounding error and must not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is the default, still quotes a stored sample exactly as before.
|
||
|
||
Say in the same place that the filter is seeded with the window's **first sample** and never from history before the window, so a measurement never depends on data outside the window it reports on — the left-edge step transient is removed by the seeding, not by priming from earlier samples.
|
||
|
||
- [ ] **Step 4: `invariants-measurements.md`**
|
||
|
||
Add: the hysteresis band is `MeasureOptions::noise_pct`, a percentage, clamped in `measure_set_level()` to half the room to the nearer extreme. The band moves the **edge decision only** — the sums still split at the bare level, because `Top` on a unit sine must read 2/pi. `measure_accumulate` loops **samples outer, levels inner**, which looks backwards and is not: the filter holds one state for the whole walk, so levels-outer would step it once per level per sample. A non-zero `hf_fc` disables **both** bucket fast paths with the one `if (!filtered && reader.bucket_range(...))` — an empty `buckets` makes `bulk_from == bulk_to == n` *and* leaves `lo_search`/`hi_search` at 0, so `pin()` becomes a no-op; a bucket aggregates unfiltered samples and cannot be mixed with a filtered walk. The pane's shared windows are keyed by `(channel, fc)`, not by channel.
|
||
|
||
- [ ] **Step 5: `invariants-math-channels.md`**
|
||
|
||
Add: `MathOp` is append-only because `math_op_at()` is the serialisation; `DeNoise` is index 12. `math_op_uses_filter()` keeps its meaning of *a biquad* — `math_op_uses_fc()` is the wider question and is what the dialog asks. The median window is **pooled in `MathRuntime`**, not held in `MathNodeRt`, so a node's state stays on one cache line. The de-noise pole reuses `b0` and `z1`, which is deliberate: `math_prepare()` carries `z1` across a re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do, while the median window restarts from rest. `median_n()` rounds up to odd and caps at 9, which is what `double sorted[9]` is sized for. Median **then** pole, and the reason. A node runs on the producer thread, so a regression here costs packets and not frames.
|
||
|
||
- [ ] **Step 6: `invariants-session-and-export.md`**
|
||
|
||
Add: measurement options gain the **named** keys `noise` and `hf`, so a file written before them loads at the defaults — which are today's behaviour exactly. The math node string grows from twelve pipe fields to thirteen and `math_node_from_text` accepts **either** width; the op stays field 0 as a `math_op_at()` index, which is why `MathOp` may only be appended to.
|
||
|
||
- [ ] **Step 7: `invariants-gtk-and-chrome.md`**
|
||
|
||
Add: `MeasureDialog` and `MathDialog` are brace-initialised **positionally** in `src/ui.cpp`, so a field added in the middle of either silently shifts every argument after it — add at the end of the widget group, and check the initialiser in the same commit. A control that cannot affect the current type is **hidden**, not disabled: the trigger's band row is hidden for `Dropout`, the measurement's for every non-crossing type, and the node editor's median row for every op but `DeNoise`.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add README.md docs/claude/invariants-trigger-and-pinning.md \
|
||
docs/claude/invariants-readouts-and-cursors.md \
|
||
docs/claude/invariants-measurements.md docs/claude/invariants-math-channels.md \
|
||
docs/claude/invariants-session-and-export.md docs/claude/invariants-gtk-and-chrome.md
|
||
git commit -m "docs: record the three noise-rejection controls and their invariants"
|
||
```
|
||
|
||
---
|
||
|
||
## Deviations from the spec, collected
|
||
|
||
A reviewer comparing this plan with `docs/superpowers/specs/2026-10-13-noise-rejection-design.md` will find four differences. All four are deliberate and are recorded in the tasks that make them.
|
||
|
||
| Spec says | Plan does | Why |
|
||
| --- | --- | --- |
|
||
| Verify the trigger through four exact `[condition-probe]` `t0` values. | Six `BufferSearch` legs, asserting hit counts and a group delay. | `probe-readings.md` states `t0` is **not** an assertion: it is a stream stamp rebased by whichever rebuild last dropped history, and read `1.7103`, `1.6975` and `1.8719` on three consecutive runs of the same leg. |
|
||
| Measure the group delay between a banded leg and a banded-plus-filtered leg on `sine`. | Measure it between two unbanded legs on `square`. | A `+0.5` impulse arriving while the sine sits at `0.3` already reaches a `0.8` threshold, so the banded leg fires up to 20 ms early, quantised to the 5 ms spike grid. A band rejects spurious crossings; it does not stop a real one from happening early. `square` carries no impulses, and a one-pole crosses a step's midpoint at exactly `tau*ln2`. |
|
||
| `feed_window` takes the band on each edge of its own band. | The same, **latched**: the band is applied to whichever edge the signal must *leave*. | An unlatched band on both edges has a dead zone the signal can sit in, so a slow traversal of the window reports no change of side at all. |
|
||
| The band applies to every condition type. | `Edge` and `Window` only. | A pulse's width is measured from a crossing to its return, so a band there changes the measured width rather than rejecting noise — the pulse-width legs' documented readings would move. A dropout is a measured absence, which a band cannot describe. |
|