// Command chain-client is the authoritative mock StreamHub client for the // streaming-chain E2E suite. It connects to a running StreamHub, records the // live binary stream to disk (received_.bin), and runs behavioural checks // (live / zoom / window / trigger), writing the results to checks_.json. // // Unlike the streamhub smoke test, individual check failures are *recorded* // (not fatal): only connection/protocol corruption exits non-zero, so one // scenario's quirk never aborts the matrix. The waveform validator and report // decide overall pass/fail from checks_.json + the recorded stream. // // received_.bin format ("RCV1"): // // magic[4]="RCV1"; [u32 nSig]; per signal: // [u16 keyLen][key][u32 N][N×f64 t][N×f64 v] // // where each signal's samples are merged across all v1 pushes, sorted by time // and de-duplicated (same timestamp kept once). package main import ( "encoding/binary" "encoding/json" "flag" "fmt" "log" "math" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/gorilla/websocket" ) var ( hubFlag = flag.String("hub", "127.0.0.1:8090", "StreamHub host:port") scenario = flag.String("scenario", "", "scenario id (artifact basename)") trigsig = flag.String("trigsig", "", "trigger signal key src:sig (empty = skip)") trigthr = flag.Float64("trigthr", math.NaN(), "trigger threshold (NaN = use mean)") checksCSV = flag.String("checks", "live,zoom,window,trigger", "checks to run") outDir = flag.String("out", "/tmp/chain_e2e", "artifact output dir") durSec = flag.Float64("dur", 4.0, "live recording duration (s)") timeout = flag.Duration("timeout", 90*time.Second, "overall timeout") verbose = flag.Bool("v", false, "log every event") mode = flag.String("mode", "checks", "checks | stress") reqrate = flag.Float64("reqrate", 0, "stress: sustained zoom requests/sec (0 = liveness only)") clientID = flag.Int("clientid", 0, "stress: parallel client index (output suffix)") ) // ── wire types ──────────────────────────────────────────────────────────── type sourceInfo struct { ID string `json:"id"` Label string `json:"label"` Addr string `json:"addr"` State string `json:"state"` } type signalInfo struct { Name string `json:"name"` TypeCode uint32 `json:"typeCode"` NumRows uint32 `json:"numRows"` NumCols uint32 `json:"numCols"` TimeMode int `json:"timeMode"` Rate float64 `json:"samplingRate"` } type points struct { T []float64 `json:"t"` V []float64 `json:"v"` } type event struct { Type string `json:"type"` Sources json.RawMessage `json:"sources"` SourceID string `json:"sourceId"` Signals json.RawMessage `json:"signals"` ReqID uint32 `json:"reqId"` State string `json:"state"` TrigTime float64 `json:"trigTime"` } type pushFrame struct { sourceID string signals map[string]points } type captureFrame struct { trigTime, preSec, postSec float64 signals map[string]points } // ── binary parsers (from Test/E2E/streamhub/main.go) ──────────────────────── func parsePush(b []byte) (*pushFrame, error) { if len(b) < 2 || b[0] != 1 { return nil, fmt.Errorf("not a v1 frame") } idLen := int(b[1]) off := 2 if len(b) < off+idLen+4 { return nil, fmt.Errorf("truncated header") } f := &pushFrame{sourceID: string(b[off : off+idLen]), signals: map[string]points{}} off += idLen nSig := int(binary.LittleEndian.Uint32(b[off:])) off += 4 for s := 0; s < nSig; s++ { if len(b) < off+2 { return nil, fmt.Errorf("truncated keyLen (sig %d)", s) } keyLen := int(binary.LittleEndian.Uint16(b[off:])) off += 2 if len(b) < off+keyLen+4 { return nil, fmt.Errorf("truncated key (sig %d)", s) } key := string(b[off : off+keyLen]) off += keyLen n := int(binary.LittleEndian.Uint32(b[off:])) off += 4 if len(b) < off+16*n { return nil, fmt.Errorf("truncated data (sig %s n=%d)", key, n) } pts := points{T: make([]float64, n), V: make([]float64, n)} for i := 0; i < n; i++ { pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n for i := 0; i < n; i++ { pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n f.signals[key] = pts } return f, nil } func parseCapture(b []byte) (*captureFrame, error) { if len(b) < 1+24+4 || b[0] != 2 { return nil, fmt.Errorf("not a v2 frame") } rd := func(off int) float64 { return math.Float64frombits(binary.LittleEndian.Uint64(b[off:])) } f := &captureFrame{trigTime: rd(1), preSec: rd(9), postSec: rd(17), signals: map[string]points{}} off := 25 nSig := int(binary.LittleEndian.Uint32(b[off:])) off += 4 for s := 0; s < nSig; s++ { keyLen := int(binary.LittleEndian.Uint16(b[off:])) off += 2 key := string(b[off : off+keyLen]) off += keyLen n := int(binary.LittleEndian.Uint32(b[off:])) off += 4 if len(b) < off+16*n { return nil, fmt.Errorf("truncated capture (sig %s n=%d)", key, n) } pts := points{T: make([]float64, n), V: make([]float64, n)} for i := 0; i < n; i++ { pts.T[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n for i := 0; i < n; i++ { pts.V[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[off+8*i:])) } off += 8 * n f.signals[key] = pts } return f, nil } // ── client ────────────────────────────────────────────────────────────────── type client struct { ws *websocket.Conn deadline time.Time mu sync.Mutex sources []sourceInfo configs map[string][]signalInfo pushes []*pushFrame zooms map[uint32]map[string]points zoomArrival map[uint32]time.Time trigSt []string captures []*captureFrame readErr error done chan struct{} } func (c *client) send(v interface{}) { b, _ := json.Marshal(v) if err := c.ws.WriteMessage(websocket.TextMessage, b); err != nil { fatal("ws write: %v", err) } } // reader runs in its own goroutine for the lifetime of the connection. gorilla's // websocket connection cannot survive a read deadline (the next ReadMessage // panics), so we never set one here: we block on ReadMessage and let the overall // timeout/cond logic in waitFor decide when enough has arrived. func (c *client) reader() { defer close(c.done) for { mt, data, err := c.ws.ReadMessage() if err != nil { c.mu.Lock() c.readErr = err c.mu.Unlock() return } c.handle(mt, data) } } func (c *client) handle(mt int, data []byte) { switch mt { case websocket.BinaryMessage: if len(data) == 0 { return } switch data[0] { case 1: if f, err := parsePush(data); err == nil { c.mu.Lock() c.pushes = append(c.pushes, f) c.mu.Unlock() } else { fatal("bad v1 frame: %v", err) } case 2: if f, err := parseCapture(data); err == nil { c.mu.Lock() c.captures = append(c.captures, f) c.mu.Unlock() } else { fatal("bad v2 frame: %v", err) } default: fatal("unknown binary frame version %d", data[0]) } case websocket.TextMessage: var ev event if err := json.Unmarshal(data, &ev); err != nil { return } if *verbose { log.Printf("event %-12s %.140s", ev.Type, data) } switch ev.Type { case "sources": var s []sourceInfo if json.Unmarshal(ev.Sources, &s) == nil { c.mu.Lock() c.sources = s c.mu.Unlock() } case "config": var s []signalInfo if json.Unmarshal(ev.Signals, &s) == nil { c.mu.Lock() c.configs[ev.SourceID] = s c.mu.Unlock() } case "zoom": var body struct { Signals map[string]points `json:"signals"` } if json.Unmarshal(data, &body) == nil { c.mu.Lock() c.zooms[ev.ReqID] = body.Signals c.zoomArrival[ev.ReqID] = time.Now() c.mu.Unlock() } case "triggerState": c.mu.Lock() c.trigSt = append(c.trigSt, ev.State) c.mu.Unlock() } } } // waitFor polls cond (evaluated under the state lock) until it holds or the // deadline passes. It returns early if the reader goroutine died. func (c *client) waitFor(d time.Duration, cond func() bool) bool { end := time.Now().Add(d) if end.After(c.deadline) { end = c.deadline } for time.Now().Before(end) { c.mu.Lock() ok := cond() err := c.readErr c.mu.Unlock() if ok { return true } if err != nil { return false } select { case <-c.done: c.mu.Lock() ok := cond() c.mu.Unlock() return ok case <-time.After(10 * time.Millisecond): } } return false } // zoom returns the recorded zoom reply for reqID, if present. func (c *client) zoom(reqID uint32) (map[string]points, bool) { c.mu.Lock() defer c.mu.Unlock() z, ok := c.zooms[reqID] return z, ok } // lastCapture returns the most recently recorded trigger capture, if any. func (c *client) lastCapture() *captureFrame { c.mu.Lock() defer c.mu.Unlock() if len(c.captures) == 0 { return nil } return c.captures[len(c.captures)-1] } // nCaptures returns the number of trigger captures recorded so far. func (c *client) nCaptures() int { c.mu.Lock() defer c.mu.Unlock() return len(c.captures) } // nPushes returns the number of live push frames recorded so far. func (c *client) nPushes() int { c.mu.Lock() defer c.mu.Unlock() return len(c.pushes) } func fatal(format string, a ...interface{}) { fmt.Printf("FATAL "+format+"\n", a...) os.Exit(1) } // merged returns time-sorted, de-duplicated samples per "src:sig" key. func (c *client) merged() map[string]points { c.mu.Lock() pushes := append([]*pushFrame(nil), c.pushes...) c.mu.Unlock() tmp := map[string]points{} for _, f := range pushes { for k, p := range f.signals { full := f.sourceID + ":" + k cur := tmp[full] cur.T = append(cur.T, p.T...) cur.V = append(cur.V, p.V...) tmp[full] = cur } } out := map[string]points{} for k, p := range tmp { idx := make([]int, len(p.T)) for i := range idx { idx[i] = i } sort.Slice(idx, func(a, b int) bool { return p.T[idx[a]] < p.T[idx[b]] }) var op points last := math.NaN() for _, i := range idx { if !math.IsNaN(last) && p.T[i] == last { continue } op.T = append(op.T, p.T[i]) op.V = append(op.V, p.V[i]) last = p.T[i] } out[k] = op } return out } // ── received_.bin writer ───────────────────────────────────────────────── func writeReceived(path string, m map[string]points) error { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) f, err := os.Create(path) if err != nil { return err } defer f.Close() var buf []byte put32 := func(v uint32) { var t [4]byte; binary.LittleEndian.PutUint32(t[:], v); buf = append(buf, t[:]...) } put16 := func(v uint16) { var t [2]byte; binary.LittleEndian.PutUint16(t[:], v); buf = append(buf, t[:]...) } putf := func(v float64) { var t [8]byte; binary.LittleEndian.PutUint64(t[:], math.Float64bits(v)); buf = append(buf, t[:]...) } buf = append(buf, []byte("RCV1")...) put32(uint32(len(keys))) for _, k := range keys { put16(uint16(len(k))) buf = append(buf, []byte(k)...) p := m[k] put32(uint32(len(p.T))) for _, t := range p.T { putf(t) } for _, v := range p.V { putf(v) } } _, err = f.Write(buf) return err } // ── checks structures ──────────────────────────────────────────────────────── type zoomCheck struct { Range [2]float64 `json:"range"` N int `json:"n"` Returned int `json:"returned"` InRange bool `json:"inrange"` Key string `json:"key"` } type windowCheck struct { WindowSec float64 `json:"windowSec"` Span float64 `json:"span"` Returned int `json:"returned"` OK bool `json:"ok"` Key string `json:"key"` } type trigCheck struct { Edge string `json:"edge"` Mode string `json:"mode"` Fired bool `json:"fired"` TrigTime float64 `json:"trigTime"` PreSec float64 `json:"preSec"` PostSec float64 `json:"postSec"` CapturePts int `json:"capturePts"` EdgeOK bool `json:"edgeOk"` WindowOK bool `json:"windowOk"` Rearmed bool `json:"rearmed"` Key string `json:"key"` } type liveCheck struct { OK bool `json:"ok"` Frames int `json:"frames"` Signals int `json:"signals"` Monotonic bool `json:"monotonic"` WallClock bool `json:"wallclock"` DurationS float64 `json:"duration"` } type checksOut struct { Scenario string `json:"scenario"` Live liveCheck `json:"live"` Zoom []zoomCheck `json:"zoom"` Window windowCheck `json:"window"` Trigger []trigCheck `json:"trigger"` } func has(set, name string) bool { for _, s := range strings.Split(set, ",") { if strings.TrimSpace(s) == name { return true } } return false } func main() { flag.Parse() if *scenario == "" { fatal("missing -scenario") } if err := os.MkdirAll(*outDir, 0o755); err != nil { fatal("mkdir %s: %v", *outDir, err) } url := "ws://" + *hubFlag + "/ws" log.Printf("connecting to %s (scenario %s)", url, *scenario) ws, _, err := websocket.DefaultDialer.Dial(url, nil) if err != nil { fatal("dial %s: %v", url, err) } defer ws.Close() c := &client{ ws: ws, deadline: time.Now().Add(*timeout), configs: map[string][]signalInfo{}, zooms: map[uint32]map[string]points{}, zoomArrival: map[uint32]time.Time{}, done: make(chan struct{}), } go c.reader() out := checksOut{Scenario: *scenario} // 1. sources connected c.send(map[string]interface{}{"type": "getSources"}) if !c.waitFor(20*time.Second, func() bool { for _, s := range c.sources { if s.State == "connected" { return true } } return false }) { fatal("no connected source within timeout") } for _, s := range c.sources { c.send(map[string]interface{}{"type": "getConfig", "sourceId": s.ID}) } c.waitFor(5*time.Second, func() bool { for _, s := range c.sources { if s.State == "connected" && len(c.configs[s.ID]) == 0 { return false } } return true }) // stress mode: record liveness + sustained zoom latency, then exit. The // correctness checks below are skipped (a separate gate framework). if *mode == "stress" { c.runStress(*scenario, *clientID, *durSec, *reqrate, *outDir) return } // 2. live recording — the reader goroutine accumulates pushes in the // background, so we simply wait out the recording window (or an early // reader death). c.waitFor(time.Duration(*durSec*float64(time.Second)), func() bool { return false }) m := c.merged() recvPath := filepath.Join(*outDir, "received_"+*scenario+".bin") if err := writeReceived(recvPath, m); err != nil { fatal("write received: %v", err) } now := float64(time.Now().UnixNano()) / 1e9 mono, wall := true, true for _, p := range m { for i, t := range p.T { if math.Abs(t-now) > 60.0 { wall = false } if i > 0 && t < p.T[i-1]-1e-9 { mono = false } } } nPush := c.nPushes() out.Live = liveCheck{ OK: nPush >= 5 && len(m) > 0 && mono && wall, Frames: nPush, Signals: len(m), Monotonic: mono, WallClock: wall, DurationS: *durSec, } log.Printf("live: %d frames, %d signals, mono=%v wall=%v", nPush, len(m), mono, wall) // busiest signal for zoom/window var busy string bn := 0 for k, p := range m { if len(p.T) > bn { bn, busy = len(p.T), k } } // 3. zoom: narrow + wide if has(*checksCSV, "zoom") && busy != "" { ts := m[busy].T t1 := ts[len(ts)-1] t0full := ts[0] ranges := [][2]float64{{t1 - 0.05, t1}, {t0full, t1}} // narrow, wide for ri, rg := range ranges { reqID := uint32(1000 + ri) c.send(map[string]interface{}{ "type": "zoom", "reqId": reqID, "t0": rg[0], "t1": rg[1], "n": 300, "signals": busy, }) ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok }) zc := zoomCheck{Range: rg, N: 300, Key: busy, InRange: true} if ok { z, _ := c.zoom(reqID) pts := z[busy] zc.Returned = len(pts.T) for _, t := range pts.T { if t < rg[0]-1e-6 || t > rg[1]+1e-6 { zc.InRange = false } } } else { zc.InRange = false } out.Zoom = append(out.Zoom, zc) log.Printf("zoom [%.4f,%.4f] returned=%d inrange=%v", rg[0], rg[1], zc.Returned, zc.InRange) } } // 4. window: zoom over [now-windowSec, now] and check span ≤ window if has(*checksCSV, "window") && busy != "" { ts := m[busy].T t1 := ts[len(ts)-1] const winSec = 1.0 reqID := uint32(2000) c.send(map[string]interface{}{ "type": "zoom", "reqId": reqID, "t0": t1 - winSec, "t1": t1, "n": 600, "signals": busy, }) ok := c.waitFor(8*time.Second, func() bool { _, ok := c.zooms[reqID]; return ok }) wc := windowCheck{WindowSec: winSec, Key: busy} if ok { z, _ := c.zoom(reqID) pts := z[busy] wc.Returned = len(pts.T) if len(pts.T) >= 2 { wc.Span = pts.T[len(pts.T)-1] - pts.T[0] wc.OK = wc.Span <= winSec+1e-3 } } out.Window = wc log.Printf("window %.2fs: span=%.4f returned=%d ok=%v", winSec, wc.Span, wc.Returned, wc.OK) } // 5. trigger matrix if has(*checksCSV, "trigger") && *trigsig != "" { thr := *trigthr if math.IsNaN(thr) { if p, ok := m[*trigsig]; ok && len(p.V) > 0 { s := 0.0 for _, v := range p.V { s += v } thr = s / float64(len(p.V)) } else { thr = 0.0 } } log.Printf("trigger signal %s threshold %.6g", *trigsig, thr) for _, edge := range []string{"rising", "falling", "both"} { for _, mode := range []string{"normal", "single"} { out.Trigger = append(out.Trigger, c.runTrigger(*trigsig, edge, mode, thr)) } } } // write checks json cj := filepath.Join(*outDir, "checks_"+*scenario+".json") b, _ := json.MarshalIndent(out, "", " ") if err := os.WriteFile(cj, b, 0o644); err != nil { fatal("write checks: %v", err) } fmt.Printf("OK chain-client %s: %s + %s\n", *scenario, filepath.Base(recvPath), filepath.Base(cj)) } // ── stress mode ────────────────────────────────────────────────────────────── type stressOut struct { Scenario string `json:"scenario"` ClientID int `json:"clientId"` Frames int `json:"frames"` Signals int `json:"signals"` Monotonic bool `json:"monotonic"` WallClock bool `json:"wallclock"` DurationS float64 `json:"duration"` ReqRate float64 `json:"reqRate"` ZoomCount int `json:"zoomCount"` ZoomFail int `json:"zoomFail"` ZoomP50ms float64 `json:"zoomP50ms"` ZoomP95ms float64 `json:"zoomP95ms"` ZoomMaxms float64 `json:"zoomMaxms"` Key string `json:"key"` } // busiestKey returns the full "src:sig" key carrying the most samples so far. func (c *client) busiestKey() string { m := c.merged() var busy string bn := 0 for k, p := range m { if len(p.T) > bn { bn, busy = len(p.T), k } } return busy } // maxTimeFull returns the latest timestamp seen for a full "src:sig" key. func (c *client) maxTimeFull(full string) (float64, bool) { c.mu.Lock() defer c.mu.Unlock() var mx float64 found := false for _, f := range c.pushes { if !strings.HasPrefix(full, f.sourceID+":") { continue } name := full[len(f.sourceID)+1:] p, ok := f.signals[name] if !ok { continue } for _, t := range p.T { if !found || t > mx { mx, found = t, true } } } return mx, found } // runStress records liveness for the duration while (optionally) issuing zoom // queries at a sustained rate, measuring round-trip latency. One request is in // flight at a time, so latency reflects the hub's serialised zoom service time // under whatever concurrent live/zoom load the matrix imposes. func (c *client) runStress(scenario string, clientID int, dur, reqrate float64, outDir string) { if !c.waitFor(20*time.Second, func() bool { return len(c.pushes) > 0 }) { fatal("stress: no live push within timeout") } // brief warmup so the busiest signal and a usable time window exist. c.waitFor(500*time.Millisecond, func() bool { return false }) key := c.busiestKey() start := time.Now() end := start.Add(time.Duration(dur * float64(time.Second))) var lat []float64 zoomFail := 0 var reqID uint32 = 5000 var interval time.Duration if reqrate > 0 { interval = time.Duration(float64(time.Second) / reqrate) } for reqrate > 0 && key != "" && time.Now().Before(end) { tick := time.Now() t1, ok := c.maxTimeFull(key) if !ok { c.waitFor(50*time.Millisecond, func() bool { return false }) continue } reqID++ c.send(map[string]interface{}{ "type": "zoom", "reqId": reqID, "t0": t1 - 0.5, "t1": t1, "n": 300, "signals": key, }) sendT := time.Now() got := c.waitFor(3*time.Second, func() bool { _, ok := c.zoomArrival[reqID] return ok }) if got { c.mu.Lock() at := c.zoomArrival[reqID] c.mu.Unlock() lat = append(lat, at.Sub(sendT).Seconds()*1000.0) } else { zoomFail++ } if rem := interval - time.Since(tick); rem > 0 { c.waitFor(rem, func() bool { return false }) } } // if no reqrate, simply wait out the remaining liveness window. if reqrate <= 0 { c.waitFor(time.Until(end), func() bool { return false }) } m := c.merged() now := float64(time.Now().UnixNano()) / 1e9 mono, wall := true, true for _, p := range m { for i, t := range p.T { if math.Abs(t-now) > 60.0 { wall = false } if i > 0 && t < p.T[i-1]-1e-9 { mono = false } } } sort.Float64s(lat) pct := func(q float64) float64 { if len(lat) == 0 { return 0 } idx := int(q * float64(len(lat)-1)) return lat[idx] } so := stressOut{ Scenario: scenario, ClientID: clientID, Frames: c.nPushes(), Signals: len(m), Monotonic: mono, WallClock: wall, DurationS: dur, ReqRate: reqrate, Key: key, ZoomCount: len(lat), ZoomFail: zoomFail, ZoomP50ms: pct(0.50), ZoomP95ms: pct(0.95), } if len(lat) > 0 { so.ZoomMaxms = lat[len(lat)-1] } path := filepath.Join(outDir, fmt.Sprintf("stress_%s_c%d.json", scenario, clientID)) b, _ := json.MarshalIndent(so, "", " ") if err := os.WriteFile(path, b, 0o644); err != nil { fatal("write stress: %v", err) } log.Printf("stress: frames=%d signals=%d zoom n=%d fail=%d p50=%.1fms p95=%.1fms max=%.1fms", so.Frames, so.Signals, so.ZoomCount, so.ZoomFail, so.ZoomP50ms, so.ZoomP95ms, so.ZoomMaxms) fmt.Printf("OK stress %s c%d: %s\n", scenario, clientID, filepath.Base(path)) } // runTrigger configures one edge/mode trigger, arms it, and records the result. func (c *client) runTrigger(key, edge, mode string, thr float64) trigCheck { tc := trigCheck{Edge: edge, Mode: mode, Key: key} beforeCaps := c.nCaptures() c.send(map[string]interface{}{ "type": "setTrigger", "signal": key, "edge": edge, "threshold": thr, "windowSec": 0.1, "prePercent": 20.0, "mode": mode, }) c.send(map[string]interface{}{"type": "arm"}) fired := c.waitFor(8*time.Second, func() bool { return len(c.captures) > beforeCaps }) tc.Fired = fired if !fired { c.send(map[string]interface{}{"type": "disarm"}) return tc } cap0 := c.lastCapture() tc.TrigTime, tc.PreSec, tc.PostSec = cap0.trigTime, cap0.preSec, cap0.postSec tc.WindowOK = math.Abs(cap0.preSec-0.02) < 1e-6 && math.Abs(cap0.postSec-0.08) < 1e-6 if pts, ok := cap0.signals[key]; ok { tc.CapturePts = len(pts.T) tc.EdgeOK = edgeCrosses(pts, cap0.trigTime, edge, thr) } // re-arm behaviour if mode == "normal" { tc.Rearmed = c.waitFor(4*time.Second, func() bool { return len(c.captures) > beforeCaps+1 }) } else { // single: must NOT fire again until rearm again := c.waitFor(1500*time.Millisecond, func() bool { return len(c.captures) > beforeCaps+1 }) c.send(map[string]interface{}{"type": "rearm"}) tc.Rearmed = !again && c.waitFor(4*time.Second, func() bool { return len(c.captures) > beforeCaps+1 }) } c.send(map[string]interface{}{"type": "disarm"}) log.Printf("trigger %s/%s fired=%v edgeOk=%v winOk=%v rearm=%v", edge, mode, tc.Fired, tc.EdgeOK, tc.WindowOK, tc.Rearmed) return tc } // edgeCrosses verifies the captured waveform crosses thr in the edge direction // near trigTime. func edgeCrosses(p points, trigTime float64, edge string, thr float64) bool { // find the sample pair straddling trigTime for i := 1; i < len(p.T); i++ { if p.T[i-1] <= trigTime && p.T[i] >= trigTime { a, b := p.V[i-1], p.V[i] switch edge { case "rising": return a <= thr && b >= thr case "falling": return a >= thr && b <= thr case "both": return (a <= thr && b >= thr) || (a >= thr && b <= thr) } } } return false }