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.
This commit is contained in:
Martino Ferrari committed 2026-10-15 00:45:05 +02:00
1 parent 41cd8e5f38
commit afd2a6cdc1
4 files changed
+85 -7

No files matched your search

+13
View File
@@ -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: `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 - `--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 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. width, which is what a `<` pulse-width trigger and a glitch search are checked against.
+14 -1
View File
@@ -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 `--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
`UDPSCOPE_EXIT_AFTER_MS=12000`. `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): 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 - **`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 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 `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 - `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. is being overtaken -- nothing to fix in the feature, but say so.
+12 -1
View File
@@ -631,13 +631,24 @@ same run).
```bash ```bash
cd /home/martino/Projects/tools/udpscope cd /home/martino/Projects/tools/udpscope
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \ 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 sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
UDPSCOPE_PROBE_NOISE_MS=4000 \ UDPSCOPE_PROBE_NOISE_MS=4000 \
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL' ./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 `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 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 buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
+46 -5
View File
@@ -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 packet carries `--batch` consecutive samples per (scalar) signal -- the shape the
viewer's per-sample path expects. viewer's per-sample path expects.
Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`. Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`,
Everything is float64 and unquantised. `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 ./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100
""" """
@@ -85,6 +87,7 @@ def build_data(
spike_every=0.0, spike_every=0.0,
spike_amp=0.0, spike_amp=0.0,
spike_width=0, spike_width=0,
square_harmonics=30,
): ):
stamps = [t0 + i * dt for i in range(batch)] stamps = [t0 + i * dt for i in range(batch)]
# `time_spike` models a device whose clock glitches for one packet: the stamps jump # `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) hit = into < (spike_width * dt)
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0) 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) sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0)
square = [ # A real square is band-limited: an edge is not a step but an overshoot followed by a
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps # 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] 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( noisy = np.sin(100 * math.pi * np.array(stamps) + 0.5) + np.random.normal(
0, 0.5, batch 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; " "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", "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( ap.add_argument(
"--clock-jitter", "--clock-jitter",
type=float, type=float,
@@ -457,6 +497,7 @@ def main():
args.spike_every, args.spike_every,
args.spike_amp, args.spike_amp,
args.spike_width, args.spike_width,
args.square_harmonics,
) )
burst = max(1, args.drop_burst) burst = max(1, args.drop_burst)
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0: if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0: