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

+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
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: