From afd2a6cdc1c0979461b87afa09a76f32ac177135 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 15 Oct 2026 00:44:52 +0200 Subject: [PATCH] mock: the square's edges ring, and the one probe that wants a step asks for it The square was the mock's only ideal step, which is the one shape a real producer does not put on the wire. It is now the truncated Fourier series of the step -- `--square-harmonics N` odd harmonics, default 30 -- so each edge overshoots by the Gibbs constant (about 9% of the 2 V swing, y = +/-1.179) and then rings, and `--square-harmonics 0` is the ideal +/-1 step as before. Odd harmonics only, so the series keeps the ideal wave's edge instants and its polarity: the level-0 trigger, the glitch's inverted pulse and the buffer search land where they did. Re-run in pairs (`--square-harmonics 0` against the default), search, condition legs 0/1, trigrenum, disarm, trig trace, measures and events all read exactly what they read before, with only `t0` moving. The reading that moved is the noise probe's group-delay leg, whose `want` is the analytic `tau*ln2` of a step: a band-limited edge reads `delay=0.002770 ratio=1.255`, the same figure on every run. That recipe now asks for the ideal step, so its documented number stays exact; probe-readings says why. The series is vectorised: as a per-sample Python sum it cost ~15 sin() calls a sample, which at the fast mock's 1.3 MS/s made the producer the bottleneck and halved the rate the app saw (the noise probe's span-dependent counts read 22 against 48). Vectorised it adds ~0.5 ms to a 2000-sample packet, so the fast mock runs at about nine tenths of its declared pace and those counts read within a few percent. --- docs/claude/build-and-run.md | 13 +++++++++ docs/claude/probe-readings.md | 15 ++++++++++- docs/claude/verification.md | 13 ++++++++- tools/mock_udpstreamer.py | 51 +++++++++++++++++++++++++++++++---- 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 5c871c1..81fd456 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -106,6 +106,19 @@ belongs with `todo.md` item 3, alongside the relative-time toggle. `tools/mock_udpstreamer.py` has waveform-injection options used by the probes: +- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier + series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the + default 30, i.e. `y = ±1.179`) and then rings — a band-limited edge, which is what a + crossing count or a pulse-width test has to survive. The series is deterministic and the + odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger, + the search and the glitch's inverted pulse still read where they did on the step; the + *extremes* are what move. `0` (or negative) is the ideal ±1 step, which is what a probe + asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is + the one that does, because its `want` is the analytic `tau*ln2` of a step + (see [verification.md](verification.md)). It costs the producer about 0.5 ms per + 2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock + delivers at roughly nine tenths of its declared pace — a reading that scales with the + retained span reads that much lower, and nothing else does. - `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the start of every square period from stream second `S` onwards — a runt pulse of a known width, which is what a `<` pulse-width trigger and a glitch search are checked against. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 8d2a8ef..a4f4bca 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -1027,6 +1027,12 @@ must **not** also be set (two arms on the same port race). Run with `--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and `UDPSCOPE_EXIT_AFTER_MS=12000`. +**That recipe needs no `--square-harmonics`**: the mock's `square` rings by default (see +[build-and-run.md](build-and-run.md)), and the odd-harmonic series passes through zero at the ideal edge's +own crossings, so the glitch's inverted 0.004 s pulse, the hit count, `agree` and the pulse-width leg's +`measured=0.200000` all read what they read on the step. Only a *level* the ripple reaches and the step does +not — the extremes, `y = ±1.179` — would need `--square-harmonics 0`, and no probe here asserts one. + Measured output (port 8419): ``` @@ -1854,7 +1860,14 @@ nothing itself but *is* rebuilt under when a graph is installed at its instant - - **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read `want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one - means `a` is far too small, i.e. `dt` never arrived at the detector. + means `a` is far too small, i.e. `dt` never arrived at the detector. **This is the leg the mock's + `--square-harmonics 0` is for**: `want` is a *step* response, and the square rings by default, which moves + the crossing to `delay=0.002770 ratio=1.255` — inside the band, but no longer the figure beside it, and the + same on every run, so it is the shape and not the run. See the recipe in + [verification.md](verification.md). The band/hf legs need no such flag: they count the crossings of `sine`, + which the ripple does not touch at all — their counts read a few percent lower at this recipe's 1.3 MS/s + only because the extra harmonics cost the *producer*, which + [verification.md](verification.md) notes. - `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. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index 8666b8f..ff029c6 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -631,13 +631,24 @@ same run). ```bash cd /home/martino/Projects/tools/udpscope python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \ - --spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 & + --spike-every 0.005 --spike-amp 0.5 --spike-width 2 --square-harmonics 0 \ + > /tmp/noise-mock.log 2>&1 & sleep 1.5 xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \ UDPSCOPE_PROBE_NOISE_MS=4000 \ ./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL' ``` +`--square-harmonics 0` is the one flag in that command that is not about the sine, and it is what keeps the +probe's last reading exact: the group-delay leg measures a one-pole's *step* response on `square`, and the +mock's square rings by default (see [build-and-run.md](build-and-run.md)). A band-limited edge moves that +crossing off `tau*ln2` — measured `delay=0.002770 ratio=1.255` against the ideal step's `0.002206 / 1.000`, +and the same figure on every run, so it is a property of the shape and not of the run. The leg's assertion is +the `0.7..1.4` band, which the ripple keeps, but the ideal step is what keeps the *number* the line prints +beside `want` as well. The other legs measure event counts on the sine, which the ripple does not touch; the +counts do read a few percent lower under the ripple at this rate, for the producer-cost reason +[build-and-run.md](build-and-run.md) gives. + `UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py index f007fc2..2628130 100644 --- a/tools/mock_udpstreamer.py +++ b/tools/mock_udpstreamer.py @@ -8,8 +8,10 @@ once and DATA packets to whoever connected. Publish mode is Accumulate, so each packet carries `--batch` consecutive samples per (scalar) signal -- the shape the viewer's per-sample path expects. -Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`. -Everything is float64 and unquantised. +Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`, +`noise`. Everything is float64 and unquantised. `square` is band-limited by +default: its edges are the truncated Fourier series of a step, so each one +overshoots and rings (`--square-harmonics 0` gives the ideal +/-1 step). ./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100 """ @@ -85,6 +87,7 @@ def build_data( spike_every=0.0, spike_amp=0.0, spike_width=0, + square_harmonics=30, ): stamps = [t0 + i * dt for i in range(batch)] # `time_spike` models a device whose clock glitches for one packet: the stamps jump @@ -116,9 +119,33 @@ def build_data( hit = into < (spike_width * dt) sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0) sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0) - square = [ - 1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps - ] + # A real square is band-limited: an edge is not a step but an overshoot followed by a + # decaying ring. The truncated Fourier series is exactly that shape, and it is + # deterministic -- unlike the interferer -- so a probe can still assert the spacing and the + # count of the events it finds. Only odd harmonics, so the sum keeps the ideal wave's edge + # instants (it passes through zero at the same crossing) and its polarity: the trigger's + # level-0 edges, the glitch's inverted pulse widths and the buffer search's hits all land + # where they did on the step. What moves is the extremes: 30 harmonics overshoot by the + # Gibbs constant, about 9% of the 2 V swing, so y = +/-1.179 rather than +/-1 -- which is + # why a probe asserting an exact extreme or an exact step width asks for + # `--square-harmonics 0`, the ideal +/-1 step that was the only shape here before. + if square_harmonics > 0: + # The same series as a per-sample Python sum costs ~15 sin() calls a sample, and at the + # fast mock's rates that makes the *producer* the bottleneck: it falls behind its own + # declared pace, and a reading that scales with the retained span (the noise probe's + # crossing counts) then moves for a reason that has nothing to do with the signal. + t_arr = np.asarray(stamps) + harmonics = np.arange(1.0, float(square_harmonics) + 1.0, 2.0) + square = ( + (4.0 / math.pi) + * np.sin(2.0 * math.pi * harmonics[:, None] * (freq * 0.5) * t_arr[None, :]) + / harmonics[:, None] + ).sum(axis=0) + else: + square = [ + 1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 + for t in stamps + ] ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps] noisy = np.sin(100 * math.pi * np.array(stamps) + 0.5) + np.random.normal( 0, 0.5, batch @@ -207,6 +234,19 @@ def main(): "what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; " "0.1 is the figure the measurement hysteresis is sized against", ) + ap.add_argument( + "--square-harmonics", + type=int, + default=30, + help="odd harmonics of `square`'s Fourier series: a real square is band-limited, so " + "each edge overshoots by the Gibbs constant (about 9%% of the 2 V swing at 30 " + "harmonics) and then rings -- the shape a crossing count, a pulse-width test or a " + "search has to survive. The odd harmonics keep the ideal wave's edge instants and " + "its polarity, so the level-0 trigger, the search and the glitch's inverted pulse " + "still read where they did on the step; the extremes move to +/-1.179. 0 (or " + "negative) = the ideal +/-1 step, which is what an assertion about an exact extreme " + "or an exact width asks for", + ) ap.add_argument( "--clock-jitter", type=float, @@ -457,6 +497,7 @@ def main(): args.spike_every, args.spike_amp, args.spike_width, + args.square_harmonics, ) burst = max(1, args.drop_burst) if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0: