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, 0, false, 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) } } }