feat(webui): sporadic-trigger capture, CSV export and UI rework
Brings the Go hub and web SPA work developed on feature/udpscope onto main, without the udpscope client itself. The trigger engine could not capture a sporadic event: it armed on the live tail only, so a burst shorter than one push window was already past by the time the FSM looked for it. It now searches the ring history for the crossing, which also makes a capture reproducible from the same data rather than dependent on push timing (wshub/trigger.go, ringbuf.go, history.go). Adds CSV/JSON export of the visible window (wshub/export.go) and reworks the SPA: per-signal axis controls, a readable trigger panel, and a fix for the flicker caused by repainting on every push instead of on a frame tick (static/app.js, index.html, style.css). BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction, which is otherwise only inferable from the three files that implement it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
cf815e1d3f
commit
f334995865
@@ -0,0 +1,75 @@
|
||||
package wshub
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A short window at a high sample rate fits in a ring's initial capacity, so the
|
||||
// retune sweep used to leave it there — and a ring holding exactly the window has
|
||||
// already rolled past the front of a capture by the time that capture is read,
|
||||
// which happens a post-window plus captureMarginSec after the trigger fires.
|
||||
//
|
||||
// 1 MSps over a 200 ms window: 200 k points fit in the 250 k initial ring, and
|
||||
// every shot came back missing its first 123 ms.
|
||||
func TestCaptureWholeAtHighRateShortWindow(t *testing.T) {
|
||||
const (
|
||||
key = "s1:Ch1"
|
||||
rate = 1e6
|
||||
window = 0.2
|
||||
prePct = 20.0
|
||||
batchSec = 1.0 / 30.0
|
||||
simSec = 6.0
|
||||
)
|
||||
|
||||
h := NewHub()
|
||||
h.SetRingBudget(defaultRingPts)
|
||||
h.rings[key] = newSigRing(ringCapInitial)
|
||||
h.trigger.SetConfig(trigConfig{signalKey: key, edge: "rising", threshold: 0,
|
||||
windowSec: window, prePercent: prePct, mode: "normal", holdoffSec: 0.2})
|
||||
|
||||
rateHz := float64(rate)
|
||||
nBatch := int(rateHz * batchSec)
|
||||
ts := make([]float64, nBatch)
|
||||
vs := make([]float64, nBatch)
|
||||
|
||||
armed, shots := false, 0
|
||||
for now := 0.0; now < simSec; now += batchSec {
|
||||
for i := range ts {
|
||||
ts[i] = now + float64(i)/rateHz
|
||||
vs[i] = math.Sin(2 * math.Pi * 5 * ts[i]) // a rising crossing every 200 ms
|
||||
}
|
||||
h.ingest(key, 1, ts, vs)
|
||||
h.retuneRings(now)
|
||||
h.refreshTriggerFill()
|
||||
|
||||
if !armed && now > 2 {
|
||||
h.trigger.Arm()
|
||||
armed = true
|
||||
}
|
||||
trigTime, pre, post, ok := h.trigger.dueCapture(now + batchSec)
|
||||
if !ok {
|
||||
if h.trigger.dueRearm(now + batchSec) {
|
||||
h.trigger.Arm()
|
||||
}
|
||||
continue
|
||||
}
|
||||
t0 := trigTime - pre
|
||||
buf := h.buildTriggerCapture(trigTime, pre, post)
|
||||
if buf == nil {
|
||||
t.Fatalf("shot at t=%.4f produced no frame at all", trigTime)
|
||||
}
|
||||
first, last, n := decodeCaptureSpan(t, buf, key)
|
||||
shots++
|
||||
if lost := first - t0; lost > shortCaptureTol*window {
|
||||
_, span := h.rings[key].stats()
|
||||
t.Errorf("shot at t=%.4f is missing %.0f ms at the front of its %.0f ms window "+
|
||||
"(got [%.4f,%.4f], %d pts; ring holds %.4f s in %d points)",
|
||||
trigTime, 1e3*lost, 1e3*window, first, last, n, span, h.rings[key].capacity())
|
||||
}
|
||||
h.trigger.markTriggered(now + batchSec)
|
||||
}
|
||||
if shots < 3 {
|
||||
t.Fatalf("only %d shots in %.0f s", shots, simSec)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package wshub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/parquet-go/parquet-go"
|
||||
)
|
||||
|
||||
// ExportSample is one row of the binary export: a single stored sample, in
|
||||
// long ("tidy") form, keyed by source and signal with its own timestamp.
|
||||
//
|
||||
// Keeping each signal's samples as its own rows — rather than resampling onto a
|
||||
// shared time grid — is what makes the export hole-free: per-signal streams of
|
||||
// different lengths export exactly as stored, nothing is fabricated, and
|
||||
// nothing is dropped.
|
||||
type ExportSample struct {
|
||||
Source string `parquet:"source"`
|
||||
Signal string `parquet:"signal"`
|
||||
Time float64 `parquet:"time"`
|
||||
Value float64 `parquet:"value"`
|
||||
}
|
||||
|
||||
// exportChunkRows bounds each batched write and, via MaxRowsPerRowGroup, the
|
||||
// size of each parquet row group: memory stays bounded however large the
|
||||
// export is, because a finished row group is flushed to the HTTP stream.
|
||||
const exportChunkRows = 65536
|
||||
|
||||
// exportWriteBuffer is the parquet writer's output buffer: larger than the
|
||||
// 32KiB default means fewer writes on the HTTP stream for a multi-GB export.
|
||||
const exportWriteBuffer = 1 << 20
|
||||
|
||||
// HandleExport serves GET /api/export?t0=..&t1=..[&signals=a,b] as a Parquet
|
||||
// file containing every stored sample of the named signals in [t0, t1].
|
||||
//
|
||||
// Unlike /api/zoom there is no decimation: the file holds the full contents of
|
||||
// the rings. At rates above the ring budget those contents are min/max buckets
|
||||
// (the finest resolution the hub retains); at lower rates they are verbatim.
|
||||
func (h *Hub) HandleExport(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
t0, err0 := strconv.ParseFloat(q.Get("t0"), 64)
|
||||
t1, err1 := strconv.ParseFloat(q.Get("t1"), 64)
|
||||
if err0 != nil || err1 != nil || t1 <= t0 {
|
||||
http.Error(w, "invalid t0/t1", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var keys []string
|
||||
if s := strings.TrimSpace(q.Get("signals")); s != "" {
|
||||
keys = strings.Split(s, ",")
|
||||
for i := range keys {
|
||||
keys[i] = strings.TrimSpace(keys[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the rings we will read. A signal removed mid-export must not
|
||||
// silently drop rows from the file.
|
||||
h.ringsMu.RLock()
|
||||
refs := make(map[string]*sigRing)
|
||||
if keys == nil {
|
||||
for k, rb := range h.rings {
|
||||
refs[k] = rb
|
||||
}
|
||||
} else {
|
||||
for _, k := range keys {
|
||||
if rb, ok := h.rings[k]; ok {
|
||||
refs[k] = rb
|
||||
}
|
||||
}
|
||||
}
|
||||
h.ringsMu.RUnlock()
|
||||
if len(refs) == 0 {
|
||||
http.Error(w, "no signals", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Deterministic column order.
|
||||
names := make([]string, 0, len(refs))
|
||||
for k := range refs {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.apache.parquet")
|
||||
w.Header().Set("Content-Disposition",
|
||||
fmt.Sprintf("attachment; filename=\"signals_%d.parquet\"", time.Now().Unix()))
|
||||
|
||||
writer := parquet.NewGenericWriter[ExportSample](w,
|
||||
parquet.MaxRowsPerRowGroup(exportChunkRows),
|
||||
parquet.WriteBufferSize(exportWriteBuffer),
|
||||
)
|
||||
batch := make([]ExportSample, 0, exportChunkRows)
|
||||
for _, key := range names {
|
||||
st, sv := refs[key].slice(t0, t1)
|
||||
colon := strings.IndexByte(key, ':')
|
||||
source, signal := key, key
|
||||
if colon >= 0 {
|
||||
source = key[:colon]
|
||||
signal = key[colon+1:]
|
||||
}
|
||||
for i := range st {
|
||||
batch = append(batch, ExportSample{Source: source, Signal: signal, Time: st[i], Value: sv[i]})
|
||||
if len(batch) >= exportChunkRows {
|
||||
if _, err := writer.Write(batch); err != nil {
|
||||
// Client went away or the stream broke; stop writing.
|
||||
return
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(batch) > 0 {
|
||||
_, _ = writer.Write(batch)
|
||||
}
|
||||
_ = writer.Close()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package wshub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/parquet-go/parquet-go"
|
||||
)
|
||||
|
||||
func TestHandleExportParquetFullResolution(t *testing.T) {
|
||||
h := NewHub()
|
||||
// Two signals with different lengths and offset time bases: the export must
|
||||
// keep every sample of each, on its own timestamps (no holes, no
|
||||
// resampling, no decimation).
|
||||
sig1 := newSigRing(10000)
|
||||
sig2 := newSigRing(10000)
|
||||
t1, v1 := make([]float64, 1000), make([]float64, 1000)
|
||||
for i := range t1 {
|
||||
t1[i] = float64(i) * 0.001
|
||||
v1[i] = float64(i) * 2
|
||||
}
|
||||
sig1.write(t1, v1)
|
||||
t2, v2 := make([]float64, 500), make([]float64, 500)
|
||||
for i := range t2 {
|
||||
t2[i] = 0.1 + float64(i)*0.002
|
||||
v2[i] = -float64(i)
|
||||
}
|
||||
sig2.write(t2, v2)
|
||||
h.rings["s1:Ch1"] = sig1
|
||||
h.rings["s1:Ch2"] = sig2
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/export?t0=0&t1=2&signals=s1:Ch1,s1:Ch2", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleExport(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
reader := parquet.NewGenericReader[ExportSample](bytes.NewReader(rec.Body.Bytes()))
|
||||
defer reader.Close()
|
||||
|
||||
var got []ExportSample
|
||||
buf := make([]ExportSample, 1000)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
got = append(got, buf[:n]...)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(got) != 1500 {
|
||||
t.Fatalf("rows = %d, want 1500 (every sample of both signals)", len(got))
|
||||
}
|
||||
ch1 := filterExportSamples(got, "s1", "Ch1")
|
||||
ch2 := filterExportSamples(got, "s1", "Ch2")
|
||||
if len(ch1) != 1000 || len(ch2) != 500 {
|
||||
t.Fatalf("ch1=%d ch2=%d rows, want 1000/500 (no holes, no resampling)", len(ch1), len(ch2))
|
||||
}
|
||||
if ch1[0].Time != 0 || ch1[0].Value != 0 || ch1[999].Time != 0.999 || ch1[999].Value != 1998 {
|
||||
t.Fatalf("ch1 endpoints wrong: first=%+v last=%+v", ch1[0], ch1[999])
|
||||
}
|
||||
if ch2[0].Time != 0.1 || ch2[499].Time != 0.1+499*0.002 || ch2[499].Value != -499 {
|
||||
t.Fatalf("ch2 endpoints wrong: first=%+v last=%+v", ch2[0], ch2[499])
|
||||
}
|
||||
}
|
||||
|
||||
func filterExportSamples(rows []ExportSample, source, signal string) []ExportSample {
|
||||
out := make([]ExportSample, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Source == source && r.Signal == signal {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestHandleExportParquetBadRange(t *testing.T) {
|
||||
h := NewHub()
|
||||
h.rings["s1:Ch1"] = newSigRing(10)
|
||||
req := httptest.NewRequest("GET", "/api/export?t0=2&t1=1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleExport(rec, req)
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("status = %d, want 400 for inverted range", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,26 @@ func (hw *historyWriter) window() float64 {
|
||||
return hw.windowSec
|
||||
}
|
||||
|
||||
// coversWindow reports whether the archive file for key currently spans at
|
||||
// least sec seconds. When true, backfillCaptureHead can reconstruct a capture's
|
||||
// front out of the archive, so the trigger need not wait for the ring to cover
|
||||
// the whole window on its own.
|
||||
func (hw *historyWriter) coversWindow(key string, sec float64) bool {
|
||||
if !hw.enabled() || !(sec > 0) {
|
||||
return false
|
||||
}
|
||||
hw.mu.RLock()
|
||||
hf, ok := hw.files[key]
|
||||
hw.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
hf.mu.RLock()
|
||||
span := hf.tNewest - hf.tOldest
|
||||
hf.mu.RUnlock()
|
||||
return span >= sec
|
||||
}
|
||||
|
||||
// setWindow points the archive at the timespan the clients are looking at, and
|
||||
// re-sizes the files that no longer match it. It reports whether any file's
|
||||
// geometry changed, which invalidates what clients know about the archive.
|
||||
@@ -842,10 +862,7 @@ func (hf *histFile) readAfter(after, t0, t1 float64, max int) ([]byte, float64,
|
||||
// The run wraps at most once, so it costs at most two reads.
|
||||
buf := make([]byte, n*histPairSize)
|
||||
start := (oldest + lo) % capacity
|
||||
head := int(capacity-start) * histPairSize
|
||||
if head > len(buf) {
|
||||
head = len(buf)
|
||||
}
|
||||
head := min(int(capacity-start)*histPairSize, len(buf))
|
||||
if _, err := hf.f.ReadAt(buf[:head], int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -898,10 +915,8 @@ func (hf *histFile) writePairs(t, v []float64) error {
|
||||
binary.LittleEndian.PutUint64(buf[i*histPairSize+8:], math.Float64bits(v[i]))
|
||||
}
|
||||
|
||||
first := int(hf.capacity - hf.head)
|
||||
if first > n {
|
||||
first = n
|
||||
}
|
||||
first := min(int(hf.capacity-hf.head), n)
|
||||
|
||||
off := int64(histHeaderSize) + int64(hf.head)*histPairSize
|
||||
if _, err := hf.f.WriteAt(buf[:first*histPairSize], off); err != nil {
|
||||
return err
|
||||
@@ -1089,10 +1104,7 @@ func (hw *historyWriter) readRange(key string, t0, t1 float64, maxOut int) ([]fl
|
||||
// Read in contiguous runs: the range wraps at most once.
|
||||
buf := make([]byte, n*histPairSize)
|
||||
start := (oldest + lo) % capacity
|
||||
first := int(capacity - start)
|
||||
if first > n {
|
||||
first = n
|
||||
}
|
||||
first := min(int(capacity-start), n)
|
||||
if _, err := hf.f.ReadAt(buf[:first*histPairSize],
|
||||
int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
|
||||
return nil, nil
|
||||
@@ -1265,7 +1277,7 @@ func (h *Hub) handleSetHistoryBudget(env map[string]interface{}) {
|
||||
// handleHistoryZoom answers a historyZoom request from disk. Same request and
|
||||
// reply shape as "zoom", so clients can fall back to it transparently when a
|
||||
// window reaches further back than the in-memory rings hold.
|
||||
func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]interface{}) {
|
||||
func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]any) {
|
||||
if !h.hist.enabled() {
|
||||
msg, _ := json.Marshal(map[string]any{
|
||||
"type": "historyZoom", "reqId": env["reqId"],
|
||||
@@ -1287,10 +1299,7 @@ func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]interface{}) {
|
||||
// oversampled relative to the plot's point budget and thinned afterwards.
|
||||
// The cap keeps a request for "no decimation" over a multi-hour window from
|
||||
// pulling the whole file into memory.
|
||||
readCap := n * histReadOversample
|
||||
if readCap > histMaxReadPoints {
|
||||
readCap = histMaxReadPoints
|
||||
}
|
||||
readCap := min(n*histReadOversample, histDefaultMaxPoints)
|
||||
|
||||
signals := make(map[string]sigData)
|
||||
for _, k := range strings.Split(sigCSV, ",") {
|
||||
|
||||
@@ -221,6 +221,19 @@ func ringCoverage(bucket, capacity int) int {
|
||||
return capacity / 2 * bucket
|
||||
}
|
||||
|
||||
// captureLagSec is how much further back than the window itself a ring has to
|
||||
// reach to deliver a capture of it.
|
||||
//
|
||||
// A capture is not read out when its last sample arrives but captureMarginSec
|
||||
// later, and then only on the next push tick — so by the time the window is
|
||||
// extracted, its oldest sample is that much deeper in the ring. A ring holding
|
||||
// exactly the window has already overwritten the front of its own capture, which
|
||||
// is what made every shot at a short window come back missing its head. The
|
||||
// pre/post split does not enter into it: the harvest is a post-window after the
|
||||
// trigger and the read reaches a pre-window before it, so the two sum to the
|
||||
// window whatever the split.
|
||||
const captureLagSec = captureMarginSec + 1.0/30.0
|
||||
|
||||
// activeWindowSec is the timespan the buffers must cover. An armed trigger owns
|
||||
// it: its pre-window has to already be in the ring when the trigger fires or
|
||||
// there is nothing to back-fill the capture from. Otherwise it is the widest
|
||||
@@ -228,7 +241,7 @@ func ringCoverage(bucket, capacity int) int {
|
||||
func (h *Hub) activeWindowSec() float64 {
|
||||
if h.trigger != nil && h.trigger.Active() {
|
||||
if cfg := h.trigger.Config(); cfg.windowSec > 0 {
|
||||
return cfg.windowSec
|
||||
return cfg.windowSec + captureLagSec
|
||||
}
|
||||
}
|
||||
widest := 0.0
|
||||
|
||||
@@ -120,7 +120,9 @@ func TestActiveWindowSecTakesTheWidestClientWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
// An armed trigger owns the window: its pre-window has to be in the buffer
|
||||
// before the trigger fires or the capture has nothing to back-fill from.
|
||||
// before the trigger fires or the capture has nothing to back-fill from. The
|
||||
// buffers must reach back past the window itself, because the capture is read
|
||||
// out a margin and a tick after its last sample lands.
|
||||
func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) {
|
||||
h := NewHub()
|
||||
c := &wsClient{}
|
||||
@@ -128,8 +130,9 @@ func TestActiveWindowSecPrefersTheArmedTrigger(t *testing.T) {
|
||||
h.clients[c] = true
|
||||
h.trigger.SetConfig(trigConfig{signalKey: "s1:sig", windowSec: 45, mode: "normal"})
|
||||
|
||||
if got := h.activeWindowSec(); got != 45 {
|
||||
t.Fatalf("activeWindowSec = %v, want the trigger's 45", got)
|
||||
if got := h.activeWindowSec(); got != 45+captureLagSec {
|
||||
t.Fatalf("activeWindowSec = %v, want the trigger's 45 plus the %v harvest lag",
|
||||
got, captureLagSec)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,14 @@ type triggerEngine struct {
|
||||
bufGrowth float64
|
||||
bufKnown bool
|
||||
bufRateOK bool
|
||||
// bufCoverage is the maximum span (seconds) the ring can reach at its
|
||||
// current bucket and capacity — the gate must never demand more than this,
|
||||
// or a ring whose coverage is below the window can never satisfy it. 0 =
|
||||
// unknown (no measurable rate).
|
||||
bufCoverage float64
|
||||
// bufArchived is true when the disk history already spans the trigger
|
||||
// window, so a short capture's front can be back-filled from it.
|
||||
bufArchived bool
|
||||
// Reference point the growth is measured against.
|
||||
bufRefSpan, bufRefWall float64
|
||||
|
||||
@@ -108,6 +116,22 @@ type triggerEngine struct {
|
||||
firedPost float64
|
||||
firedValid bool
|
||||
|
||||
// The edge to fire on as soon as the FSM rearms, in sample time. Recorded
|
||||
// while a capture is still being collected or handed out, for edges late
|
||||
// enough that a capture of them would not overlap the one in flight.
|
||||
//
|
||||
// Without this the trigger is deaf from its own trigger point until the
|
||||
// capture has been harvested — a post-window plus captureMarginSec — and
|
||||
// then for the holdoff on top of that, and afterwards waits for a FRESH
|
||||
// edge. On a sparse pulse train that rounds the capture spacing up to a
|
||||
// whole pulse period: at the default 1 s window the blind stretch comes to
|
||||
// 1.15 s, so a 1 Hz train was caught at 0.5 Hz and a wider window lost whole
|
||||
// multiples. Remembering the edge instead makes the blind stretch exactly
|
||||
// the post-window it has to be, since the capture is built from the edge's
|
||||
// own timestamp and the ring still holds everything around it.
|
||||
pendingT float64
|
||||
pendingValid bool
|
||||
|
||||
rearmAt float64 // wall-clock seconds; 0 when no rearm is pending
|
||||
}
|
||||
|
||||
@@ -163,10 +187,14 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) {
|
||||
if base != te.baseKey {
|
||||
// The buffer measurement belongs to the old signal's ring.
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
}
|
||||
te.baseKey, te.elemIdx = base, idx
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
// An edge held over from the old configuration would be latched against the
|
||||
// new window, whose fill the gate has not vouched for.
|
||||
te.pendingValid = false
|
||||
}
|
||||
|
||||
func (te *triggerEngine) Config() trigConfig {
|
||||
@@ -175,15 +203,37 @@ func (te *triggerEngine) Config() trigConfig {
|
||||
return te.cfg
|
||||
}
|
||||
|
||||
// Arm starts a fresh acquisition. It is the user's own arm, so it discards any
|
||||
// edge remembered during the previous capture: the user asked for the next
|
||||
// event, not for one that has already been and gone.
|
||||
func (te *triggerEngine) Arm() {
|
||||
te.mu.Lock()
|
||||
te.state = trigArmed
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
te.pendingValid = false
|
||||
te.rearmAt = 0
|
||||
te.mu.Unlock()
|
||||
}
|
||||
|
||||
// rearm is the automatic arm at the end of a capture. Unlike Arm it honours an
|
||||
// edge that arrived while the capture was being collected, firing on it at once
|
||||
// rather than waiting for the next one — see pendingT. It also keeps the level
|
||||
// tracked through the dead time, so the first sample after rearming is compared
|
||||
// against its real predecessor instead of being spent seeding one.
|
||||
func (te *triggerEngine) rearm() {
|
||||
te.mu.Lock()
|
||||
te.rearmAt = 0
|
||||
if te.pendingValid {
|
||||
t := te.pendingT
|
||||
te.pendingValid = false
|
||||
te.latchWindowLocked(t)
|
||||
} else {
|
||||
te.state = trigArmed
|
||||
}
|
||||
te.mu.Unlock()
|
||||
}
|
||||
|
||||
func (te *triggerEngine) Disarm() {
|
||||
te.mu.Lock()
|
||||
te.state = trigIdle
|
||||
@@ -191,6 +241,7 @@ func (te *triggerEngine) Disarm() {
|
||||
te.prevValid = false
|
||||
te.prevValue = 0
|
||||
te.firedValid = false
|
||||
te.pendingValid = false
|
||||
te.rearmAt = 0
|
||||
te.mu.Unlock()
|
||||
}
|
||||
@@ -243,13 +294,16 @@ const bufGrowthIntervalSec = 0.5
|
||||
const bufGrowthSmooth = 0.5
|
||||
|
||||
// setBuffered records how far back the trigger signal's ring reaches, at wall
|
||||
// clock now, and derives how fast that is growing. Pass known=false when there
|
||||
// is no such ring.
|
||||
func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// clock now, and derives how fast that is growing. coverage is the maximum
|
||||
// span (seconds) the ring can reach at its current bucket/capacity; archived
|
||||
// says the disk history already spans the trigger window. Pass known=false when
|
||||
// there is no ring to measure.
|
||||
func (te *triggerEngine) setBuffered(span, coverage float64, archived, known bool, now float64) {
|
||||
te.mu.Lock()
|
||||
defer te.mu.Unlock()
|
||||
if !known {
|
||||
te.bufKnown, te.bufRateOK = false, false
|
||||
te.bufCoverage, te.bufArchived = 0, false
|
||||
return
|
||||
}
|
||||
if !te.bufKnown {
|
||||
@@ -257,6 +311,8 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
te.bufRefSpan, te.bufRefWall = span, now
|
||||
}
|
||||
te.bufSpan = span
|
||||
te.bufCoverage = coverage
|
||||
te.bufArchived = archived
|
||||
dt := now - te.bufRefWall
|
||||
if dt < bufGrowthIntervalSec {
|
||||
return
|
||||
@@ -294,9 +350,17 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) {
|
||||
// anyway. A full one grows only as fast as its incoming samples free space —
|
||||
// re-bucketing to a longer window replaces dense old samples with sparse new
|
||||
// ones — and it is that case, growth well below 1, where firing on the
|
||||
// pre-window alone delivers a capture whose front has been overwritten by the
|
||||
// time it is read. In the steady state growth is 0 and need is the whole
|
||||
// window, which a ring tuned for that window already exceeds, so nothing waits.
|
||||
//
|
||||
// Two escapes keep an armed trigger from staying deaf forever:
|
||||
//
|
||||
// - archived — the disk history already spans the window, so the front of a
|
||||
// capture can be back-filled from it; the ring only needs to
|
||||
// hold the pre-window worth of recent data.
|
||||
// - coverage — never demand more than the ring can physically reach. If its
|
||||
// coverage saturates below the window (a measured source rate
|
||||
// that over-estimates the true one), the gate opens once the
|
||||
// ring is full anyway and a short capture is delivered instead
|
||||
// of deafness.
|
||||
func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
pre := te.cfg.windowSec * te.cfg.prePercent / 100
|
||||
growth := 0.0 // until measured, assume the buffer will not fill on its own
|
||||
@@ -307,6 +371,14 @@ func (te *triggerEngine) fillNeedLocked() float64 {
|
||||
if need < pre {
|
||||
need = pre
|
||||
}
|
||||
if te.bufArchived {
|
||||
// The archive back-fills the front; the ring holds the post-trigger
|
||||
// window live, so the pre-window is all it needs to have reached.
|
||||
return pre
|
||||
}
|
||||
if te.bufCoverage > 0 && need > te.bufCoverage {
|
||||
need = te.bufCoverage
|
||||
}
|
||||
return need
|
||||
}
|
||||
|
||||
@@ -366,7 +438,11 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
te.lastT = t[len(t)-1]
|
||||
te.lastTOK = true
|
||||
te.lastFeedWall = float64(time.Now().UnixNano()) / 1e9
|
||||
if te.state != trigArmed {
|
||||
|
||||
// A capture in flight does not stop the comparator; it only changes what an
|
||||
// edge does. See pendingT.
|
||||
inFlight := te.state == trigCollecting || te.state == trigTriggered
|
||||
if te.state != trigArmed && !inFlight {
|
||||
return
|
||||
}
|
||||
step, start := 1, 0
|
||||
@@ -381,12 +457,20 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
// which is what made the first shot after a window change come back short.
|
||||
// Track the level meanwhile, so the first edge once the buffer is deep
|
||||
// enough is still measured against the right previous sample.
|
||||
if te.fillLocked() < 1 {
|
||||
if !inFlight && te.fillLocked() < 1 {
|
||||
for i := start; i < len(v); i += step {
|
||||
te.prevValue, te.prevValid = v[i], true
|
||||
}
|
||||
return
|
||||
}
|
||||
// The earliest trigger point a new capture may take. The one in flight owns
|
||||
// everything up to the end of its own post-window, and the holdoff — a guard
|
||||
// against re-triggering on the ringing of the SAME event — is measured from
|
||||
// its trigger point too, so the two overlap rather than add.
|
||||
notBefore := math.Inf(1)
|
||||
if inFlight && te.firedValid {
|
||||
notBefore = te.trigTime + math.Max(te.firedPost, te.cfg.holdoffSec)
|
||||
}
|
||||
thr := te.cfg.threshold
|
||||
for i := start; i < len(t); i += step {
|
||||
if !te.prevValid {
|
||||
@@ -406,10 +490,19 @@ func (te *triggerEngine) feed(key string, nElem int, t, v []float64) {
|
||||
default:
|
||||
fired = up
|
||||
}
|
||||
if fired {
|
||||
if !fired {
|
||||
continue
|
||||
}
|
||||
if !inFlight {
|
||||
te.latchWindowLocked(t[i])
|
||||
return
|
||||
}
|
||||
// Keep the FIRST qualifying edge and go on tracking the level: a later
|
||||
// one would be no more use, and stopping here would leave prevValue
|
||||
// stale by the time the FSM rearms.
|
||||
if !te.pendingValid && t[i] >= notBefore {
|
||||
te.pendingT, te.pendingValid = t[i], true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,19 +685,32 @@ func (h *Hub) refreshTriggerFill() {
|
||||
return
|
||||
}
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
key := h.trigger.baseSignalKey()
|
||||
var rb *sigRing
|
||||
if key := h.trigger.baseSignalKey(); key != "" {
|
||||
if key != "" {
|
||||
rb = h.getRing(key)
|
||||
}
|
||||
if rb == nil {
|
||||
// Nothing to measure. Do not gate on a signal the hub does not carry:
|
||||
// that would leave the trigger armed forever, which is worse than a
|
||||
// short capture.
|
||||
h.trigger.setBuffered(0, false, now)
|
||||
h.trigger.setBuffered(0, 0, false, false, now)
|
||||
return
|
||||
}
|
||||
_, span := rb.stats()
|
||||
h.trigger.setBuffered(span, true, now)
|
||||
// Maximum span the ring can ever reach at its current bucket/capacity, in
|
||||
// seconds. The gate must never demand more than this, or a ring whose
|
||||
// coverage is below the window (a measured source rate that over-estimates
|
||||
// the true one) can never satisfy it.
|
||||
coverage := 0.0
|
||||
if rate := rb.sourceRate(); rate > 0 {
|
||||
coverage = float64(ringCoverage(rb.bucketSize(), rb.capacity())) / rate
|
||||
}
|
||||
// If the disk archive already spans the trigger window, the front of a
|
||||
// short capture can be back-filled from it, so the ring need not cover the
|
||||
// whole window on its own.
|
||||
archived := h.hist.coversWindow(key, h.trigger.Config().windowSec)
|
||||
h.trigger.setBuffered(span, coverage, archived, true, now)
|
||||
}
|
||||
|
||||
// triggerTick services the trigger FSM; called from Hub.Run() on every push tick.
|
||||
@@ -640,7 +746,7 @@ func (h *Hub) triggerTick() {
|
||||
// file of its own, where nothing overwrites it until the next trigger.
|
||||
h.hist.captureRange(trigTime-pre, trigTime+post)
|
||||
} else if h.trigger.dueRearm(nowSec) {
|
||||
h.trigger.Arm()
|
||||
h.trigger.rearm()
|
||||
}
|
||||
|
||||
if h.trigger.stateUnsent() {
|
||||
|
||||
@@ -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, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,9 +297,9 @@ func TestCollectingIsBroadcast(t *testing.T) {
|
||||
// later. It forgets any earlier measurement first, so the rate is the one
|
||||
// asked for rather than a blend with it.
|
||||
func setFill(te *triggerEngine, span, growth, now float64) {
|
||||
te.setBuffered(0, false, now)
|
||||
te.setBuffered(span-growth, true, now)
|
||||
te.setBuffered(span, true, now+1)
|
||||
te.setBuffered(0, 0, false, false, now)
|
||||
te.setBuffered(span-growth, 0, false, true, now)
|
||||
te.setBuffered(span, 0, false, true, now+1)
|
||||
}
|
||||
|
||||
// What has to hold is that the buffer spans the whole window by the time the
|
||||
@@ -417,9 +417,9 @@ func TestForceIgnoresFillGate(t *testing.T) {
|
||||
// interval, so they refresh the span and leave the seeded rate alone.
|
||||
func seedFillNow(te *triggerEngine, span, growth float64) {
|
||||
now := float64(time.Now().UnixNano()) / 1e9
|
||||
te.setBuffered(0, false, now-1)
|
||||
te.setBuffered(span-growth, true, now-1)
|
||||
te.setBuffered(span, true, now)
|
||||
te.setBuffered(0, 0, false, false, now-1)
|
||||
te.setBuffered(span-growth, 0, false, true, now-1)
|
||||
te.setBuffered(span, 0, false, true, now)
|
||||
}
|
||||
|
||||
// While it holds off, the trigger looks identical to one that is ignoring
|
||||
@@ -505,3 +505,55 @@ func drainStates(t *testing.T, h *Hub) []map[string]any {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ring whose coverage saturates below the window (measured source rate that
|
||||
// over-estimates the true one) can never satisfy the full-window need. The
|
||||
// coverage clamp must open the gate once the ring is full, delivering a short
|
||||
// capture rather than staying deaf forever.
|
||||
func TestFillNeedClampedToCoverage(t *testing.T) {
|
||||
te := newTriggerEngine()
|
||||
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
|
||||
setFill(te, 50, 0, 100) // ring full at 50 s, no growth
|
||||
te.mu.Lock()
|
||||
te.bufCoverage = 50 // the ring can never reach further back
|
||||
te.mu.Unlock()
|
||||
|
||||
if need := te.fillNeedLocked(); need != 50 {
|
||||
t.Errorf("need = %v, want 50 (clamped to coverage, not the 60 s window)", need)
|
||||
}
|
||||
if f := te.fillLocked(); f < 1 {
|
||||
t.Errorf("fillLocked = %v, want >= 1: a full ring below the window must still open the gate", f)
|
||||
}
|
||||
|
||||
// Without the clamp the gate would stay shut forever.
|
||||
te.mu.Lock()
|
||||
te.bufCoverage = 0
|
||||
te.mu.Unlock()
|
||||
if f := te.fillLocked(); f >= 1 {
|
||||
t.Errorf("baseline: fillLocked = %v, want < 1 without a coverage clamp", f)
|
||||
}
|
||||
}
|
||||
|
||||
// When the disk archive already spans the window it can back-fill the front of
|
||||
// a capture, so the gate must only require the ring to have reached the
|
||||
// pre-window, not the whole window.
|
||||
func TestFillNeedArchiveLowersToPreWindow(t *testing.T) {
|
||||
te := newTriggerEngine()
|
||||
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
|
||||
setFill(te, 30, 0, 100) // ring holds only 30 s, no growth → need 60 without archive
|
||||
te.mu.Lock()
|
||||
te.bufArchived = true
|
||||
te.mu.Unlock()
|
||||
|
||||
if want := 12.0; te.fillNeedLocked() != want { // 60 * 0.20
|
||||
t.Errorf("need = %v, want %v (archive lowers to the pre-window)", te.fillNeedLocked(), want)
|
||||
}
|
||||
|
||||
// A ring holding just the pre-window opens the gate once archived.
|
||||
te.mu.Lock()
|
||||
te.bufSpan = 12
|
||||
te.mu.Unlock()
|
||||
if f := te.fillLocked(); f < 1 {
|
||||
t.Errorf("fillLocked = %v, want >= 1 with pre-window buffered and the archive available", f)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user