fixed issue on udpstreamer trigger logic

This commit is contained in:
Martino Ferrari
2026-08-28 16:53:17 +02:00
parent 1c61e814c0
commit 044ce57ba3
170 changed files with 16958 additions and 24036 deletions
@@ -0,0 +1,367 @@
package wshub
import (
"encoding/binary"
"math"
"testing"
)
/*
Sporadic-signal trigger coverage.
Every other trigger test in this package feeds a periodic waveform, or a
hand-built two-sample batch. Neither can show a trigger that is blind most of
the time: a sine crosses the threshold again a few milliseconds after every
missed crossing, so a trigger losing 80 % of its edges still fires steadily and
looks healthy. A sparse train — 0000000111000000000000, one short burst in a
long flat run — has nothing to fall back on, so every missed edge is a missed
capture and the yield is a direct measure of how long the FSM was deaf.
That deafness is what these tests pin down. It is not a bug in itself: a capture
cannot be harvested before the samples after its trigger point exist, so the
trigger is necessarily blind for its own post-trigger window. What must NOT
happen is for edges arriving after that window to be thrown away as well.
*/
// pulseTrainSim drives a Hub the way Run() does — ingest on one side, the
// trigger tick on the other — on a simulated clock.
type pulseTrainSim struct {
rateHz float64
batchSec float64
pulsePeriod float64
pulseSamples int
simSec float64
windowSec float64
prePercent float64
holdoffSec float64
armAt float64
// When windowChangeAt > 0 the window is switched to windowChangeTo at that
// time and the trigger re-armed, as a user editing the trigger bar would.
windowChangeAt float64
windowChangeTo float64
}
type pulseTrainResult struct {
pulses int // pulse starts presented after the trigger was armed
shots int // captures actually delivered
trigTimes []float64 // the sample time each capture triggered on
worstCov float64 // smallest fraction of its window a capture spanned
holdDeclined int // captures the zoom hold would not answer for
drawnPulses int // pulses visible in the delivered frames
wantPulses int // pulses those frames' windows really contained
gatedPulses int // pulses that arrived armed but with the fill gate shut
}
// yield is the fraction of presented pulses that produced a capture.
func (r pulseTrainResult) yield() float64 {
if r.pulses == 0 {
return 0
}
return float64(r.shots) / float64(r.pulses)
}
// run executes the simulation and returns what the trigger caught.
func (s pulseTrainSim) run(t *testing.T, key string) pulseTrainResult {
t.Helper()
h := NewHub()
h.rings[key] = newSigRing(ringCapInitial)
h.trigger.SetConfig(trigConfig{
signalKey: key, edge: "rising", threshold: 0.5,
windowSec: s.windowSec, prePercent: s.prePercent,
mode: "normal", holdoffSec: s.holdoffSec,
})
res := pulseTrainResult{worstCov: 1}
nBatch := int(s.rateHz * s.batchSec)
ts := make([]float64, nBatch)
vs := make([]float64, nBatch)
armed, changed := false, false
for now := 0.0; now < s.simSec; now += s.batchSec {
nPulseStarts := 0
for i := range ts {
ts[i] = now + float64(i)/s.rateHz
// Position within the current pulse period, in samples.
k := int((ts[i] - math.Floor(ts[i]/s.pulsePeriod)*s.pulsePeriod) * s.rateHz)
if k < s.pulseSamples {
vs[i] = 1
if k == 0 {
nPulseStarts++
}
} else {
vs[i] = 0
}
}
if !armed && now >= s.armAt {
h.trigger.Arm()
armed = true
}
if s.windowChangeAt > 0 && !changed && now >= s.windowChangeAt {
cfg := h.trigger.Config()
cfg.windowSec = s.windowChangeTo
h.trigger.SetConfig(cfg)
h.trigger.Arm()
changed = true
}
if armed {
res.pulses += nPulseStarts
if nPulseStarts > 0 && h.trigger.State() == trigArmed {
h.trigger.mu.Lock()
f := h.trigger.fillLocked()
h.trigger.mu.Unlock()
if f < 1 {
res.gatedPulses += nPulseStarts
}
}
}
h.ingest(key, 1, ts, vs)
// Mirror triggerTick, on the simulated clock.
tick := now + s.batchSec
h.retuneRings(tick)
_, span := h.rings[key].stats()
h.trigger.setBuffered(span, true, tick)
if trigTime, pre, post, ok := h.trigger.dueCapture(tick); ok {
if buf := h.buildTriggerCapture(trigTime, pre, post); buf != nil {
res.shots++
res.trigTimes = append(res.trigTimes, trigTime)
first, last, _ := decodeCaptureSpan(t, buf, key)
if cov := (last - first) / (pre + post); cov < res.worstCov {
res.worstCov = cov
}
if _, _, ok := h.capture.slice(key, trigTime-pre, trigTime+post); !ok {
res.holdDeclined++
}
// What the client would actually draw, against what the window
// really contained. A wide window holds several pulses, and a
// frame showing only the one it triggered on has lost the rest
// between the ring, the bucketing and the decimation.
_, fv := decodeCaptureSig(t, buf, key)
res.drawnPulses += countPulses(fv, 0.5)
res.wantPulses += countPulseStarts(trigTime-pre, trigTime+post,
s.pulsePeriod, 1/s.rateHz)
}
h.trigger.markTriggered(tick)
} else if h.trigger.dueRearm(tick) {
h.trigger.rearm()
}
}
return res
}
// decodeCaptureSig pulls one signal's samples out of a v2 capture frame.
func decodeCaptureSig(t *testing.T, buf []byte, key string) (ts, vs []float64) {
t.Helper()
off := 1 + 8 + 8 + 8
nSig := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
for i := 0; i < nSig; i++ {
kl := int(binary.LittleEndian.Uint16(buf[off:]))
off += 2
k := string(buf[off : off+kl])
off += kl
cnt := int(binary.LittleEndian.Uint32(buf[off:]))
off += 4
if k == key {
ts = make([]float64, cnt)
vs = make([]float64, cnt)
for j := 0; j < cnt; j++ {
ts[j] = math.Float64frombits(binary.LittleEndian.Uint64(buf[off+j*8:]))
vs[j] = math.Float64frombits(binary.LittleEndian.Uint64(buf[off+cnt*8+j*8:]))
}
}
off += cnt * 16
}
return
}
// countPulses counts runs of samples at or above thr.
func countPulses(v []float64, thr float64) int {
n, in := 0, false
for _, x := range v {
if x >= thr {
if !in {
n, in = n+1, true
}
} else {
in = false
}
}
return n
}
// countPulseStarts is how many pulse starts fall inside [t0, t1]. A pulse
// starting within one sample of t1 is not counted: only its first sample is
// inside the window, and the ring's min/max bucket for it may put that sample's
// extremum just past the edge, which is a boundary artefact rather than a loss.
func countPulseStarts(t0, t1, period, dt float64) int {
n := 0
for k := math.Floor(t0 / period); k*period <= t1; k++ {
if p := k * period; p >= t0 && p < t1-2*dt {
n++
}
}
return n
}
// deadTimeSec is how long the FSM is blind after firing at t: it must acquire
// the post-trigger window before the capture can be harvested, and the holdoff
// guards against re-triggering on the same event. Both are measured from the
// trigger point, so they overlap rather than add.
func deadTimeSec(windowSec, prePercent, holdoffSec float64) float64 {
return math.Max(windowSec*(1-prePercent/100), holdoffSec)
}
// A trigger cannot show two windows at once, so pulses closer together than its
// post-trigger window are necessarily lost. Pulses spaced FURTHER apart than
// that are not: nothing about the acquisition prevents catching every one.
//
// This is the reported failure. The FSM used to go deaf from the trigger point
// until the capture had been harvested (a post-window plus captureMarginSec)
// and the holdoff had then elapsed on top of that, then wait for a fresh edge —
// so the effective spacing was rounded UP to a whole pulse period. At the
// default 1 s window and 0.2 s holdoff the blind stretch came to 1.15 s, which
// is longer than a 1 s pulse period by a hair, and a pulse train at 1 Hz was
// caught at 0.5 Hz. Widening the window made it worse in whole multiples.
func TestSporadicPulsesWiderThanThePostWindowAreAllCaught(t *testing.T) {
const key = "s1:Ch1"
cases := []struct{ window, period float64 }{
{0.5, 0.5}, // post 0.4 s
{1.0, 1.0}, // post 0.8 s — the case the report was made against
{2.0, 2.0}, // post 1.6 s
{5.0, 5.0}, // post 4.0 s
}
for _, c := range cases {
sim := pulseTrainSim{
rateHz: 1000, batchSec: 1.0 / 30.0,
pulsePeriod: c.period, pulseSamples: 3,
simSec: 41 * c.period, windowSec: c.window, prePercent: 20,
holdoffSec: autoRearmDelaySec, armAt: c.period,
}
res := sim.run(t, key)
// Two pulses are always in flight rather than caught: the one that lands
// as the trigger arms, and the one still being collected when the run
// ends.
if got := res.yield(); got < 0.94 {
t.Errorf("window %.1f s, pulse every %.1f s: caught %d of %d (%.0f%%); "+
"the post-trigger window is only %.2f s, so every pulse fits",
c.window, c.period, res.shots, res.pulses, 100*got,
c.window*0.8)
}
}
}
// The loss that remains must be the loss that has to remain. A capture cannot
// start before the previous one's post-window is acquired, and it can only start
// on a pulse, so consecutive captures are a dead time apart rounded UP to the
// next pulse — never further. Any longer gap means an edge that the acquisition
// no longer needed was thrown away anyway.
//
// The bound is stated as dead + period rather than ceil(dead/period)*period
// because when the two divide exactly, whether the pulse at the boundary counts
// comes down to the last bit of the sample timestamp. Both answers are correct;
// a gap beyond either is not.
func TestSporadicCaptureGapsStayWithinTheDeadTime(t *testing.T) {
const key = "s1:Ch1"
for _, window := range []float64{0.5, 1.0, 2.0, 5.0} {
for _, period := range []float64{0.25, 0.5, 1.0, 2.0} {
sim := pulseTrainSim{
rateHz: 1000, batchSec: 1.0 / 30.0,
pulsePeriod: period, pulseSamples: 3,
simSec: 60, windowSec: window, prePercent: 20,
holdoffSec: autoRearmDelaySec, armAt: 1.0,
}
res := sim.run(t, key)
dead := deadTimeSec(window, 20, autoRearmDelaySec)
limit := dead + period + 2*sim.batchSec
worst, worstAt := 0.0, 0.0
for i := 1; i < len(res.trigTimes); i++ {
if g := res.trigTimes[i] - res.trigTimes[i-1]; g > worst {
worst, worstAt = g, res.trigTimes[i-1]
}
}
if worst > limit {
t.Errorf("window %.1f s, pulse every %.2f s: %.2f s between the captures "+
"at %.2f s and %.2f s; the dead time is only %.2f s, so %.2f s is the most "+
"that can be missed",
window, period, worst, worstAt, worstAt+worst, dead, limit)
}
t.Logf("window %.1f s, pulse every %.2f s: %d/%d captures (%.0f%%), "+
"dead time %.2f s, worst gap %.2f s, gated %d",
window, period, res.shots, res.pulses, 100*res.yield(), dead,
worst, res.gatedPulses)
}
}
}
// Whatever the trigger does catch has to come back whole: a window wide enough
// to hold several pulses must show all of them, at every rate, including the
// rates that force the ring into min/max bucketing.
func TestSporadicCaptureShowsEveryPulseInItsWindow(t *testing.T) {
const key = "s1:Ch1"
for _, rate := range []float64{1000, 200e3} {
for _, window := range []float64{1.0, 2.0, 5.0} {
sim := pulseTrainSim{
rateHz: rate, batchSec: 1.0 / 30.0,
pulsePeriod: 0.5, pulseSamples: 3,
simSec: 40, windowSec: window, prePercent: 20,
holdoffSec: autoRearmDelaySec, armAt: 1.0,
}
res := sim.run(t, key)
if res.shots == 0 {
t.Fatalf("rate %.0f window %.1f s: no captures at all", rate, window)
}
// wantPulses excludes the pulse straddling each window's far edge,
// whose bucket may place its extremum just past it, so the frames
// may legitimately draw a few more than that — but never fewer.
if res.drawnPulses < res.wantPulses {
t.Errorf("rate %.0f window %.1f s: frames drew %d pulses, their windows held %d",
rate, window, res.drawnPulses, res.wantPulses)
}
if res.worstCov < 0.98 {
t.Errorf("rate %.0f window %.1f s: worst capture spanned %.0f%% of its window",
rate, window, 100*res.worstCov)
}
if res.holdDeclined > 0 {
t.Errorf("rate %.0f window %.1f s: the zoom hold declined %d of %d captures",
rate, window, res.holdDeclined, res.shots)
}
}
}
}
// Widening the window mid-run is the gesture the report came from. The fill gate
// holds the first shot off until the ring reaches back far enough, which is
// correct; what it must not do is stay shut, nor leave the trigger losing pulses
// once the ring has caught up.
func TestSporadicYieldRecoversAfterAWindowChange(t *testing.T) {
const key = "s1:Ch1"
for _, w := range []float64{1.0, 2.0, 5.0} {
sim := pulseTrainSim{
rateHz: 200e3, batchSec: 1.0 / 30.0,
pulsePeriod: w, pulseSamples: 3,
simSec: 30 * w, windowSec: 0.2, prePercent: 20,
holdoffSec: autoRearmDelaySec, armAt: 1.0,
windowChangeAt: 10 * w, windowChangeTo: w,
}
res := sim.run(t, key)
// Count only what happened after the change settled.
after, want := 0, 0
for _, tt := range res.trigTimes {
if tt > 11*w {
after++
}
}
for p := 11 * w; p < 30*w; p += w {
want++
}
if float64(after) < 0.9*float64(want) {
t.Errorf("window 0.2 -> %.1f s: %d captures in the %d pulses after the change",
w, after, want)
}
}
}