Files
MARTe-Integrated-Components/Test/E2E/chain/client/main.go
T
2026-06-26 09:11:10 +02:00

872 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_<id>.bin), and runs behavioural checks
// (live / zoom / window / trigger), writing the results to checks_<id>.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_<id>.json + the recorded stream.
//
// received_<id>.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_<id>.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
}