refactor(e2e): rename Test/E2E/chain -> Test/E2E/suite, run_chain_e2e.sh -> run_e2e.sh

This commit is contained in:
Martino Ferrari
2026-07-01 18:42:33 +02:00
parent 1fcc4e4e6d
commit 69e52af20b
30 changed files with 14 additions and 14 deletions
+332
View File
@@ -0,0 +1,332 @@
// E2E_Report.typ — Streaming-chain E2E report.
//
// Renders report_data.json (produced by report_build.py) into a single PDF:
// headline KPIs, per-field progression/regression vs the previous run, unit-test
// suites, code coverage, performance (CPU/peak-RSS/throughput), per-scenario
// waveform fidelity, trend plots, and embedded per-scenario waveform images.
//
// Compile from the directory holding report_data.json + the *.png artifacts:
// typst compile E2E_Report.typ E2E_Report.pdf
// Missing inputs degrade to "n/a" / skipped sections so a partial run still
// renders.
#let data = json("report_data.json")
#let meta = data.meta
#let e2e = data.e2e
#let ut = data.unit_tests
#let cov = data.coverage
#let reg = data.regression
#let hl = data.headline
// ── helpers ──────────────────────────────────────────────────────────────────
#let fmt(v, suffix: "") = if v == none { "n/a" } else { str(v) + suffix }
#let fnum(v, digits: 3, suffix: "") = {
if v == none { "n/a" } else {
[#calc.round(v, digits: digits)] + suffix
}
}
#let ok_color = rgb("#1a7f37")
#let bad_color = rgb("#cf222e")
#let neutral = rgb("#57606a")
#let warn_color = rgb("#9a6700") // XFAIL — expected/known failure
#let xpass_color = rgb("#8250df") // XPASS — stale marker, needs attention
#let status_badge(s) = {
let c = if s == "PASS" { ok_color }
else if s == "FAIL" { bad_color }
else if s == "XFAIL" { warn_color }
else if s == "XPASS" { xpass_color }
else { neutral }
box(fill: c, inset: (x: 6pt, y: 2pt), radius: 3pt, text(fill: white, weight: "bold", size: 8pt)[#s])
}
#let yesno(b) = {
if b == true { text(fill: ok_color)[] }
else if b == false { text(fill: bad_color)[] }
else { text(fill: neutral)[] }
}
// progression arrow for a regression row
#let trend_arrow(r) = {
if r.delta == none or r.delta == 0 { text(fill: neutral)[] }
else {
let up = r.delta > 0
let good = r.better == true
let c = if good { ok_color } else { bad_color }
let arrow = if up { "▲" } else { "▼" }
text(fill: c)[#arrow #calc.round(r.delta, digits: 4)]
}
}
// ── page setup ───────────────────────────────────────────────────────────────
#set page(
paper: "a4", margin: (x: 1.8cm, y: 1.8cm),
header: align(right, text(size: 8pt, fill: neutral)[Streaming-chain E2E · #meta.git_sha]),
footer: context align(center, text(size: 8pt, fill: neutral)[#counter(page).display("1 / 1", both: true)]),
)
#set text(font: "DejaVu Sans", size: 9.5pt)
#set heading(numbering: "1.1")
#show heading.where(level: 1): it => block(above: 14pt, below: 8pt, text(size: 14pt, weight: "bold", it))
#show heading.where(level: 2): it => block(above: 10pt, below: 6pt, text(size: 11pt, weight: "bold", it))
// ── title ────────────────────────────────────────────────────────────────────
#align(center)[
#text(size: 20pt, weight: "bold")[MARTe2 Streaming-Chain E2E Report] \
#v(2pt)
#text(size: 10pt, fill: neutral)[
#meta.timestamp · commit #raw(meta.git_sha) · target #meta.target ·
run \##data.history_len
]
]
#v(6pt)
#align(center, status_badge(e2e.overall) + h(8pt) + text(size: 11pt)[
E2E #hl.e2e_pass/#hl.e2e_total · Units #hl.unit_pass/#hl.unit_total
#if hl.at("e2e_xfail", default: 0) > 0 [ · #hl.e2e_xfail xfail]
#if hl.at("e2e_xpass", default: 0) > 0 [ · #text(fill: xpass_color)[#hl.e2e_xpass xpass!]]
])
// ── headline KPIs ────────────────────────────────────────────────────────────
#v(10pt)
#let kpi(label, value) = box(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#f6f8fa"))[
#align(center)[
#text(size: 14pt, weight: "bold")[#value] \
#text(size: 8pt, fill: neutral)[#label]
]
]
#grid(columns: 4, gutter: 6pt,
kpi("E2E passed", [#hl.e2e_pass/#hl.e2e_total]),
kpi("Unit passed", [#hl.unit_pass/#hl.unit_total]),
kpi("Mean sine corr", fnum(hl.mean_corr, digits: 4)),
kpi("Throughput", fnum(hl.mean_throughput_sps, digits: 0, suffix: " sp/s")),
kpi("Python cov", fmt(hl.cov_python, suffix: "%")),
kpi("Go cov", fmt(hl.cov_go, suffix: "%")),
kpi("Mean peak RSS", fnum(hl.mean_peak_rss_mb, digits: 1, suffix: " MB")),
kpi("Mean CPU", fnum(hl.mean_cpu_s, digits: 2, suffix: " s")),
)
// ── progression / regression ─────────────────────────────────────────────────
= Progression / regression
#if data.is_first_run [
_First recorded run baseline established; no previous run to compare against._
] else [
Comparison against the previous run (#text(fill: ok_color)[▲] better,
#text(fill: bad_color)[] worse, unchanged/unavailable).
#v(4pt)
#table(
columns: (1.6fr, 1fr, 1fr, 1.2fr),
align: (left, right, right, right),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 5pt,
table.header([*Metric*], [*Current*], [*Previous*], [*Δ (trend)*]),
..reg.map(r => (
[#r.name],
fnum(r.current, digits: 4),
fnum(r.previous, digits: 4),
trend_arrow(r),
)).flatten()
)
]
// ── unit tests ───────────────────────────────────────────────────────────────
= Unit tests
#table(
columns: (2fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr),
align: (left, center, right, right, right, right, right),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 5pt,
table.header([*Suite*], [*Status*], [*Total*], [*Pass*], [*Fail*], [*Skip*], [*Time (s)*]),
..ut.suites.map(s => (
[#s.name],
if not s.avail { status_badge("SKIP") } else if s.ok { status_badge("PASS") } else { status_badge("FAIL") },
[#s.total], [#s.passed], [#s.failed], [#s.skipped], fnum(s.time_s, digits: 2),
)).flatten(),
table.cell(colspan: 2)[*Totals*],
[#ut.totals.total], [#ut.totals.passed], [#ut.totals.failed], [#ut.totals.skipped], [],
)
// ── coverage ─────────────────────────────────────────────────────────────────
= Code coverage
#table(
columns: (1fr, 1fr, 2fr),
align: (left, right, left),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 5pt,
table.header([*Language*], [*Coverage*], [*Source*]),
..cov.languages.map(l => (
[#l.name],
if l.pct == none { text(fill: neutral)[n/a] } else { [#l.pct%] },
text(size: 8pt, fill: neutral)[#l.note],
)).flatten()
)
// per-file C++ detail — the streaming chain's most critical code, so the report
// shows line coverage for every project C++ source (worst-covered first).
#let cpp = cov.languages.find(l => l.name == "C++")
#if cpp != none and cpp.at("files", default: ()).len() > 0 [
== C++ source detail
#let cf = cpp.files
#text(size: 8pt, fill: neutral)[
#cpp.at("lines_hit", default: 0) of #cpp.at("lines_found", default: 0)
lines covered across #cf.len() project files (worst-covered first;
green ≥75%, amber 5075%, red under 50%).
]
#v(4pt)
#let covcell(p) = {
let c = if p == none { neutral }
else if p >= 75 { ok_color }
else if p >= 50 { warn_color }
else { bad_color }
text(fill: c, weight: "bold")[#if p == none { "n/a" } else [#p%]]
}
#table(
columns: (3.2fr, 1fr, 1fr, 1.1fr),
align: (left, right, right, right),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 4pt,
table.header([*Source file*], [*Lines*], [*Hit*], [*Coverage*]),
..cf.map(f => (
text(size: 8pt)[#f.path],
[#f.lines_found],
[#f.lines_hit],
covcell(f.pct),
)).flatten()
)
]
// ── performance ──────────────────────────────────────────────────────────────
= Performance
Per-scenario CPU time and peak resident memory (VmHWM) of the StreamHub and
MARTe2 processes, plus sustained client throughput (recorded samples ÷ duration).
#v(4pt)
#table(
columns: (1.8fr, 1fr, 1fr, 1fr, 1fr, 1.1fr),
align: (left, right, right, right, right, right),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 5pt,
table.header([*Scenario*], [*Hub CPU (s)*], [*Hub RSS (MB)*],
[*MARTe CPU (s)*], [*MARTe RSS (MB)*], [*Throughput (sp/s)*]),
..e2e.scenarios.map(sc => {
let h = sc.perf.at("hub", default: (:))
let m = sc.perf.at("marte", default: (:))
(
[#sc.id],
fnum(h.at("cpu_s", default: none), digits: 2),
fnum(h.at("peak_rss_mb", default: none), digits: 1),
fnum(m.at("cpu_s", default: none), digits: 2),
fnum(m.at("peak_rss_mb", default: none), digits: 1),
fnum(sc.throughput_sps, digits: 0),
)
}).flatten()
)
// ── per-scenario waveform fidelity ───────────────────────────────────────────
= Scenarios
#for sc in e2e.scenarios [
== #sc.id #h(6pt) #status_badge(sc.status)
#if sc.at("desc", default: none) != none [
#v(1pt)
#text(size: 9pt, fill: neutral, style: "italic")[#sc.desc]
#v(2pt)
]
#if sc.at("known_issue", default: none) != none [
#v(2pt)
#box(fill: rgb("#fff8c5"), inset: 5pt, radius: 3pt, width: 100%,
text(size: 8pt, fill: rgb("#7d4e00"))[*Known issue:* #sc.known_issue])
]
#let rb = sc.rollup
Client checks:
live #yesno(rb.at("live_ok", default: none)) ·
zoom #yesno(rb.at("zoom_ok", default: none)) ·
window #yesno(rb.at("window_ok", default: none)) ·
trigger #yesno(rb.at("trigger_ok", default: none))
#if sc.live_frames != none [ · #sc.live_frames live frames]
#v(3pt)
#table(
columns: (1.6fr, 0.6fr, 0.8fr, 0.8fr, 1fr, 0.9fr, 0.9fr, 0.8fr, 0.8fr),
align: (left, center, left, left, right, right, right, center, center),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 4pt,
table.header([*Signal*], [*Pass*], [*Type*], [*Quant*], [*Max abs err*],
[*Corr*], [*nRMSE*], [*Fidelity*], [*Shape*]),
..sc.signals.map(g => (
raw(g.key),
yesno(g.pass),
text(size: 8pt)[#g.type],
text(size: 8pt)[#g.quant],
fnum(g.max_abs_err, digits: 6),
fnum(g.corr, digits: 4),
fnum(g.nrmse, digits: 4),
yesno(g.fidelity_ok),
yesno(g.shape_ok),
)).flatten()
)
// zoom / window / trigger behavioural detail (real WS-driven results)
#let zooms = sc.at("zoom", default: ())
#let win = sc.at("window", default: (:))
#let trigs = sc.at("trigger", default: ())
#if zooms.len() > 0 or win.len() > 0 [
#v(3pt)
#text(size: 8.5pt, weight: "bold")[Zoom / window queries]
#table(
columns: (1fr, 1.2fr, 1fr, 0.8fr, 0.8fr),
align: (left, left, right, right, center),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 4pt,
table.header([*Query*], [*Signal*], [*Span (s)*], [*Pts*], [*OK*]),
..zooms.enumerate().map(((i, z)) => {
let r = z.at("range", default: (0, 0))
(
[zoom #str(i + 1)],
raw(z.at("key", default: "")),
fnum(r.at(1) - r.at(0), digits: 4),
[#z.at("returned", default: 0)],
yesno(z.at("inrange", default: none)),
)
}).flatten(),
..(if win.len() > 0 and win.at("returned", default: 0) > 0 {(
[window #fnum(win.at("windowSec", default: 0), digits: 2)],
raw(win.at("key", default: "")),
fnum(win.at("span", default: 0), digits: 4),
[#win.at("returned", default: 0)],
yesno(win.at("ok", default: none)),
)} else {()}),
)
]
#if trigs.len() > 0 [
#v(3pt)
#text(size: 8.5pt, weight: "bold")[Trigger captures] #h(4pt)
#text(size: 8pt, fill: neutral)[(signal #raw(trigs.at(0).at("key", default: "")))]
#table(
columns: (1fr, 1fr, 0.8fr, 1fr, 1fr, 0.8fr, 0.8fr),
align: (left, left, center, right, right, center, center),
stroke: 0.4pt + rgb("#d0d7de"),
inset: 4pt,
table.header([*Edge*], [*Mode*], [*Fired*], [*Pre/Post (s)*],
[*Capture pts*], [*Win OK*], [*Edge OK*]),
..trigs.map(t => (
text(size: 8pt)[#t.at("edge", default: "")],
text(size: 8pt)[#t.at("mode", default: "")],
yesno(t.at("fired", default: none)),
text(size: 8pt)[#fnum(t.at("preSec", default: 0), digits: 3) / #fnum(t.at("postSec", default: 0), digits: 3)],
[#t.at("capturePts", default: 0)],
yesno(t.at("windowOk", default: none)),
yesno(t.at("edgeOk", default: none)),
)).flatten()
)
]
// embed the waveform overview if report_build.py found one in the artifacts
#if sc.at("wave_img", default: none) != none [
#v(3pt)
#align(center, image(sc.wave_img, width: 92%))
]
#v(6pt)
]
// ── trend plots ──────────────────────────────────────────────────────────────
#if data.trend_plots.len() > 0 [
= Trends over runs
#grid(columns: 2, gutter: 8pt,
..data.trend_plots.map(p => image(p, width: 100%))
)
]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
module chain-client
go 1.21
require github.com/gorilla/websocket v1.5.1
require golang.org/x/net v0.17.0 // indirect
+4
View File
@@ -0,0 +1,4 @@
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+871
View File
@@ -0,0 +1,871 @@
// 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
}
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"encoding/binary"
"math"
"testing"
)
// ── frame builders (mirror the StreamHub wire format) ───────────────────────
func putf(b []byte, v float64) []byte {
var t [8]byte
binary.LittleEndian.PutUint64(t[:], math.Float64bits(v))
return append(b, t[:]...)
}
func putSignals(b []byte, sigs map[string]points) []byte {
var n [4]byte
binary.LittleEndian.PutUint32(n[:], uint32(len(sigs)))
b = append(b, n[:]...)
for k, p := range sigs {
var kl [2]byte
binary.LittleEndian.PutUint16(kl[:], uint16(len(k)))
b = append(b, kl[:]...)
b = append(b, []byte(k)...)
var cnt [4]byte
binary.LittleEndian.PutUint32(cnt[:], uint32(len(p.T)))
b = append(b, cnt[:]...)
for _, t := range p.T {
b = putf(b, t)
}
for _, v := range p.V {
b = putf(b, v)
}
}
return b
}
func buildPush(srcID string, sigs map[string]points) []byte {
b := []byte{1, byte(len(srcID))}
b = append(b, []byte(srcID)...)
return putSignals(b, sigs)
}
func buildCapture(trigTime, preSec, postSec float64, sigs map[string]points) []byte {
b := []byte{2}
b = putf(b, trigTime)
b = putf(b, preSec)
b = putf(b, postSec)
return putSignals(b, sigs)
}
// ── tests ───────────────────────────────────────────────────────────────────
func TestParsePushRoundTrip(t *testing.T) {
in := map[string]points{"Sine": {T: []float64{0, 0.1, 0.2}, V: []float64{1, 2, 3}}}
f, err := parsePush(buildPush("src", in))
if err != nil {
t.Fatalf("parsePush: %v", err)
}
if f.sourceID != "src" {
t.Errorf("sourceID = %q, want src", f.sourceID)
}
got := f.signals["Sine"]
if len(got.T) != 3 || got.T[2] != 0.2 || got.V[1] != 2 {
t.Errorf("bad payload: %+v", got)
}
}
func TestParsePushRejectsWrongVersion(t *testing.T) {
if _, err := parsePush([]byte{2, 0, 0}); err == nil {
t.Error("expected error on non-v1 frame")
}
}
func TestParsePushTruncated(t *testing.T) {
full := buildPush("src", map[string]points{"A": {T: []float64{0}, V: []float64{1}}})
if _, err := parsePush(full[:len(full)-4]); err == nil {
t.Error("expected truncation error")
}
}
func TestParseCaptureRoundTrip(t *testing.T) {
in := map[string]points{"Sig": {T: []float64{1, 2}, V: []float64{9, 8}}}
f, err := parseCapture(buildCapture(100.5, 0.02, 0.08, in))
if err != nil {
t.Fatalf("parseCapture: %v", err)
}
if f.trigTime != 100.5 || f.preSec != 0.02 || f.postSec != 0.08 {
t.Errorf("header = %v/%v/%v", f.trigTime, f.preSec, f.postSec)
}
if g := f.signals["Sig"]; len(g.T) != 2 || g.V[0] != 9 {
t.Errorf("bad capture payload: %+v", g)
}
}
func TestParseCaptureRejectsWrongVersion(t *testing.T) {
if _, err := parseCapture([]byte{1, 0, 0, 0}); err == nil {
t.Error("expected error on non-v2 frame")
}
}
func TestMergedSortsAndDedups(t *testing.T) {
c := &client{
configs: map[string][]signalInfo{},
zooms: map[uint32]map[string]points{},
}
// two pushes, out of order, with one duplicate timestamp
c.pushes = []*pushFrame{
{sourceID: "s", signals: map[string]points{"A": {T: []float64{0.2, 0.1}, V: []float64{2, 1}}}},
{sourceID: "s", signals: map[string]points{"A": {T: []float64{0.1, 0.3}, V: []float64{1, 3}}}},
}
m := c.merged()
got := m["s:A"]
want := []float64{0.1, 0.2, 0.3}
if len(got.T) != len(want) {
t.Fatalf("len = %d, want %d (%+v)", len(got.T), len(want), got)
}
for i := range want {
if got.T[i] != want[i] {
t.Errorf("t[%d] = %v, want %v", i, got.T[i], want[i])
}
}
}
func TestEdgeCrosses(t *testing.T) {
rising := points{T: []float64{0, 1, 2}, V: []float64{-1, 1, 2}}
if !edgeCrosses(rising, 0.5, "rising", 0.0) {
t.Error("rising cross not detected")
}
if edgeCrosses(rising, 0.5, "falling", 0.0) {
t.Error("false falling detection on rising data")
}
falling := points{T: []float64{0, 1}, V: []float64{1, -1}}
if !edgeCrosses(falling, 0.5, "falling", 0.0) {
t.Error("falling cross not detected")
}
if !edgeCrosses(falling, 0.5, "both", 0.0) {
t.Error("both should match a falling edge")
}
}
func TestHas(t *testing.T) {
if !has("live,zoom,trigger", "zoom") {
t.Error("has should find zoom")
}
if has("live, window", "trigger") {
t.Error("has should not find trigger")
}
if !has("live, window", "window") {
t.Error("has should trim spaces")
}
}
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""
collect.py — Run the unit-test suites and gather coverage for the E2E report.
Produces two JSON artifacts in --out:
* ``unit_tests.json`` — per-suite {total, passed, failed, skipped, time_s, ok}
for the C++ GTest binary, the Go chain-client tests, and the Python
framework tests, plus grand totals.
* ``coverage.json`` — per-language {pct, avail, note} for Python (coverage.py),
Go (``go test -cover``) and C++ (lcov, best-effort: only when the build was
instrumented with .gcno files; otherwise reported unavailable).
Each suite is isolated: a missing toolchain or a failing suite is recorded, never
fatal, so the report always renders. Requires the MARTe env (LD_LIBRARY_PATH) to
already be exported for the GTest binary — the orchestrator does this.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
def _run(cmd, cwd=None, env=None, timeout=600):
try:
p = subprocess.run(cmd, cwd=cwd, env=env, timeout=timeout,
capture_output=True, text=True)
return p.returncode, p.stdout, p.stderr
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
return 127, "", str(e)
# ── C++ GTest ─────────────────────────────────────────────────────────────────
def gtest_suite(gtest_bin, work):
s = {"name": "C++ GTest", "lang": "cpp", "total": 0, "passed": 0,
"failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
if not gtest_bin or not os.path.exists(gtest_bin):
s["detail"] = "GTest binary not found"
return s
xml_p = os.path.join(work, "gtest.xml")
rc, out, err = _run([gtest_bin, f"--gtest_output=xml:{xml_p}"], timeout=900)
s["avail"] = True
if os.path.exists(xml_p):
try:
root = ET.parse(xml_p).getroot()
s["total"] = int(root.get("tests", 0))
s["failed"] = int(root.get("failures", 0)) + int(root.get("errors", 0))
s["skipped"] = int(root.get("disabled", 0)) + int(root.get("skipped", 0))
s["time_s"] = float(root.get("time", 0.0))
s["passed"] = s["total"] - s["failed"] - s["skipped"]
s["ok"] = s["failed"] == 0 and s["total"] > 0
except (ET.ParseError, ValueError) as e:
s["detail"] = f"xml parse: {e}"
else:
s["detail"] = (err or out or "no xml produced")[-200:]
return s
# ── C++ Integration (DebugService runtime, non-GTest) ─────────────────────────
def integration_suite(int_bin, work, timeout=220):
"""Run the printf-narrated IntegrationTests.ex binary and heuristically
derive per-test pass/fail from its stdout.
This binary predates GTest adoption and always ``return 0`` from main()
(only an internal 180s SIGALRM timeout or an OS-level crash produce a
non-zero exit), so exit code alone is not a reliable signal. Each of its
7 "--- Test N: ..." blocks prints "SUCCESS:"/"VALIDATION SUCCESSFUL:" on
success or "ERROR:"/"FAILURE:" on failure, so split stdout by those
headers and flag a block failed if it contains an ERROR/FAILURE marker.
Exercises DebugServiceBase.cpp/DebugService.cpp runtime logic that the
header-only DebugServiceGTest suite deliberately does not touch, so it is
the only source of real coverage for those files.
"""
s = {"name": "C++ Integration", "lang": "cpp", "total": 0, "passed": 0,
"failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
if not int_bin or not os.path.exists(int_bin):
s["detail"] = "IntegrationTests binary not found"
return s
s["avail"] = True
t0 = time.time()
rc, out, err = _run([int_bin], timeout=timeout)
s["time_s"] = round(time.time() - t0, 1)
text = out + "\n" + err
blocks = re.split(r"\n(?=--- Test \d+:)", text)
test_blocks = [b for b in blocks if b.lstrip().startswith("--- Test")]
finished = "All Integration Tests Finished." in text
s["total"] = len(test_blocks)
s["failed"] = sum(1 for b in test_blocks if re.search(r"\b(ERROR|FAILURE):", b))
if not finished and s["total"] == 0:
# Crashed/timed out before printing anything useful.
s["total"] = 1
s["failed"] = 1
s["detail"] = f"binary did not complete (rc={rc}): {(err or out)[-200:]}"
elif not finished:
s["detail"] = f"binary exited rc={rc} before finishing all tests"
s["passed"] = s["total"] - s["failed"]
s["ok"] = finished and s["failed"] == 0 and s["total"] > 0
return s
# ── Go ────────────────────────────────────────────────────────────────────────
def go_all_suites(repo, work):
"""Run Go test suites across all project modules and aggregate results."""
modules = [
(os.path.join(repo, "Test/E2E/suite/client"),
"Go (chain-client)"),
(os.path.join(repo, "Common/Client/go"),
"Go (common udpsprotocol + wshub)"),
(os.path.join(repo, "Client/debugger"),
"Go (debugger)"),
]
total_pct = 0.0
pct_count = 0
suites = []
for mod_dir, name in modules:
s = {"name": name, "lang": "go", "total": 0, "passed": 0,
"failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
cov_p = os.path.join(work, f"go_cover_{name.replace(' ', '_')}.out")
rc, out, err = _run(
["go", "test", "-json", f"-coverprofile={cov_p}", "./..."],
cwd=mod_dir)
if rc == 127:
s["detail"] = "go toolchain not found"
suites.append(s)
continue
s["avail"] = True
cov_pct = _parse_go_json(out, s)
if cov_pct is not None:
s["cov_pct"] = cov_pct
total_pct += cov_pct
pct_count += 1
suites.append(s)
return suites, (round(total_pct / pct_count, 1) if pct_count else None)
def _parse_go_json(out, s):
"""Parse Go test -json output into passed/failed/skipped counts.
Returns coverage percentage (float or None)."""
cov_pct = None
for line in out.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
act = ev.get("Action")
if act == "pass" and ev.get("Test"):
s["passed"] += 1
s["total"] += 1
elif act == "fail" and ev.get("Test"):
s["failed"] += 1
s["total"] += 1
elif act == "skip" and ev.get("Test"):
s["skipped"] += 1
s["total"] += 1
elif act == "output":
m = re.search(r"coverage:\s+([\d.]+)%", ev.get("Output", ""))
if m:
cov_pct = float(m.group(1))
s["ok"] = s["failed"] == 0 and s["total"] > 0
return cov_pct
# ── Python ──────────────────────────────────────────────────────────────────
def py_suite(chain_dir, work):
s = {"name": "Python (framework)", "lang": "python", "total": 0, "passed": 0,
"failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": True}
cov_json = os.path.join(work, "py_cover.json")
env = dict(os.environ)
env["COVERAGE_FILE"] = os.path.join(work, ".coverage")
have_cov = _run(["coverage", "--version"])[0] == 0
if have_cov:
cmd = ["coverage", "run", f"--source={chain_dir}", "-m", "unittest",
"tests_py", "-v"]
else:
cmd = [sys.executable, "-m", "unittest", "tests_py", "-v"]
rc, out, err = _run(cmd, cwd=chain_dir, env=env)
text = out + "\n" + err
m = re.search(r"Ran (\d+) tests? in ([\d.]+)s", text)
if m:
s["total"] = int(m.group(1))
s["time_s"] = float(m.group(2))
fm = re.search(r"failures=(\d+)", text)
em = re.search(r"errors=(\d+)", text)
sm = re.search(r"skipped=(\d+)", text)
s["failed"] = (int(fm.group(1)) if fm else 0) + (int(em.group(1)) if em else 0)
s["skipped"] = int(sm.group(1)) if sm else 0
s["passed"] = s["total"] - s["failed"] - s["skipped"]
s["ok"] = s["failed"] == 0 and s["total"] > 0
pct = None
if have_cov:
_run(["coverage", "json", "-o", cov_json], cwd=chain_dir, env=env)
if os.path.exists(cov_json):
try:
cj = json.load(open(cov_json))
pct = round(cj["totals"]["percent_covered"], 1)
except (KeyError, ValueError):
pass
s["cov_pct"] = pct
return s
# ── C++ coverage (best-effort) ────────────────────────────────────────────────
def _parse_lcov_info(path, repo):
"""Parse an LCOV tracefile into per-file line/function coverage.
Returns (files, totals) where ``files`` is a list of
{path (repo-relative), lines_found, lines_hit, pct, funcs_found,
funcs_hit} sorted worst-covered first, and ``totals`` aggregates the
same line counts across all files. Only the line counters (DA/LF/LH)
and function counters (FNF/FNH) are read; branch data is ignored.
"""
files = []
cur = None
repo_abs = os.path.abspath(repo) + os.sep
try:
fh = open(path)
except OSError:
return [], {"lines_found": 0, "lines_hit": 0, "pct": None}
with fh:
for line in fh:
line = line.rstrip("\n")
if line.startswith("SF:"):
src = line[3:]
rel = src[len(repo_abs):] if src.startswith(repo_abs) else src
cur = {"path": rel, "lines_found": 0, "lines_hit": 0,
"funcs_found": 0, "funcs_hit": 0}
elif cur is None:
continue
elif line.startswith("LF:"):
cur["lines_found"] = int(line[3:] or 0)
elif line.startswith("LH:"):
cur["lines_hit"] = int(line[3:] or 0)
elif line.startswith("FNF:"):
cur["funcs_found"] = int(line[4:] or 0)
elif line.startswith("FNH:"):
cur["funcs_hit"] = int(line[4:] or 0)
elif line == "end_of_record":
lf = cur["lines_found"]
cur["pct"] = round(100.0 * cur["lines_hit"] / lf, 1) if lf else None
files.append(cur)
cur = None
tot_f = sum(f["lines_found"] for f in files)
tot_h = sum(f["lines_hit"] for f in files)
totals = {"lines_found": tot_f, "lines_hit": tot_h,
"pct": round(100.0 * tot_h / tot_f, 1) if tot_f else None}
files.sort(key=lambda f: (f["pct"] if f["pct"] is not None else 101.0,
f["path"]))
return files, totals
def cpp_coverage(repo, target):
cov = {"name": "C++", "avail": False, "pct": None, "files": [],
"note": "not instrumented (rebuild with --cpp-coverage)"}
build = os.path.join(repo, "Build", target)
gcno = []
for root, _, files in os.walk(build):
for fn in files:
if fn.endswith(".gcno"):
gcno.append(os.path.join(root, fn))
if not gcno:
return cov
# lcov 2.x is strict by default; tolerate the benign mismatches that arise
# from mixing instrumented project objects with non-instrumented MARTe2/STL
# headers, and never let a single bad file abort the whole capture.
ign = ["--ignore-errors",
"mismatch,source,gcov,unused,empty,negative,unsupported,inconsistent"]
raw = os.path.join(build, "coverage_raw.info")
rc, out, err = _run(["lcov", "--capture", "--directory", build,
"--output-file", raw, "--quiet"] + ign, timeout=900)
if rc != 0 or not os.path.exists(raw):
cov["note"] = "lcov capture failed: " + (err or out or "")[-160:]
return cov
# Keep only this repo's own sources so the number reflects project code,
# not the MARTe2 framework headers dragged in by templates/inlines.
info = os.path.join(build, "coverage.info")
rc2, _, e2 = _run(["lcov", "--extract", raw,
os.path.join(repo, "Source", "*"),
os.path.join(repo, "Test", "*"),
"--output-file", info, "--quiet"] + ign, timeout=300)
summ_file = info if (rc2 == 0 and os.path.exists(info)) else raw
# Parse the tracefile directly for per-file detail; this also yields the
# aggregate so the headline number and the per-file table are consistent.
fdetail, totals = _parse_lcov_info(summ_file, repo)
if totals["pct"] is not None:
cov.update(avail=True, pct=totals["pct"], files=fdetail,
lines_found=totals["lines_found"],
lines_hit=totals["lines_hit"],
note="lcov (project sources)" if summ_file == info else "lcov")
return cov
# Fall back to lcov --summary if the tracefile had no parseable line data.
rc3, summ, _ = _run(["lcov", "--summary", summ_file] + ign)
m = re.search(r"lines[.\s]+:\s+([\d.]+)%", summ)
if m:
cov.update(avail=True, pct=float(m.group(1)),
note="lcov (project sources)" if summ_file == info else "lcov")
else:
cov["note"] = "lcov summary unparsed"
return cov
def main():
p = argparse.ArgumentParser(description="Collect unit tests + coverage")
p.add_argument("--repo", required=True)
p.add_argument("--target", default="x86-linux")
p.add_argument("--out", required=True)
p.add_argument("--work", default=None,
help="scratch dir for *.xml/cover files (default: --out)")
p.add_argument("--cpp-coverage", action="store_true")
args = p.parse_args()
os.makedirs(args.out, exist_ok=True)
work = args.work or args.out
os.makedirs(work, exist_ok=True)
chain_dir = os.path.dirname(os.path.abspath(__file__))
gtest_bin = os.path.join(args.repo, "Build", args.target, "GTest", "MainGTest.ex")
# BUILD_DIR for Test/Integration doubles the last path component
# ($(PACKAGE)/$(lastword of CURDIR)) — see MakeStdLibDefs.gcc.
integration_bin = os.path.join(args.repo, "Build", args.target,
"Test", "Integration", "Integration",
"IntegrationTests.ex")
go_suites, go_avg_cov = go_all_suites(args.repo, work)
suites = ([gtest_suite(gtest_bin, work), integration_suite(integration_bin, work)]
+ go_suites + [py_suite(chain_dir, work)])
totals = {k: sum(s.get(k, 0) for s in suites)
for k in ("total", "passed", "failed", "skipped")}
ut = {"suites": suites, "totals": totals,
"ok": all(s["ok"] for s in suites if s["avail"])}
with open(os.path.join(args.out, "unit_tests.json"), "w") as f:
json.dump(ut, f, indent=2)
langs = []
py = next(s for s in suites if s["lang"] == "python")
langs.append({"name": "Python", "avail": py.get("cov_pct") is not None,
"pct": py.get("cov_pct"), "note": "coverage.py"})
langs.append({"name": "Go", "avail": go_avg_cov is not None,
"pct": go_avg_cov, "note": "go test -cover (avg across modules)"})
cpp = cpp_coverage(args.repo, args.target) if args.cpp_coverage else \
{"name": "C++", "avail": False, "pct": None, "note": "skipped (use --cpp-coverage)"}
langs.append(cpp)
with open(os.path.join(args.out, "coverage.json"), "w") as f:
json.dump({"languages": langs}, f, indent=2)
print(f"unit_tests: {totals['passed']}/{totals['total']} pass "
f"({totals['failed']} fail, {totals['skipped']} skip)")
print("coverage: " + ", ".join(
f"{l['name']}={l['pct']}%" if l["pct"] is not None else f"{l['name']}=n/a"
for l in langs))
if __name__ == "__main__":
main()
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
gen_cfg.py — MARTe2 app + StreamHub config generator for the streaming-chain E2E.
Given a scenario (scenarios.py) it produces two config files:
* MARTe app cfg: ``LinuxTimer + FileReader(input) -> IOGAM -> UDPStreamer`` per
source. When ``oracle in {fed,both}`` a second IOGAM branch taps the same fed
signals into a ``FileWriter`` (the "fed reference").
* StreamHub cfg: one ``Source`` per UDPStreamer (unicast or multicast) on the
scenario's ``ws_port``.
The producer rate (LinuxTimer Frequency) defaults to 1 kHz; the FileReader uses
``EOF = "Rewind"`` so the NUM_ROWS-row input loops for the whole run. The
validator reconstructs truth as the cyclic ground-truth sequence, so the wrap is
expected, not an error.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S # noqa: E402
PRODUCER_HZ = 1000 # LinuxTimer frequency (Hz)
def _ndims(elements):
return 0 if elements == 1 else 1
def _gam_sig(sig, datasource):
"""A GAM signal entry referencing a DataSource (no UDPStreamer extras)."""
return _gam_sig_named(sig["name"], sig, datasource)
def _gam_sig_named(name, sig, datasource):
"""A GAM signal entry with an explicit (possibly renamed) signal name.
IOGAM copies inputs to outputs positionally, so the input and output names
may differ; we exploit that to route through the DDB with source-prefixed
names that never clash with the TimerGAM's Counter/Time or across sources.
"""
return (f"{name} = {{ Type = {sig['type']} "
f"NumberOfDimensions = {_ndims(sig['elements'])} "
f"NumberOfElements = {sig['elements']} DataSource = {datasource} }}")
def _streamer_sig(sig):
"""A UDPStreamer DataSource signal entry (carries time/quant/unit options)."""
parts = [f"Type = {sig['type']}",
f"NumberOfDimensions = {_ndims(sig['elements'])}",
f"NumberOfElements = {sig['elements']}"]
if sig["time_mode"] and sig["time_mode"] != "PacketTime":
parts.append(f'TimeMode = "{sig["time_mode"]}"')
if sig["time_signal"]:
parts.append(f'TimeSignal = "{sig["time_signal"]}"')
if sig["sampling_rate"]:
parts.append(f"SamplingRate = {sig['sampling_rate']}")
if sig["quant"] and sig["quant"] != "none":
parts.append(f'QuantizedType = "{sig["quant"]}"')
parts.append(f"RangeMin = {sig['range_min']}")
parts.append(f"RangeMax = {sig['range_max']}")
if sig["unit"]:
parts.append(f'Unit = "{sig["unit"]}"')
return f"{sig['name']} = {{ {' '.join(parts)} }}"
def _streamer_block(src, scenario):
s = src["signals"]
parts = [f"Port = {src['udp_port']}",
f"MaxPayloadSize = {scenario['max_payload']}",
f'PublishingMode = "{scenario["publishing"]}"']
if scenario["publishing"] == "Accumulate":
parts.append(f"MinRefreshRate = {scenario['min_refresh_hz']}")
if scenario["publishing"] == "Decimate":
parts.append(f"Ratio = {scenario['ratio']}")
if scenario["network"] == "multicast":
parts.append(f'MulticastGroup = "{src["multicast_group"]}"')
parts.append(f"DataPort = {src['data_port']}")
sigs = " ".join(_streamer_sig(sig) for sig in s)
return (f" +Streamer_{src['id']} = {{ Class = UDPStreamer "
f"{' '.join(parts)} Signals = {{ {sigs} }} }}")
def write_marte_cfg(scenario, path, input_bin, tap_bin=None):
"""Write the MARTe app cfg. ``input_bin`` is the src[0] file; additional
sources read ``<input_bin>.<srcid>`` (matching gen_data.write_input)."""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
srcs = scenario["sources"]
want_tap = scenario["oracle"] in ("fed", "both") and tap_bin is not None
_row_dt, _num_rows, producer_hz, _loop_hz = S.geometry(scenario)
gams = [] # +Functions entries
datas = [] # +Data entries
thread_funcs = ["TimerGAM"]
# Timer GAM (drives the schedule)
gams.append(
" +TimerGAM = { Class = IOGAM "
"InputSignals = { Counter = { DataSource = ReaderTimer Type = uint32 } "
f"Time = {{ Frequency = {producer_hz} DataSource = ReaderTimer Type = uint32 }} }} "
"OutputSignals = { Counter = { DataSource = DDB Type = uint32 } "
"Time = { DataSource = DDB Type = uint32 } } }")
for i, src in enumerate(srcs):
sid = src["id"]
fpath = input_bin if i == 0 else f"{input_bin}.{sid}"
rds = f"FileReaderDS_{sid}"
# The FileReader DataSource allows exactly one consuming Function, so a
# tapped source must route through the DDB: ReaderGAM copies FileReader
# -> DDB (source-prefixed names), then StreamGAM and TapGAM both read DDB.
tap_here = want_tap and i == 0
if tap_here:
in_sigs = " ".join(_gam_sig(sig, rds) for sig in src["signals"])
ddb_out = " ".join(_gam_sig_named(f"{sid}_{sig['name']}", sig, "DDB")
for sig in src["signals"])
gams.append(
f" +ReaderGAM_{sid} = {{ Class = IOGAM "
f"InputSignals = {{ {in_sigs} }} OutputSignals = {{ {ddb_out} }} }}")
ddb_in = " ".join(_gam_sig_named(f"{sid}_{sig['name']}", sig, "DDB")
for sig in src["signals"])
stream_out = " ".join(_gam_sig(sig, f"Streamer_{sid}")
for sig in src["signals"])
gams.append(
f" +StreamGAM_{sid} = {{ Class = IOGAM "
f"InputSignals = {{ {ddb_in} }} OutputSignals = {{ {stream_out} }} }}")
thread_funcs.append(f"ReaderGAM_{sid}")
thread_funcs.append(f"StreamGAM_{sid}")
else:
in_sigs = " ".join(_gam_sig(sig, rds) for sig in src["signals"])
out_sigs = " ".join(_gam_sig(sig, f"Streamer_{sid}") for sig in src["signals"])
gams.append(
f" +ReaderGAM_{sid} = {{ Class = IOGAM "
f"InputSignals = {{ {in_sigs} }} OutputSignals = {{ {out_sigs} }} }}")
thread_funcs.append(f"ReaderGAM_{sid}")
datas.append(
f' +{rds} = {{ Class = FileReader Filename = "{fpath}" '
f'Interpolate = "no" FileFormat = "binary" EOF = "Rewind" }}')
datas.append(_streamer_block(src, scenario))
if want_tap:
# tap the first source's signals (now in the DDB) to a FileWriter.
src = srcs[0]
sid = src["id"]
tap_in = " ".join(_gam_sig_named(f"{sid}_{sig['name']}", sig, "DDB")
for sig in src["signals"])
tap_out = " ".join(_gam_sig(sig, "TapWriterDS") for sig in src["signals"])
gams.append(
f" +TapGAM = {{ Class = IOGAM "
f"InputSignals = {{ {tap_in} }} OutputSignals = {{ {tap_out} }} }}")
thread_funcs.append("TapGAM")
tap_sigs = " ".join(_gam_sig(sig, "TapWriterDS").replace(
" DataSource = TapWriterDS", "") for sig in src["signals"])
datas.append(
f' +TapWriterDS = {{ Class = FileWriter NumberOfBuffers = 10 '
f'CPUMask = 0x4 StackSize = 10000000 Filename = "{tap_bin}" '
f'Overwrite = "yes" StoreOnTrigger = 0 FileFormat = "binary" '
f"Signals = {{ {tap_sigs} }} }}")
funcs_block = "\n".join(gams)
data_block = "\n".join(datas)
thread_list = " ".join(thread_funcs)
cfg = f"""$ChainE2E = {{
Class = RealTimeApplication
+Functions = {{
Class = ReferenceContainer
{funcs_block}
}}
+Data = {{
Class = ReferenceContainer DefaultDataSource = DDB
+DDB = {{ Class = GAMDataSource }}
+ReaderTimer = {{ Class = LinuxTimer SleepNature = "Default" Signals = {{ Counter = {{ Type = uint32 }} Time = {{ Type = uint32 }} }} }}
{data_block}
+Timings = {{ Class = TimingDataSource }}
}}
+States = {{ Class = ReferenceContainer +Running = {{ Class = RealTimeState +Threads = {{ Class = ReferenceContainer +ReaderThread = {{ Class = RealTimeThread CPUs = 0x1 Functions = {{{thread_list}}} }} }} }} }}
+Scheduler = {{ Class = GAMScheduler TimingDataSource = Timings }}
}}
"""
with open(path, "w") as f:
f.write(cfg)
return path
def write_hub_cfg(scenario, path):
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
src_blocks = []
for src in scenario["sources"]:
parts = [f'Label = "chain {src["id"]}"', 'Addr = "127.0.0.1"',
f"Port = {src['udp_port']}"]
if scenario["network"] == "multicast":
parts.append(f'MulticastGroup = "{src["multicast_group"]}"')
parts.append(f"DataPort = {src['data_port']}")
src_blocks.append(f" {src['id']} = {{ {' '.join(parts)} }}")
sources = "\n".join(src_blocks)
cfg = f"""Hub = {{
WSPort = {scenario['ws_port']}
MaxPoints = 200000
PushRate = 30
MaxPushPoints = 2000
RingTemporal = 1000000
RingScalar = 100000
Sources = {{
{sources}
}}
}}
"""
with open(path, "w") as f:
f.write(cfg)
return path
def main():
p = argparse.ArgumentParser(description="Generate MARTe + StreamHub cfgs")
p.add_argument("--scenario", required=True)
p.add_argument("--input", required=True, help="input_<id>.bin path")
p.add_argument("--marte-out", required=True)
p.add_argument("--hub-out", required=True)
p.add_argument("--tap", default=None)
args = p.parse_args()
sc = next((s for s in S.SCENARIOS if s["id"] == args.scenario), None)
if sc is None:
print(f"unknown scenario {args.scenario}", file=sys.stderr)
sys.exit(2)
write_marte_cfg(sc, args.marte_out, args.input, args.tap)
write_hub_cfg(sc, args.hub_out)
print(f"marte: {args.marte_out}")
print(f"hub: {args.hub_out}")
if __name__ == "__main__":
main()
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
gen_data.py — Deterministic typed/shaped data generator for the streaming-chain
E2E suite.
For a scenario (see scenarios.py) it writes a MARTe2 FileReader-compatible binary
``input_<id>.bin`` and returns a *ground-truth* dict the waveform validator uses
to reconstruct the expected stream without re-deriving the layout.
MARTe2 binary format
--------------------
[u32 numSigs]
per signal: [u16 TypeDescriptor.all][32B name (null-padded)][u32 numElements]
then NUM_ROWS rows, each row = all signals' elements concatenated, every value
little-endian at its native width.
Ground-truth dict schema (keyed "<src_id>:<sig_name>")
------------------------------------------------------
{
"t": np.ndarray[float64] # intended sample time (s), flattened
"v": np.ndarray # native-dtype values, flattened
"dt": float # per-sample spacing (s)
"formula": str
"freq": float | None
"elements": int
"rows": int
"type": str
"quant": str
"range_min": float | None
"range_max": float | None
"is_time": bool
}
Values are the *raw native* values fed to the FileReader. Wire-side quantisation
is performed by the UDPStreamer, not here — the validator applies the quant
tolerance using the recorded quant/range fields.
"""
import argparse
import os
import struct
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S # noqa: E402 (TYPE_CODES / NP_DTYPE / SCENARIOS)
# Buffer geometry lives in scenarios.py so the seamless-loop constraint
# (validate_scenario) and the data layout cannot drift apart.
NUM_ROWS = S.NUM_ROWS # producer cycles written to the FileReader input
ROW_DT = S.ROW_DT # seconds per producer cycle (row); 1 kHz producer
def _sample_dt(sig, row_dt=ROW_DT):
"""Per-element time spacing (s) for a signal."""
if sig["sampling_rate"]:
return 1.0 / sig["sampling_rate"]
e = sig["elements"]
return row_dt / e if e > 1 else row_dt
def _sample_times(sig, row_dt=ROW_DT, num_rows=NUM_ROWS):
"""Flattened intended sample times (s): num_rows*elements values."""
e = sig["elements"]
sdt = _sample_dt(sig, row_dt)
rows = np.arange(num_rows, dtype=np.float64).reshape(-1, 1) * row_dt
cols = np.arange(e, dtype=np.float64).reshape(1, -1) * sdt
return (rows + cols).reshape(-1)
def _values(sig, t):
"""Compute float64 values for flattened sample times ``t``."""
f = sig["formula"]
e = sig["elements"]
idx = np.arange(t.size, dtype=np.float64)
if f == "sine":
freq = sig["freq"] if sig["freq"] else 1.0
return np.sin(2.0 * np.pi * freq * t)
if f == "ramp":
# linear in the global element index, normalised to a modest range
return (idx % 1000.0)
if f == "counter":
return idx
if f == "time_ns":
return np.round(t * 1.0e9)
if f == "time_us":
return np.round(t * 1.0e6)
raise ValueError(f"unknown formula {f!r} for {sig['name']}")
def _native(sig, vals):
"""Cast float64 values to the signal's native numpy dtype."""
dt = S.NP_DTYPE[sig["type"]]
if sig["type"] in S.FLOAT_TYPES:
return vals.astype(dt)
# integer: round then cast (deterministic, no banker's rounding surprises)
return np.rint(vals).astype(dt)
def build_ground_truth(scenario):
"""Return {"<src>:<sig>": gt_dict} for every signal in the scenario."""
row_dt, num_rows, _ph, _lh = S.geometry(scenario)
gt = {}
for src in scenario["sources"]:
for sig in src["signals"]:
t = _sample_times(sig, row_dt, num_rows)
v = _native(sig, _values(sig, t))
gt[f"{src['id']}:{sig['name']}"] = {
"t": t, "v": v, "dt": _sample_dt(sig, row_dt),
"formula": sig["formula"], "freq": sig["freq"],
"elements": sig["elements"], "rows": num_rows,
"type": sig["type"], "quant": sig["quant"],
"range_min": sig["range_min"], "range_max": sig["range_max"],
"is_time": sig["is_time"],
}
return gt
def write_input(scenario, path):
"""Write the MARTe binary for *one* source and return its ground-truth dict.
The MARTe FileReader reads a single flat row layout, so one input file maps
to one source. Multi-source scenarios call this once per source with distinct
paths (the orchestrator handles the per-source filename); here we write the
first source by default but accept an explicit ``src`` via the scenario when
a single source is present.
"""
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
gt = build_ground_truth(scenario)
row_dt, num_rows, _ph, _lh = S.geometry(scenario)
# write each source to its own file: <path> for src[0], <path>.<srcid> else.
written = {}
for i, src in enumerate(scenario["sources"]):
p = path if i == 0 else f"{path}.{src['id']}"
_write_source_bin(src, p, row_dt, num_rows)
written[src["id"]] = p
gt["_files"] = written
return gt
def _write_source_bin(src, path, row_dt=ROW_DT, num_rows=NUM_ROWS):
sigs = src["signals"]
# per-signal native 2D arrays [num_rows, elements]
cols = {}
for sig in sigs:
t = _sample_times(sig, row_dt, num_rows)
v = _native(sig, _values(sig, t))
cols[sig["name"]] = v.reshape(num_rows, sig["elements"])
with open(path, "wb") as f:
f.write(struct.pack("<I", len(sigs)))
for sig in sigs:
f.write(struct.pack("<H", S.TYPE_CODES[sig["type"]]))
name = (sig["name"] + "\0").encode()
f.write((name + b"\0" * 32)[:32])
f.write(struct.pack("<I", sig["elements"]))
for r in range(num_rows):
for sig in sigs:
f.write(cols[sig["name"]][r].tobytes())
def main():
p = argparse.ArgumentParser(description="Generate E2E chain input data")
p.add_argument("--scenario", required=True, help="scenario id")
p.add_argument("--out", required=True, help="output input_<id>.bin path")
args = p.parse_args()
sc = next((s for s in S.SCENARIOS if s["id"] == args.scenario), None)
if sc is None:
print(f"unknown scenario {args.scenario}", file=sys.stderr)
sys.exit(2)
gt = write_input(sc, args.out)
for sid, fp in gt["_files"].items():
print(f"{sid}: {fp} ({os.path.getsize(fp)} bytes)")
if __name__ == "__main__":
main()
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
plots.py — Report figures for the streaming-chain E2E suite.
For a scenario it reads ``received_<id>.bin`` (RCV1), ``metrics_<id>.json`` and
``checks_<id>.json`` from the artifact dir and writes:
* ``wave_<id>.png`` — received waveform(s) vs analytic sinusoid fit (truth)
* ``trig_<id>.png`` — trigger signal with threshold + fired-trigger markers
* ``zoom_<id>.png`` — full received signal with the requested zoom spans shaded
Missing inputs are handled gracefully (a placeholder note is drawn) so the
orchestrator never aborts on a partial scenario.
"""
import argparse
import json
import os
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
import numpy as np # noqa: E402
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S # noqa: E402
import gen_data as G # noqa: E402
import validate_waveform as V # noqa: E402
def _placeholder(path, msg):
fig, ax = plt.subplots(figsize=(10, 3))
ax.axis("off")
ax.text(0.5, 0.5, msg, ha="center", va="center", fontsize=12)
fig.savefig(path, dpi=110, bbox_inches="tight")
plt.close(fig)
def _load(scenario, d):
sid = scenario["id"]
recv_p = os.path.join(d, f"received_{sid}.bin")
checks_p = os.path.join(d, f"checks_{sid}.json")
recv = V.read_received(recv_p) if os.path.exists(recv_p) else {}
checks = json.load(open(checks_p)) if os.path.exists(checks_p) else {}
return recv, checks
def _data_keys(recv, gt):
"""Non-time signal keys present in received, base-matched to ground truth."""
out = []
for k in sorted(recv):
base = k.split("[")[0]
g = gt.get(base)
if g is None or g.get("is_time"):
continue
out.append((k, base, g))
return out
def plot_wave(scenario, recv, gt, path):
keys = _data_keys(recv, gt)
if not keys:
_placeholder(path, f"{scenario['id']}: no received signal data")
return path
keys = keys[:4]
fig, axes = plt.subplots(len(keys), 1, figsize=(12, 3.0 * len(keys)),
squeeze=False)
for ax, (k, base, g) in zip(axes[:, 0], keys):
t, v = recv[k]
if t.size == 0:
ax.text(0.5, 0.5, f"{k}: empty", ha="center"); continue
t0 = t[0]
ax.plot(t - t0, v, ".", ms=3, label="received", color="tab:blue")
if g["formula"] == "sine" and g["freq"] and t.size >= 8:
corr, nrmse, amp = V.sine_shape(t, v, g["freq"])
w = 2 * np.pi * g["freq"]
A = np.column_stack([np.sin(w * t), np.cos(w * t), np.ones_like(t)])
coef, *_ = np.linalg.lstsq(A, v, rcond=None)
ts = np.linspace(t.min(), t.max(), 2000)
As = np.column_stack([np.sin(w * ts), np.cos(w * ts), np.ones_like(ts)])
ax.plot(ts - t0, As @ coef, "-", lw=1, color="tab:orange",
label=f"fit f={g['freq']}Hz corr={corr:.4f}")
ax.set_title(f"{k} ({g['type']}, quant={g['quant']}, {g['formula']})")
ax.set_xlabel("t t0 (s)")
ax.legend(loc="upper right", fontsize=8)
ax.grid(alpha=0.3)
fig.suptitle(f"{scenario['id']} — received waveform vs truth")
fig.tight_layout()
fig.savefig(path, dpi=120, bbox_inches="tight")
plt.close(fig)
return path
def plot_trigger(scenario, recv, checks, gt, path):
trig = checks.get("trigger", [])
if not trig:
_placeholder(path, f"{scenario['id']}: no trigger checks")
return path
key = trig[0].get("key", "")
if key not in recv or recv[key][0].size == 0:
_placeholder(path, f"{scenario['id']}: trigger signal {key} not recorded")
return path
t, v = recv[key]
t0 = t[0]
thr = float(np.mean(v))
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(t - t0, v, "-", lw=0.7, color="tab:blue", label=key)
ax.axhline(thr, color="gray", ls="--", lw=1, label=f"~threshold {thr:.3g}")
seen = set()
for tr in trig:
if not tr.get("fired"):
continue
tt = tr["trigTime"] - t0
lbl = f"{tr['edge']}/{tr['mode']}"
ax.axvline(tt, color="tab:red", lw=1, alpha=0.6,
label=("fired" if "fired" not in seen else None))
seen.add("fired")
ax.set_title(f"{scenario['id']} — trigger captures ({len(trig)} combos)")
ax.set_xlabel("t t0 (s)")
ax.legend(loc="upper right", fontsize=8)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(path, dpi=120, bbox_inches="tight")
plt.close(fig)
return path
def plot_zoom(scenario, recv, checks, gt, path):
zooms = checks.get("zoom", [])
keys = _data_keys(recv, gt)
if not keys:
_placeholder(path, f"{scenario['id']}: no received data for zoom")
return path
k, base, g = keys[0]
t, v = recv[k]
t0 = t[0]
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(t - t0, v, "-", lw=0.6, color="tab:blue", label=k)
for zc in zooms:
rg = zc.get("range", [0, 0])
ax.axvspan(rg[0] - t0, rg[1] - t0, alpha=0.15, color="tab:green")
ax.set_title(f"{scenario['id']} — zoom spans (n={len(zooms)})")
ax.set_xlabel("t t0 (s)")
ax.legend(loc="upper right", fontsize=8)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(path, dpi=120, bbox_inches="tight")
plt.close(fig)
return path
def main():
p = argparse.ArgumentParser(description="Generate E2E chain report plots")
p.add_argument("--scenario", required=True)
p.add_argument("--dir", required=True)
args = p.parse_args()
sc = next((s for s in S.SCENARIOS if s["id"] == args.scenario), None)
if sc is None:
print(f"unknown scenario {args.scenario}", file=sys.stderr)
sys.exit(2)
gt = G.build_ground_truth(sc)
recv, checks = _load(sc, args.dir)
w = plot_wave(sc, recv, gt, os.path.join(args.dir, f"wave_{sc['id']}.png"))
tr = plot_trigger(sc, recv, checks, gt,
os.path.join(args.dir, f"trig_{sc['id']}.png"))
z = plot_zoom(sc, recv, checks, gt,
os.path.join(args.dir, f"zoom_{sc['id']}.png"))
print(w)
print(tr)
print(z)
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
proc_perf.py — Snapshot a live process's CPU time and peak memory from /proc.
Usage: proc_perf.py <pid> <label> <out.json>
Reads /proc/<pid>/stat (utime+stime, in clock ticks) and /proc/<pid>/status
(VmHWM = peak resident set, VmRSS = current) *while the process is alive* — peak
RSS is only legible before the process exits, so the orchestrator calls this just
before tearing the stack down. Emits a small JSON record:
{"label": ..., "avail": true, "cpu_s": float, "peak_rss_kb": int,
"rss_kb": int, "threads": int}
If the process is already gone, writes {"label": ..., "avail": false}.
"""
import json
import os
import sys
def _clk_tck():
try:
return os.sysconf("SC_CLK_TCK") or 100
except (ValueError, OSError):
return 100
def snapshot(pid):
stat_p = f"/proc/{pid}/stat"
status_p = f"/proc/{pid}/status"
if not os.path.exists(stat_p):
return {"avail": False}
try:
with open(stat_p) as f:
raw = f.read()
# The comm field is parenthesised and may contain spaces/parens, so split
# on the final ')': everything after it is space-separated, with state as
# the first token. utime/stime are overall fields 14/15 → indices 11/12
# of the post-')' tokens (pid + comm consumed before the split).
after = raw[raw.rindex(")") + 1:].split()
utime = int(after[11])
stime = int(after[12])
cpu_s = (utime + stime) / float(_clk_tck())
rec: dict = {"avail": True, "cpu_s": cpu_s}
with open(status_p) as f:
for line in f:
if line.startswith("VmHWM:"):
rec["peak_rss_kb"] = int(line.split()[1])
elif line.startswith("VmRSS:"):
rec["rss_kb"] = int(line.split()[1])
elif line.startswith("Threads:"):
rec["threads"] = int(line.split()[1])
return rec
except (OSError, ValueError, IndexError):
return {"avail": False}
def main():
if len(sys.argv) != 4:
print("usage: proc_perf.py <pid> <label> <out.json>", file=sys.stderr)
sys.exit(2)
pid, label, out = sys.argv[1], sys.argv[2], sys.argv[3]
rec = snapshot(pid)
rec["label"] = label
with open(out, "w") as f:
json.dump(rec, f)
if rec.get("avail"):
print(f"perf {label}: cpu={rec.get('cpu_s', 0):.2f}s "
f"peakRSS={rec.get('peak_rss_kb', 0)/1024:.1f}MB")
else:
print(f"perf {label}: unavailable (process gone)")
if __name__ == "__main__":
main()
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
report_build.py — Consolidate the E2E run into report_data.json (+ trend plots).
Inputs (paths via flags):
* results.json — per-scenario status + waveform metrics (orchestrator)
* perf_<id>_*.json — per-scenario CPU/peak-RSS snapshots (proc_perf.py)
* unit_tests.json — GTest/Go/Python suite results (collect.py)
* coverage.json — per-language coverage (collect.py)
Outputs (into --out):
* report_data.json — everything the Typst template renders, including a
``regression`` block that diffs this run's headline metrics against the
previous entry in history.jsonl (progression ▲ / regression ▼).
* history.jsonl — appended one line of headline metrics per run.
* trend_*.png — pass-rate / coverage / fidelity / memory over runs.
Throughput is derived as recorded-samples / recording-duration. Memory is the
peak resident set (VmHWM). All inputs are optional: a missing artifact degrades
to nulls so a partial run still produces a report.
"""
import argparse
import datetime
import json
import os
import subprocess
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
REC_DUR_S = 4.0 # client -dur; samples/sec denominator
def _load(path, default=None):
if path and os.path.exists(path):
try:
return json.load(open(path))
except (ValueError, OSError):
return default
return default
def _git_sha(repo):
try:
return subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=repo,
capture_output=True, text=True, timeout=10).stdout.strip()
except (subprocess.SubprocessError, OSError):
return "unknown"
def _scenario_perf(work, sid):
out = {}
for role in ("hub", "marte"):
rec = _load(os.path.join(work, f"perf_{sid}_{role}.json"), {})
if rec and rec.get("avail"):
out[role] = {"cpu_s": round(rec.get("cpu_s", 0.0), 3),
"peak_rss_mb": round(rec.get("peak_rss_kb", 0) / 1024.0, 1),
"threads": rec.get("threads")}
return out
def _scenario_descs():
"""id -> human description, imported from the scenario matrix (best effort)."""
try:
from scenarios import SCENARIOS
return {s["id"]: s.get("desc") for s in SCENARIOS}
except Exception:
return {}
def build_e2e(results, work):
descs = _scenario_descs()
scen = []
corrs, rss_vals, cpu_vals, tput_vals = [], [], [], []
for r in results.get("scenarios", []):
sid = r["id"]
metrics = r.get("metrics", {})
sigs = []
nrecv_total = 0
for key, m in (metrics.get("signals", {}) or {}).items():
nrecv_total += int(m.get("n_recv", 0) or 0)
if "corr" in m:
corrs.append(m["corr"])
sigs.append({
"key": key, "pass": m.get("pass"),
"type": m.get("type"), "quant": m.get("quant"),
"max_abs_err": m.get("max_abs_err"),
"corr": m.get("corr"), "nrmse": m.get("nrmse"),
"fidelity_ok": m.get("fidelity_ok"), "shape_ok": m.get("shape_ok"),
"n_recv": m.get("n_recv"),
})
perf = _scenario_perf(work, sid)
for role in perf.values():
if role.get("peak_rss_mb"):
rss_vals.append(role["peak_rss_mb"])
if role.get("cpu_s"):
cpu_vals.append(role["cpu_s"])
tput = round(nrecv_total / REC_DUR_S, 1) if nrecv_total else 0.0
if tput:
tput_vals.append(tput)
client = metrics.get("client", {}) or {}
# waveform overview image (plots.py writes it into --work); record the
# basename only when present so the Typst template can embed it without
# tripping over a missing file (Typst read() throws on absence).
wave_img = f"wave_{sid}.png"
has_wave = os.path.exists(os.path.join(work, wave_img))
scen.append({
"id": sid, "status": r.get("status"),
"desc": descs.get(sid),
"known_issue": r.get("known_issue"),
"signals": sigs, "perf": perf, "throughput_sps": tput,
"live_frames": (client.get("live", {}) or {}).get("frames"),
"rollup": client.get("_rollup", {}),
# detailed client behavioural checks (chain-client checks_<id>.json),
# surfaced so the report can show real zoom ranges + trigger captures
# rather than only the pass/fail rollup booleans.
"zoom": client.get("zoom", []) or [],
"window": client.get("window", {}) or {},
"trigger": client.get("trigger", []) or [],
"wave_img": wave_img if has_wave else None,
})
agg = {
"mean_corr": round(sum(corrs) / len(corrs), 4) if corrs else None,
"mean_peak_rss_mb": round(sum(rss_vals) / len(rss_vals), 1) if rss_vals else None,
"mean_cpu_s": round(sum(cpu_vals) / len(cpu_vals), 3) if cpu_vals else None,
"mean_throughput_sps": round(sum(tput_vals) / len(tput_vals), 1) if tput_vals else None,
}
npass = sum(1 for s in scen if s["status"] == "PASS")
nfail = sum(1 for s in scen if s["status"] == "FAIL")
nskip = sum(1 for s in scen if s["status"] == "SKIP")
nxfail = sum(1 for s in scen if s["status"] == "XFAIL")
nxpass = sum(1 for s in scen if s["status"] == "XPASS")
return {
"overall": results.get("overall", "FAIL"),
"n_pass": npass, "n_fail": nfail, "n_skip": nskip,
"n_xfail": nxfail, "n_xpass": nxpass,
"scenarios": scen, "agg": agg,
}
def headline(e2e, ut, cov):
cov_by = {c["name"]: c.get("pct") for c in cov.get("languages", [])}
t = ut.get("totals", {})
return {
"e2e_pass": e2e["n_pass"], "e2e_fail": e2e["n_fail"],
"e2e_xfail": e2e.get("n_xfail", 0), "e2e_xpass": e2e.get("n_xpass", 0),
"e2e_total": (e2e["n_pass"] + e2e["n_fail"] + e2e["n_skip"]
+ e2e.get("n_xfail", 0) + e2e.get("n_xpass", 0)),
"unit_pass": t.get("passed", 0), "unit_fail": t.get("failed", 0),
"unit_total": t.get("total", 0),
"cov_python": cov_by.get("Python"), "cov_go": cov_by.get("Go"),
"cov_cpp": cov_by.get("C++"),
"mean_corr": e2e["agg"]["mean_corr"],
"mean_peak_rss_mb": e2e["agg"]["mean_peak_rss_mb"],
"mean_cpu_s": e2e["agg"]["mean_cpu_s"],
"mean_throughput_sps": e2e["agg"]["mean_throughput_sps"],
}
# field → "higher is better" (True), "lower is better" (False)
_DIRECTION = {
"e2e_pass": True, "e2e_fail": False, "unit_pass": True, "unit_fail": False,
"cov_python": True, "cov_go": True, "cov_cpp": True, "mean_corr": True,
"mean_peak_rss_mb": False, "mean_cpu_s": False, "mean_throughput_sps": True,
}
_LABELS = {
"e2e_pass": "E2E scenarios passed", "e2e_fail": "E2E scenarios failed",
"unit_pass": "Unit tests passed", "unit_fail": "Unit tests failed",
"cov_python": "Python coverage %", "cov_go": "Go coverage %",
"cov_cpp": "C++ coverage %", "mean_corr": "Mean sine corr",
"mean_peak_rss_mb": "Mean peak RSS (MB)", "mean_cpu_s": "Mean CPU (s)",
"mean_throughput_sps": "Mean throughput (samp/s)",
}
def regression(curr, prev):
rows = []
for k, label in _LABELS.items():
c = curr.get(k)
p = prev.get(k) if prev else None
better = None
delta = None
if isinstance(c, (int, float)) and isinstance(p, (int, float)):
delta = round(c - p, 4)
if delta == 0:
better = None
else:
better = (delta > 0) == _DIRECTION[k]
rows.append({"name": label, "key": k, "current": c, "previous": p,
"delta": delta, "better": better,
"higher_better": _DIRECTION[k]})
return rows
def trend_plots(history, out):
if not history:
return []
xs = list(range(len(history)))
labels = [h.get("ts_short", str(i)) for i, h in enumerate(history)]
made = []
def _plot(fname, series, title, ylabel):
ys = [[h.get(k) for h in history] for _, k in series]
if all(all(v is None for v in y) for y in ys):
return
fig, ax = plt.subplots(figsize=(7, 3))
for (lbl, _), y in zip(series, ys):
xp = [x for x, v in zip(xs, y) if v is not None]
yp = [v for v in y if v is not None]
if yp:
ax.plot(xp, yp, "o-", label=lbl)
ax.set_title(title)
ax.set_ylabel(ylabel)
ax.set_xticks(xs)
ax.set_xticklabels(labels, rotation=45, ha="right", fontsize=7)
ax.grid(alpha=0.3)
ax.legend(fontsize=8)
fig.tight_layout()
p = os.path.join(out, fname)
fig.savefig(p, dpi=110)
plt.close(fig)
made.append(p)
_plot("trend_tests.png",
[("E2E pass", "e2e_pass"), ("Unit pass", "unit_pass")],
"Passing tests over runs", "count")
_plot("trend_coverage.png",
[("Python", "cov_python"), ("Go", "cov_go"), ("C++", "cov_cpp")],
"Code coverage over runs", "% covered")
_plot("trend_fidelity.png", [("Mean sine corr", "mean_corr")],
"Waveform fidelity over runs", "correlation")
_plot("trend_perf.png",
[("Peak RSS (MB)", "mean_peak_rss_mb"), ("CPU (s)", "mean_cpu_s")],
"Resource use over runs", "value")
return made
def main():
ap = argparse.ArgumentParser(description="Build E2E report_data.json")
ap.add_argument("--repo", required=True)
ap.add_argument("--results", required=True)
ap.add_argument("--work", required=True)
ap.add_argument("--out", required=True)
args = ap.parse_args()
os.makedirs(args.out, exist_ok=True)
results = _load(args.results, {"overall": "FAIL", "scenarios": []})
ut = _load(os.path.join(args.out, "unit_tests.json"), {"suites": [], "totals": {}})
cov = _load(os.path.join(args.out, "coverage.json"), {"languages": []})
e2e = build_e2e(results, args.work)
now = datetime.datetime.now()
meta = {"timestamp": now.isoformat(timespec="seconds"),
"ts_short": now.strftime("%m-%d %H:%M"),
"git_sha": _git_sha(args.repo), "target": "x86-linux"}
hl = headline(e2e, ut, cov)
# history: read previous, then append current
hist_path = os.path.join(args.out, "history.jsonl")
history = []
if os.path.exists(hist_path):
for line in open(hist_path):
line = line.strip()
if line:
try:
history.append(json.loads(line))
except ValueError:
pass
prev = history[-1] if history else None
reg = regression(hl, prev)
entry = dict(hl)
entry["timestamp"] = meta["timestamp"]
entry["ts_short"] = meta["ts_short"]
entry["git_sha"] = meta["git_sha"]
entry["overall"] = e2e["overall"]
with open(hist_path, "a") as f:
f.write(json.dumps(entry) + "\n")
history.append(entry)
plots = [os.path.basename(p) for p in trend_plots(history, args.out)]
doc = {
"meta": meta, "e2e": e2e, "unit_tests": ut,
"coverage": cov, "regression": reg, "headline": hl,
"trend_plots": plots, "history_len": len(history),
"is_first_run": prev is None,
}
with open(os.path.join(args.out, "report_data.json"), "w") as f:
json.dump(doc, f, indent=2)
print(f"report_data.json: e2e {e2e['n_pass']}/{e2e['n_pass']+e2e['n_fail']+e2e['n_skip']}"
f" pass, units {hl['unit_pass']}/{hl['unit_total']}, "
f"cov py={hl['cov_python']} go={hl['cov_go']} cpp={hl['cov_cpp']}")
if prev:
ups = sum(1 for r in reg if r["better"] is True)
downs = sum(1 for r in reg if r["better"] is False)
print(f"regression vs previous run: {ups} improved, {downs} regressed")
else:
print("regression: first run (baseline established)")
if __name__ == "__main__":
main()
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env bash
# run_e2e.sh — Full-chain E2E orchestrator for the streaming chain
#
# MARTe2 app (FileReader -> IOGAM -> UDPStreamer)
# -> UDPS -> StreamHub -> chain-client (record + zoom/window/trigger)
# -> validate_waveform.py -> plots.py -> results.json [-> PDF]
#
# Per scenario (scenarios.py) it generates input data + both cfgs, runs the
# two-process stack, drives the mock client, validates the recorded waveform
# against the analytic/fed oracle, renders plots, and aggregates results.json.
# Artifacts: Build/x86-linux/E2E/chain/ (report) and /tmp/chain_e2e/ (scratch).
#
# Usage: ./run_e2e.sh [--skip-build] [--only <id>] [--pdf-only]
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
TARGET=x86-linux
BUILD_DIR="${REPO_ROOT}/Build/${TARGET}"
OUT_DIR="${BUILD_DIR}/E2E/chain"
WORK="/tmp/chain_e2e"
mkdir -p "${OUT_DIR}" "${WORK}"
SKIP_BUILD=0
ONLY=""
PDF_ONLY=0
CPP_COV=1
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1 ;;
--only) shift; ONLY="$1" ;;
--pdf-only) PDF_ONLY=1 ;;
--cpp-coverage) CPP_COV=1 ;;
--help|-h) echo "Usage: $0 [--skip-build] [--only <id>] [--pdf-only] [--cpp-coverage]"; exit 0 ;;
*) echo "unknown arg $1" >&2; exit 2 ;;
esac
shift
done
ENV_SCRIPT="${REPO_ROOT}/env.sh"
[ -f "${ENV_SCRIPT}" ] || { echo "ERROR: ${ENV_SCRIPT} not found" >&2; exit 1; }
: "${LD_LIBRARY_PATH:=}" # env.sh appends to it under our set -u
source "${ENV_SCRIPT}"
COMP="${MARTe2_Components_DIR}/Build/${TARGET}/Components"
export LD_LIBRARY_PATH="\
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
${BUILD_DIR}/Components/Interfaces/UDPStream:\
${MARTe2_DIR}/Build/${TARGET}/Core:\
${COMP}/DataSources/LinuxTimer:\
${COMP}/DataSources/FileDataSource:\
${COMP}/GAMs/IOGAM:\
${LD_LIBRARY_PATH:-}"
MARTE_APP="${MARTe2_DIR}/Build/${TARGET}/App/MARTeApp.ex"
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
CLIENT="${SCRIPT_DIR}/client/chain-client"
PY="python3"
SCEN() { ${PY} "${SCRIPT_DIR}/scenarios.py" >/dev/null 2>&1; }
# ── PDF-only shortcut (Task 9 fills in the compile) ──────────────────────────
if [ "${PDF_ONLY}" -eq 1 ]; then
if command -v typst >/dev/null 2>&1 && [ -f "${SCRIPT_DIR}/E2E_Report.typ" ]; then
cp "${SCRIPT_DIR}/E2E_Report.typ" "${OUT_DIR}/" 2>/dev/null || true
(cd "${OUT_DIR}" && typst compile E2E_Report.typ E2E_Report.pdf) \
&& echo "PDF: ${OUT_DIR}/E2E_Report.pdf"
else
echo "typst or E2E_Report.typ missing — skipping PDF"
fi
exit 0
fi
# ── Build ────────────────────────────────────────────────────────────────────
if [ "${SKIP_BUILD}" -eq 0 ]; then
echo "── Building components ──"
make -C "${REPO_ROOT}/Source/Components/Interfaces/UDPStream" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamer" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
make -C "${REPO_ROOT}/Source/Applications/StreamHub" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
fi
if [ ! -x "${CLIENT}" ]; then
echo "── Building chain-client ──"
(cd "${SCRIPT_DIR}/client" && go build -o chain-client .) || { echo "client build failed"; exit 1; }
fi
[ -x "${MARTE_APP}" ] || { echo "ERROR: MARTeApp.ex not found at ${MARTE_APP}" >&2; exit 1; }
[ -x "${STREAMHUB_EX}" ] || { echo "ERROR: StreamHub.ex not found" >&2; exit 1; }
# ── Scenario list (id|ws_port|udp_port0|network|oracle|trig|checks) ──────────
LIST="$(${PY} - "${ONLY}" <<'PY'
import sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath("Test/E2E/suite/scenarios.py")))
sys.path.insert(0, os.path.join(os.getcwd(), "Test/E2E/suite"))
import scenarios as S
only = sys.argv[1] if len(sys.argv) > 1 else ""
for s in S.SCENARIOS:
if only and s["id"] != only:
continue
trig = s.get("trig_signal") or ""
checks = ",".join(s.get("client_checks", []))
if not trig:
checks = ",".join(c for c in s.get("client_checks", []) if c != "trigger")
print("|".join([s["id"], str(s["ws_port"]), str(s["sources"][0]["udp_port"]),
s["network"], s["oracle"], trig, checks]))
PY
)"
if [ -z "${LIST}" ]; then echo "no scenarios selected"; exit 1; fi
SCEN_IDS=""
HUB_PID=""; APP_PID=""
cleanup() {
[ -n "${APP_PID}" ] && kill "${APP_PID}" 2>/dev/null
[ -n "${HUB_PID}" ] && kill "${HUB_PID}" 2>/dev/null
wait "${APP_PID}" 2>/dev/null; wait "${HUB_PID}" 2>/dev/null
APP_PID=""; HUB_PID=""
}
trap cleanup EXIT
while IFS='|' read -r ID WSPORT UDPPORT NET ORACLE TRIG CHECKS; do
[ -z "${ID}" ] && continue
SCEN_IDS="${SCEN_IDS} ${ID}"
echo ""
echo "══ scenario ${ID} (net=${NET} oracle=${ORACLE} ws=${WSPORT}) ══"
: > "${WORK}/status_${ID}.txt"
# multicast route probe
if [ "${NET}" = "multicast" ]; then
GRP="$(${PY} -c "import sys;sys.path.insert(0,'${SCRIPT_DIR}');import scenarios as S;print(next(s for s in S.SCENARIOS if s['id']=='${ID}')['sources'][0]['multicast_group'])")"
if ! ip route get "${GRP}" >/dev/null 2>&1; then
echo " SKIP: no multicast route to ${GRP}"
echo "SKIP" > "${WORK}/status_${ID}.txt"
continue
fi
fi
INPUT="${WORK}/input_${ID}.bin"
MCFG="${WORK}/m_${ID}.cfg"
HCFG="${WORK}/h_${ID}.cfg"
TAP=""
if [ "${ORACLE}" = "fed" ] || [ "${ORACLE}" = "both" ]; then
TAP="${WORK}/tap_${ID}.bin"
fi
${PY} "${SCRIPT_DIR}/gen_data.py" --scenario "${ID}" --out "${INPUT}" || { echo FAIL > "${WORK}/status_${ID}.txt"; continue; }
if [ -n "${TAP}" ]; then
${PY} "${SCRIPT_DIR}/gen_cfg.py" --scenario "${ID}" --input "${INPUT}" --marte-out "${MCFG}" --hub-out "${HCFG}" --tap "${TAP}"
else
${PY} "${SCRIPT_DIR}/gen_cfg.py" --scenario "${ID}" --input "${INPUT}" --marte-out "${MCFG}" --hub-out "${HCFG}"
fi
HUB_LOG="${OUT_DIR}/hub_${ID}.log"
APP_LOG="${OUT_DIR}/marte_${ID}.log"
rm -f "${WORK}/received_${ID}.bin" "${WORK}/checks_${ID}.json" "${WORK}/metrics_${ID}.json"
"${STREAMHUB_EX}" -cfg "${HCFG}" > "${HUB_LOG}" 2>&1 &
HUB_PID=$!
sleep 1
timeout 120 "${MARTE_APP}" -l RealTimeLoader -f "${MCFG}" -s Running > "${APP_LOG}" 2>&1 &
APP_PID=$!
sleep 1
# APP_PID is the `timeout` wrapper; perf must target the real MARTeApp child.
APP_PERF_PID="$(pgrep -P "${APP_PID}" 2>/dev/null | head -1)"
[ -z "${APP_PERF_PID}" ] && APP_PERF_PID="${APP_PID}"
TRIGARG=""
[ -n "${TRIG}" ] && TRIGARG="-trigsig ${TRIG}"
if "${CLIENT}" -hub "127.0.0.1:${WSPORT}" -scenario "${ID}" ${TRIGARG} \
-checks "${CHECKS}" -out "${WORK}" -dur 4 > "${OUT_DIR}/client_${ID}.log" 2>&1; then
echo " client OK"
else
echo " client FAILED (see client_${ID}.log)"
tail -3 "${OUT_DIR}/client_${ID}.log" | sed 's/^/ /'
fi
# Snapshot CPU/peak-RSS while the stack is still alive (peak RSS is only
# legible before exit), then tear it down.
[ -n "${HUB_PID}" ] && ${PY} "${SCRIPT_DIR}/proc_perf.py" "${HUB_PID}" streamhub "${WORK}/perf_${ID}_hub.json" || true
[ -n "${APP_PERF_PID}" ] && ${PY} "${SCRIPT_DIR}/proc_perf.py" "${APP_PERF_PID}" marte "${WORK}/perf_${ID}_marte.json" || true
cleanup
# validate + plot
VARGS="--scenario ${ID} --received ${WORK}/received_${ID}.bin --checks ${WORK}/checks_${ID}.json --out ${WORK}/metrics_${ID}.json"
[ -n "${TAP}" ] && [ -f "${TAP}" ] && VARGS="${VARGS} --tap ${TAP}"
if ${PY} "${SCRIPT_DIR}/validate_waveform.py" ${VARGS}; then
echo "PASS" > "${WORK}/status_${ID}.txt"
else
echo "FAIL" > "${WORK}/status_${ID}.txt"
fi
${PY} "${SCRIPT_DIR}/plots.py" --scenario "${ID}" --dir "${WORK}" >/dev/null 2>&1 || true
done <<< "${LIST}"
trap - EXIT
cleanup
# ── Aggregate results.json ───────────────────────────────────────────────────
# Scenarios carrying a `known_issue` marker exercise a documented, not-yet-fixed
# chain gap: a raw FAIL is reclassified XFAIL (expected failure — does not break
# the green baseline) and a raw PASS becomes XPASS (the bug was unexpectedly
# fixed; the marker should be dropped). Overall is PASS when nothing FAILs and
# nothing unexpectedly XPASSes.
WORK="${WORK}" OUT_DIR="${OUT_DIR}" SCEN_IDS="${SCEN_IDS}" \
SCRIPT_DIR="${SCRIPT_DIR}" ${PY} - <<'PY'
import json, os, sys
work = os.environ["WORK"]; out = os.environ["OUT_DIR"]
ids = os.environ["SCEN_IDS"].split()
sys.path.insert(0, os.environ["SCRIPT_DIR"])
try:
from scenarios import SCENARIOS
known = {s["id"]: s.get("known_issue") for s in SCENARIOS}
except Exception:
known = {}
results = []
for sid in ids:
rec = {"id": sid}
st = os.path.join(work, f"status_{sid}.txt")
raw = open(st).read().strip() if os.path.exists(st) else "UNKNOWN"
ki = known.get(sid)
if ki:
rec["known_issue"] = ki
if raw == "FAIL":
raw = "XFAIL" # expected failure — documented chain gap
elif raw == "PASS":
raw = "XPASS" # unexpectedly fixed — drop the marker
rec["status"] = raw
mp = os.path.join(work, f"metrics_{sid}.json")
if os.path.exists(mp):
rec["metrics"] = json.load(open(mp))
results.append(rec)
# Green when no hard FAIL and no XPASS (an XPASS means a known_issue marker is
# now stale and must be removed — surfaced as a failure to force the cleanup).
overall = (bool(results)
and all(r["status"] in ("PASS", "SKIP", "XFAIL") for r in results))
doc = {"overall": "PASS" if overall else "FAIL", "scenarios": results}
with open(os.path.join(out, "results.json"), "w") as f:
json.dump(doc, f, indent=2)
c = lambda st: sum(r["status"] == st for r in results)
print(f"\nresults.json: {c('PASS')} pass, {c('FAIL')} fail, {c('SKIP')} skip, "
f"{c('XFAIL')} xfail, {c('XPASS')} xpass → {doc['overall']}")
PY
# ── Unit tests + coverage ────────────────────────────────────────────────────
echo ""
echo "── Unit tests + coverage ──"
# Optional C++ line coverage: rebuild the project's libraries + GTest with gcov
# instrumentation in place (OPTIM/LFLAGS are the MARTe2-sanctioned override
# hooks), let collect.py run the instrumented GTest (emits .gcda) and lcov, then
# restore a clean build so later --skip-build runs aren't left instrumented.
CPP_COV_FLAG=""
if [ "${CPP_COV}" -eq 1 ]; then
echo " building instrumented (gcov) libraries + GTest ..."
COV_O="--coverage"
COV_L="-Wl,--no-as-needed -fPIC --coverage"
make -C "${REPO_ROOT}" -f Makefile.gcc clean >/dev/null 2>&1 || true
make -C "${REPO_ROOT}" -f Makefile.gcc core TARGET="${TARGET}" \
OPTIM="${COV_O}" LFLAGS="${COV_L}" 2>&1 | tail -1
for d in Test/Components/DataSources/UDPStreamer Test/Components/DataSources/UDPStreamerClient Test/Applications/StreamHub Test/GTest Test/Integration; do
make -C "${REPO_ROOT}/${d}" -f Makefile.gcc TARGET="${TARGET}" \
OPTIM="${COV_O}" LFLAGS="${COV_L}" 2>&1 | tail -1
done
CPP_COV_FLAG="--cpp-coverage"
fi
${PY} "${SCRIPT_DIR}/collect.py" --repo "${REPO_ROOT}" --target "${TARGET}" \
--out "${OUT_DIR}" --work "${WORK}" ${CPP_COV_FLAG} || true
if [ "${CPP_COV}" -eq 1 ]; then
echo " restoring non-instrumented build ..."
make -C "${REPO_ROOT}" -f Makefile.gcc clean >/dev/null 2>&1 || true
make -C "${REPO_ROOT}" -f Makefile.gcc core apps TARGET="${TARGET}" 2>&1 | tail -1
(cd "${SCRIPT_DIR}/client" && go build -o chain-client . >/dev/null 2>&1) || true
fi
# ── Consolidated report data (+ history/regression + trend plots) ────────────
${PY} "${SCRIPT_DIR}/report_build.py" --repo "${REPO_ROOT}" \
--results "${OUT_DIR}/results.json" --work "${WORK}" --out "${OUT_DIR}" || true
# ── PDF ──────────────────────────────────────────────────────────────────────
if command -v typst >/dev/null 2>&1 && [ -f "${SCRIPT_DIR}/E2E_Report.typ" ]; then
cp "${SCRIPT_DIR}/E2E_Report.typ" "${OUT_DIR}/" 2>/dev/null || true
# Embedded waveform overviews live in WORK; the template references them by
# bare name, so stage them next to report_data.json before compiling.
cp "${WORK}"/wave_*.png "${OUT_DIR}/" 2>/dev/null || true
if (cd "${OUT_DIR}" && typst compile E2E_Report.typ E2E_Report.pdf); then
echo "PDF: ${OUT_DIR}/E2E_Report.pdf"
else
echo "typst compile failed (report_data.json present at ${OUT_DIR})"
fi
fi
echo "Done — artifacts in ${OUT_DIR} and ${WORK}"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# run_stress.sh — Capacity/stress harness for the streaming chain.
#
# MARTe2 app (FileReader -> IOGAM -> UDPStreamer)
# -> UDPS -> StreamHub(s) -> chain-client(s) (stress: liveness + zoom latency)
# -> proc_perf (cpu/peakRSS) -> per-case gate -> stress_results.json
#
# It sweeps one load axis at a time (signal size/count, subscriber fan-out, source
# count, WS-client count, zoom request rate — see stress.py) and gates each case on
# survival + liveness (hard) and RSS + zoom-p95 latency (soft). This is the
# capacity sibling of run_e2e.sh (which gates waveform correctness).
#
# Usage: ./run_stress.sh [--skip-build] [--only <id>] [--axis <axis>]
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
TARGET=x86-linux
BUILD_DIR="${REPO_ROOT}/Build/${TARGET}"
OUT_DIR="${BUILD_DIR}/E2E/chain/stress"
WORK="/tmp/chain_stress"
mkdir -p "${OUT_DIR}" "${WORK}"
SKIP_BUILD=0
ONLY=""
AXIS=""
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1 ;;
--only) shift; ONLY="$1" ;;
--axis) shift; AXIS="$1" ;;
--help|-h) echo "Usage: $0 [--skip-build] [--only <id>] [--axis <axis>]"; exit 0 ;;
*) echo "unknown arg $1" >&2; exit 2 ;;
esac
shift
done
ENV_SCRIPT="${REPO_ROOT}/env.sh"
[ -f "${ENV_SCRIPT}" ] || { echo "ERROR: ${ENV_SCRIPT} not found" >&2; exit 1; }
: "${LD_LIBRARY_PATH:=}"
source "${ENV_SCRIPT}"
COMP="${MARTe2_Components_DIR}/Build/${TARGET}/Components"
export LD_LIBRARY_PATH="\
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
${BUILD_DIR}/Components/Interfaces/UDPStream:\
${MARTe2_DIR}/Build/${TARGET}/Core:\
${COMP}/DataSources/LinuxTimer:\
${COMP}/DataSources/FileDataSource:\
${COMP}/GAMs/IOGAM:\
${LD_LIBRARY_PATH:-}"
MARTE_APP="${MARTe2_DIR}/Build/${TARGET}/App/MARTeApp.ex"
STREAMHUB_EX="${BUILD_DIR}/StreamHub/StreamHub.ex"
CLIENT="${SCRIPT_DIR}/client/chain-client"
PY="python3"
# ── Build ────────────────────────────────────────────────────────────────────
if [ "${SKIP_BUILD}" -eq 0 ]; then
echo "── Building components ──"
make -C "${REPO_ROOT}/Source/Components/Interfaces/UDPStream" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamer" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
make -C "${REPO_ROOT}/Source/Applications/StreamHub" -f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -1
fi
if [ ! -x "${CLIENT}" ] || [ "${SKIP_BUILD}" -eq 0 ]; then
echo "── Building chain-client ──"
(cd "${SCRIPT_DIR}/client" && go build -o chain-client .) || { echo "client build failed"; exit 1; }
fi
[ -x "${MARTE_APP}" ] || { echo "ERROR: MARTeApp.ex not found at ${MARTE_APP}" >&2; exit 1; }
[ -x "${STREAMHUB_EX}" ] || { echo "ERROR: StreamHub.ex not found" >&2; exit 1; }
# ── Validate the matrix, then run ────────────────────────────────────────────
${PY} "${SCRIPT_DIR}/stress.py" >/dev/null || { echo "stress matrix invalid"; exit 1; }
ARGS=(--marte "${MARTE_APP}" --hub "${STREAMHUB_EX}" --client "${CLIENT}"
--work "${WORK}" --out "${OUT_DIR}")
[ -n "${ONLY}" ] && ARGS+=(--only "${ONLY}")
[ -n "${AXIS}" ] && ARGS+=(--axis "${AXIS}")
${PY} "${SCRIPT_DIR}/stress_run.py" "${ARGS[@]}"
RC=$?
echo ""
echo "Done — results in ${OUT_DIR}/stress_results.json, logs in ${OUT_DIR}"
exit ${RC}
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""
scenarios.py — Declarative scenario matrix for the streaming-chain E2E suite.
Each scenario describes one full-chain run:
MARTe2 App (UDPStreamer) -> StreamHub -> mock client
The matrix is a *curated covering set*: every configurable option value of the
UDPStreamer appears in at least one scenario, plus deliberately chosen
high-risk interactions. gen_data.py / gen_cfg.py / client / validate_waveform.py
all consume the scenario dict schema documented below.
Scenario dict schema
--------------------
{
"id": str # unique slug, also the artifact basename
"desc": str # human-readable one-liner for the report
"network": "unicast" | "multicast"
"publishing": "Strict" | "Accumulate" | "Decimate"
"ratio": int | None # Decimate only (>=1)
"min_refresh_hz": float | None # Accumulate only (>0)
"max_payload": int # MaxPayloadSize bytes
"ws_port": int # StreamHub WSPort (unique per scenario)
"sources": [ # one or more UDPStreamer blocks
{
"id": str # source/session id (hub Source key)
"udp_port": int # UDPStreamer Port (unique per scenario)
"data_port": int | None # multicast DATA port
"multicast_group": str | None # e.g. "239.0.0.1"
"signals": [
{
"name": str
"type": one of TYPE_CODES keys
"elements": int # NumberOfElements (1 = scalar)
"time_mode": "PacketTime"|"FullArray"|"FirstSample"|"LastSample"
"time_signal": str | None # name of the TimeSignal (when needed)
"sampling_rate": float | None # Hz, for First/LastSample
"quant": "none"|"uint8"|"int8"|"uint16"|"int16"
"range_min": float | None # quant only
"range_max": float | None # quant only
"unit": str | None # e.g. "V", "us", "ns"
"formula": "sine"|"ramp"|"counter"|"time_ns"|"time_us"
"freq": float | None # sine frequency (Hz over row index)
"is_time": bool # True if this signal is a TimeSignal
}, ...
]
}, ...
],
"oracle": "analytic" | "fed" | "both"
"client_checks": subset of ["live","zoom","window","trigger"]
"trig_signal": "src:sig" | None # which signal to trigger on
"known_issue": str | None # if set, the scenario exercises a
# documented, not-yet-fixed chain gap:
# a FAIL is reclassified XFAIL (does not
# break the baseline) and a PASS becomes
# XPASS (the bug is unexpectedly fixed —
# time to drop the marker). The string is
# the human-readable reason.
}
"""
# MARTe2 TypeDescriptor.all codes: all = (type<<2) | (bits<<6),
# BasicType SignedInteger=0, UnsignedInteger=1, Float=2 (verified against
# MARTe2 L0Types/BasicType.h and L1Portability/TypeDescriptor.h).
TYPE_CODES = {
"int8": 0x0200,
"uint8": 0x0204,
"int16": 0x0400,
"uint16": 0x0404,
"int32": 0x0800,
"uint32": 0x0804,
"int64": 0x1000,
"uint64": 0x1004,
"float32": 0x0808,
"float64": 0x1008,
}
# numpy dtype per MARTe type (little-endian).
NP_DTYPE = {
"int8": "<i1", "uint8": "<u1", "int16": "<i2", "uint16": "<u2",
"int32": "<i4", "uint32": "<u4", "int64": "<i8", "uint64": "<u8",
"float32": "<f4", "float64": "<f8",
}
# Wire constraints shared with the C++ UDPS layer. The UDPStreamer fragments the
# serialised packet into chunks of at most MaxPayloadSize *payload* bytes and
# prepends a UDPS_HEADER_SIZE-byte header, so the datagram it hands to sendto() is
# MaxPayloadSize+UDPS_HEADER_SIZE bytes. That must not exceed the maximum UDP
# payload or sendto() fails with EMSGSIZE.
UDPS_HEADER_SIZE = 17 # bytes; mirrors Common/UDP/UDPSProtocol.h
MAX_UDP_PAYLOAD = 65507 # 65535 - 20 (IP) - 8 (UDP)
FLOAT_TYPES = {"float32", "float64"}
QUANT_TYPES = {"none", "uint8", "int8", "uint16", "int16"}
QUANT_LEVELS = {"uint8": 255, "int8": 254, "uint16": 65535, "int16": 65534}
TIME_MODES = {"PacketTime", "FullArray", "FirstSample", "LastSample"}
# Producer buffer geometry — the single source of truth shared with gen_data.py.
# The MARTe FileReader loops this finite buffer (EOF=Rewind), so the streamed
# signal is only a continuous waveform if the buffer holds an integer number of
# periods. The buffer fundamental LOOP_HZ = 1/(NUM_ROWS*ROW_DT) is therefore the
# smallest sine frequency that loops seamlessly; every sine freq must be a
# positive integer multiple of it or the analytic shape oracle is invalid.
NUM_ROWS = 200 # producer cycles written to the FileReader input
ROW_DT = 1.0e-3 # seconds per producer cycle (row); 1 kHz producer
LOOP_HZ = 1.0 / (NUM_ROWS * ROW_DT) # 5.0 Hz buffer fundamental
def geometry(s):
"""Resolve a scenario's producer geometry (per-scenario override or global).
Returns (row_dt, num_rows, producer_hz, loop_hz). Every data/config/validator
consumer reads geometry through here so the override cannot drift apart from
the seamless-loop fundamental used by the sine constraint.
"""
row_dt = s.get("row_dt") or ROW_DT
num_rows = s.get("num_rows") or NUM_ROWS
producer_hz = s.get("producer_hz") or int(round(1.0 / row_dt))
loop_hz = 1.0 / (num_rows * row_dt)
return row_dt, num_rows, producer_hz, loop_hz
def _sig(name, type, elements=1, time_mode="PacketTime", time_signal=None,
sampling_rate=None, quant="none", range_min=None, range_max=None,
unit=None, formula="sine", freq=1.0, is_time=False):
return {
"name": name, "type": type, "elements": elements,
"time_mode": time_mode, "time_signal": time_signal,
"sampling_rate": sampling_rate, "quant": quant,
"range_min": range_min, "range_max": range_max, "unit": unit,
"formula": formula, "freq": freq, "is_time": is_time,
}
def validate_scenario(s):
"""Return a list of validity error strings (empty == valid)."""
errs = []
row_dt, num_rows, _producer_hz, loop_hz = geometry(s)
if s.get("network") not in ("unicast", "multicast"):
errs.append("network must be unicast|multicast")
if s.get("publishing") not in ("Strict", "Accumulate", "Decimate"):
errs.append("publishing invalid")
if s["publishing"] == "Decimate" and not (s.get("ratio") and s["ratio"] >= 1):
errs.append("Decimate needs ratio>=1")
if s["publishing"] == "Accumulate" and not (s.get("min_refresh_hz") and
s["min_refresh_hz"] > 0):
errs.append("Accumulate needs min_refresh_hz>0")
if not s.get("sources"):
errs.append("no sources")
max_payload = s.get("max_payload")
if max_payload is not None and max_payload + UDPS_HEADER_SIZE > MAX_UDP_PAYLOAD:
errs.append(
f"max_payload {max_payload} + {UDPS_HEADER_SIZE}B header exceeds the "
f"{MAX_UDP_PAYLOAD}B UDP datagram limit (sendto EMSGSIZE); "
f"cap at {MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE}")
for src in s.get("sources", []):
if s["network"] == "multicast":
if not src.get("multicast_group") or not src.get("data_port"):
errs.append(f"src {src['id']}: multicast needs group+data_port")
names = {sig["name"] for sig in src["signals"]}
for sig in src["signals"]:
if sig["type"] not in TYPE_CODES:
errs.append(f"{sig['name']}: bad type {sig['type']}")
if sig["quant"] not in QUANT_TYPES:
errs.append(f"{sig['name']}: bad quant {sig['quant']}")
if sig["quant"] != "none":
if sig["type"] not in FLOAT_TYPES:
errs.append(f"{sig['name']}: quant only on float types")
if sig["range_min"] is None or sig["range_max"] is None:
errs.append(f"{sig['name']}: quant needs range_min/max")
if sig["time_mode"] not in TIME_MODES:
errs.append(f"{sig['name']}: bad time_mode")
if sig["time_mode"] != "PacketTime":
if not sig["time_signal"] or sig["time_signal"] not in names:
errs.append(f"{sig['name']}: time_mode needs valid time_signal")
if sig["time_mode"] == "FullArray":
ts = next((x for x in src["signals"]
if x["name"] == sig["time_signal"]), None)
if ts and ts["elements"] != sig["elements"]:
errs.append(f"{sig['name']}: FullArray time_signal length mismatch")
if sig["time_mode"] in ("FirstSample", "LastSample"):
if not sig["sampling_rate"] or sig["sampling_rate"] <= 0:
errs.append(f"{sig['name']}: First/LastSample needs sampling_rate>0")
else:
# The per-packet sample window must fit inside one producer
# cycle, or successive packets' expanded timestamps overlap
# and the hub's binary-search ring (which assumes a sorted
# time axis) is corrupted — the s17/s18 failure class.
window = (sig["elements"] - 1) / sig["sampling_rate"]
if window > row_dt + 1e-12:
errs.append(
f"{sig['name']}: {sig['time_mode']} window "
f"{window * 1e3:.4f} ms > row period {row_dt * 1e3:.4f} ms "
f"(non-monotonic ring); raise producer rate or "
f"sampling_rate, or lower elements")
if sig["formula"] == "sine" and sig.get("freq"):
ratio = sig["freq"] / loop_hz
if abs(ratio - round(ratio)) > 1e-9 or round(ratio) < 1:
errs.append(f"{sig['name']}: sine freq {sig['freq']} must be a "
f"positive multiple of LOOP_HZ={loop_hz} (seamless loop)")
return errs
# ── Curated covering matrix ───────────────────────────────────────────────────
# The first three scenarios are referenced positionally by tests_py.py
# (SCENARIOS[0..2]) and are kept verbatim. The remainder is built with the helpers
# below, which auto-allocate unique WS/UDP/DATA ports so the matrix can grow
# without manual bookkeeping. Coverage goal: every UDPStreamer option *value*
# (all 10 types, scalar+array shapes, all four TimeModes, all five quant kinds,
# the three publishing modes, unicast+multicast, fragmentation via small payload,
# multi-source) appears at least once, plus deliberately chosen high-risk
# interactions (decimate+quant+array, accumulate+fullarray, multicast+decimate,
# fragmentation+decimate). Non-sine formulas (counter/ramp) are preferred for
# pure type/shape coverage because only the fidelity oracle gates them; sine is
# used where the shape metric should be tracked. MCAST_GROUP must have a route on
# the test host or those scenarios report SKIP (the orchestrator probes it).
import itertools
MCAST_GROUP = "239.0.7.7"
_ws = itertools.count(8104)
_udp = itertools.count(44616, 2)
_data = itertools.count(45616, 2)
def _src(sid, signals, multicast=False):
return {
"id": sid, "udp_port": next(_udp),
"data_port": next(_data) if multicast else None,
"multicast_group": MCAST_GROUP if multicast else None,
"signals": signals,
}
def mk(sid, desc, sources, network="unicast", publishing="Strict",
ratio=None, min_refresh_hz=None, max_payload=1400,
oracle="analytic", checks=("live", "zoom"), trig=None,
known_issue=None, row_dt=None, num_rows=None, producer_hz=None):
# row_dt / num_rows / producer_hz override the global producer geometry for
# this scenario only (default None == use the suite-wide NUM_ROWS / ROW_DT /
# 1 kHz). They are kept together so the per-cycle wall gap (1/producer_hz),
# the encoded time-step (row_dt) and the seamless-loop fundamental
# (1/(num_rows*row_dt)) stay mutually consistent.
return {
"id": sid, "desc": desc, "network": network, "publishing": publishing,
"ratio": ratio, "min_refresh_hz": min_refresh_hz,
"max_payload": max_payload, "ws_port": next(_ws),
"sources": sources, "oracle": oracle,
"client_checks": list(checks), "trig_signal": trig,
"known_issue": known_issue,
"row_dt": row_dt, "num_rows": num_rows, "producer_hz": producer_hz,
}
_STARTERS = [
{
"id": "s01_scalar_uint32",
"desc": "Single uint32 scalar counter, Strict unicast (type fidelity)",
"network": "unicast", "publishing": "Strict",
"ratio": None, "min_refresh_hz": None, "max_payload": 1400,
"ws_port": 8101,
"sources": [{
"id": "src", "udp_port": 44610, "data_port": None,
"multicast_group": None,
"signals": [
_sig("Counter", "uint32", 1, formula="counter"),
_sig("Sine", "float32", 1, formula="sine", freq=5.0, unit="V"),
],
}],
"oracle": "analytic",
"client_checks": ["live", "zoom", "window", "trigger"],
"trig_signal": "src:Sine",
},
{
"id": "s02_array_float32_fullarray",
"desc": "100-elem float32 array, FullArray time mode, uint64 ns time array",
"network": "unicast", "publishing": "Strict",
"ratio": None, "min_refresh_hz": None,
"max_payload": MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE,
"ws_port": 8102,
"sources": [{
"id": "src", "udp_port": 44612, "data_port": None,
"multicast_group": None,
"signals": [
_sig("TimeArr", "uint64", 100, unit="ns",
formula="time_ns", is_time=True),
_sig("Wave", "float32", 100, time_mode="FullArray",
time_signal="TimeArr", formula="sine", freq=5.0, unit="V"),
],
}],
"oracle": "both",
"client_checks": ["live", "zoom"],
"trig_signal": None,
},
{
"id": "s03_quant_uint16",
"desc": "float32 scalar quantised to uint16 over [-5,5], Strict unicast",
"network": "unicast", "publishing": "Strict",
"ratio": None, "min_refresh_hz": None, "max_payload": 1400,
"ws_port": 8103,
"sources": [{
"id": "src", "udp_port": 44614, "data_port": None,
"multicast_group": None,
"signals": [
_sig("Sine", "float32", 1, quant="uint16",
range_min=-5.0, range_max=5.0, formula="sine",
freq=10.0, unit="V"),
],
}],
"oracle": "analytic",
"client_checks": ["live", "zoom"],
"trig_signal": "src:Sine",
},
]
# ── Type fidelity: one scalar per remaining MARTe type (fidelity-only) ────────
_TYPES = [
mk("s04_int8_scalar", "int8 scalar counter, type fidelity",
[_src("src", [_sig("Cnt", "int8", 1, formula="counter")])]),
mk("s05_uint8_scalar", "uint8 scalar counter, type fidelity",
[_src("src", [_sig("Cnt", "uint8", 1, formula="counter")])]),
mk("s06_int16_scalar", "int16 scalar ramp, type fidelity",
[_src("src", [_sig("Ramp", "int16", 1, formula="ramp")])]),
mk("s07_uint16_scalar", "uint16 scalar ramp, type fidelity",
[_src("src", [_sig("Ramp", "uint16", 1, formula="ramp")])]),
mk("s08_int32_scalar", "int32 scalar counter, type fidelity",
[_src("src", [_sig("Cnt", "int32", 1, formula="counter")])]),
mk("s09_int64_scalar", "int64 scalar counter, type fidelity",
[_src("src", [_sig("Cnt", "int64", 1, formula="counter")])]),
mk("s10_uint64_scalar", "uint64 scalar counter, type fidelity",
[_src("src", [_sig("Cnt", "uint64", 1, formula="counter")])]),
mk("s11_float64_scalar", "float64 scalar sine 5 Hz (double-precision path)",
[_src("src", [_sig("Sine", "float64", 1, formula="sine", freq=5.0,
unit="V")])],
checks=("live", "zoom", "trigger"), trig="src:Sine"),
]
# ── Array shapes (NumberOfElements) ──────────────────────────────────────────
_ARRAYS = [
mk("s12_f32_arr8", "float32 8-elem array sine 5 Hz",
[_src("src", [_sig("Wave", "float32", 8, formula="sine", freq=5.0)])]),
mk("s13_f32_arr32", "float32 32-elem array sine 10 Hz",
[_src("src", [_sig("Wave", "float32", 32, formula="sine", freq=10.0)])]),
mk("s14_f64_arr64", "float64 64-elem array ramp",
[_src("src", [_sig("Ramp", "float64", 64, formula="ramp")])]),
mk("s15_i16_arr16", "int16 16-elem array counter",
[_src("src", [_sig("Cnt", "int16", 16, formula="counter")])]),
mk("s16_f32_arr256", "float32 256-elem array sine 5 Hz (large frame)",
[_src("src", [_sig("Wave", "float32", 256, formula="sine", freq=5.0)])],
max_payload=MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE),
mk("s39_uint8_arr32", "uint8 32-elem array counter (wrap fidelity)",
[_src("src", [_sig("Cnt", "uint8", 32, formula="counter")])]),
mk("s40_int8_arr16", "int8 16-elem array counter (wrap fidelity)",
[_src("src", [_sig("Cnt", "int8", 16, formula="counter")])]),
]
# ── Time modes (each non-PacketTime mode needs a TimeSignal in the source) ────
_TIMEMODES = [
# sampling_rate = elements/ROW_DT (8/0.001 = 8000) so the per-cycle window
# of 8 samples fills exactly one 1 kHz producer cycle. A smaller rate makes
# each cycle's window wider than the inter-cycle gap, so successive windows
# overlap and the published timestamps stop being monotonic — which corrupts
# the hub's binary-search range/zoom queries (they assume a sorted ring).
mk("s17_lastsample", "float32 8-elem LastSample, uint64 ns scalar anchor",
[_src("src", [
_sig("Tns", "uint64", 1, unit="ns", formula="time_ns", is_time=True),
_sig("Data", "float32", 8, time_mode="LastSample",
time_signal="Tns", sampling_rate=8000.0, formula="counter"),
])]),
mk("s18_firstsample", "float32 8-elem FirstSample, uint32 us scalar anchor",
[_src("src", [
_sig("Tus", "uint32", 1, unit="us", formula="time_us", is_time=True),
_sig("Data", "float32", 8, time_mode="FirstSample",
time_signal="Tus", sampling_rate=8000.0, formula="counter"),
])]),
mk("s19_fullarray_f64", "float64 50-elem FullArray sine 5 Hz, uint64 ns time",
[_src("src", [
_sig("TimeArr", "uint64", 50, unit="ns", formula="time_ns",
is_time=True),
_sig("Wave", "float64", 50, time_mode="FullArray",
time_signal="TimeArr", formula="sine", freq=5.0, unit="V"),
])],
oracle="both"),
mk("s43_fullarray_quant", "float32 16-elem FullArray quant uint16 sine 5 Hz",
[_src("src", [
_sig("TimeArr", "uint64", 16, unit="ns", formula="time_ns",
is_time=True),
_sig("Wave", "float32", 16, time_mode="FullArray",
time_signal="TimeArr", quant="uint16", range_min=-1.0,
range_max=1.0, formula="sine", freq=5.0, unit="V"),
])]),
]
# ── Quantization (each QuantizedType, on float signals) ──────────────────────
_QUANT = [
mk("s20_quant_uint8", "float32 scalar quant uint8 [-1,1] sine 5 Hz",
[_src("src", [_sig("Sine", "float32", 1, quant="uint8", range_min=-1.0,
range_max=1.0, formula="sine", freq=5.0)])]),
mk("s21_quant_int8", "float32 scalar quant int8 [-10,10] sine 5 Hz",
[_src("src", [_sig("Sine", "float32", 1, quant="int8", range_min=-10.0,
range_max=10.0, formula="sine", freq=5.0)])]),
mk("s22_quant_int16", "float32 scalar quant int16 [-100,100] ramp",
[_src("src", [_sig("Ramp", "float32", 1, quant="int16", range_min=-100.0,
range_max=100.0, formula="ramp")])]),
mk("s23_quant_f64_arr", "float64 16-elem quant uint16 [-2,2] sine 5 Hz",
[_src("src", [_sig("Wave", "float64", 16, quant="uint16", range_min=-2.0,
range_max=2.0, formula="sine", freq=5.0)])]),
]
# ── Publishing modes (Strict covered; Accumulate + Decimate here) ────────────
_PUBLISH = [
mk("s24_accumulate", "float32 scalar sine 5 Hz, Accumulate @50 Hz refresh",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
publishing="Accumulate", min_refresh_hz=50.0),
mk("s25_decimate4", "float32 scalar sine 5 Hz, Decimate ratio 4",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
publishing="Decimate", ratio=4),
mk("s26_decimate10_arr", "float32 8-elem counter, Decimate ratio 10",
[_src("src", [_sig("Cnt", "float32", 8, formula="counter")])],
publishing="Decimate", ratio=10),
mk("s46_accumulate_arr",
"Accumulate @200 Hz: accumulated scalar sine + 16-elem array passenger",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0),
_sig("Wave", "float32", 16, formula="sine", freq=5.0)])],
publishing="Accumulate", min_refresh_hz=200.0),
mk("s45_decimate_multisig", "Decimate ratio 2 over a 2-signal source",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0),
_sig("Cnt", "uint32", 1, formula="counter")])],
publishing="Decimate", ratio=2),
]
# ── Payload size / fragmentation (small MaxPayloadSize vs large array) ───────
_PAYLOAD = [
mk("s27_frag_f64_128", "float64 128-elem ramp, MaxPayload 512 (fragmented)",
[_src("src", [_sig("Ramp", "float64", 128, formula="ramp")])],
max_payload=512),
mk("s28_frag_f32_100", "float32 100-elem sine 5 Hz, MaxPayload 256 (fragmented)",
[_src("src", [_sig("Wave", "float32", 100, formula="sine", freq=5.0)])],
max_payload=256),
mk("s48_f64_arr_big_payload", "float64 100-elem ramp, MaxPayload 65490 (single frame)",
[_src("src", [_sig("Ramp", "float64", 100, formula="ramp")])],
max_payload=MAX_UDP_PAYLOAD - UDPS_HEADER_SIZE),
]
# ── Multicast ────────────────────────────────────────────────────────────────
_MCAST = [
mk("s29_mcast_scalar", "multicast float32 scalar sine 5 Hz",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
multicast=True)],
network="multicast"),
mk("s30_mcast_arr_fullarray", "multicast float32 32-elem FullArray sine 5 Hz",
[_src("src", [
_sig("TimeArr", "uint64", 32, unit="ns", formula="time_ns",
is_time=True),
_sig("Wave", "float32", 32, time_mode="FullArray",
time_signal="TimeArr", formula="sine", freq=5.0),
], multicast=True)],
network="multicast"),
mk("s47_mcast_multisrc", "multicast, two sources (scalar each)",
[_src("a", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
multicast=True),
_src("b", [_sig("Cnt", "uint32", 1, formula="counter")],
multicast=True)],
network="multicast"),
]
# ── Multiple sources (independent UDPStreamer blocks, one hub) ───────────────
_MULTISRC = [
mk("s31_two_src", "two unicast sources: float32 sine + uint32 counter",
[_src("a", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)]),
_src("b", [_sig("Cnt", "uint32", 1, formula="counter")])]),
mk("s32_three_src", "three unicast sources: int16 ramp / float64 sine / uint8 counter",
[_src("a", [_sig("Ramp", "int16", 1, formula="ramp")]),
_src("b", [_sig("Sine", "float64", 1, formula="sine", freq=5.0)]),
_src("c", [_sig("Cnt", "uint8", 1, formula="counter")])]),
]
# ── High-risk interactions ───────────────────────────────────────────────────
_INTERACT = [
mk("s33_dec_arr_quant", "Decimate 2 + 16-elem quant uint16 sine 5 Hz",
[_src("src", [_sig("Wave", "float32", 16, quant="uint16", range_min=-1.0,
range_max=1.0, formula="sine", freq=5.0)])],
publishing="Decimate", ratio=2),
mk("s34_acc_fullarray",
"Accumulate @100 Hz: accumulated scalar + 32-elem FullArray sine passenger",
[_src("src", [
_sig("Sine", "float32", 1, formula="sine", freq=5.0),
_sig("TimeArr", "uint64", 32, unit="ns", formula="time_ns",
is_time=True),
_sig("Wave", "float32", 32, time_mode="FullArray",
time_signal="TimeArr", formula="sine", freq=5.0),
])],
publishing="Accumulate", min_refresh_hz=100.0),
mk("s35_mcast_decimate", "multicast + Decimate ratio 5, float32 scalar sine 5 Hz",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)],
multicast=True)],
network="multicast", publishing="Decimate", ratio=5),
mk("s36_big_frag_dec", "float64 64-elem ramp, MaxPayload 256 + Decimate 4",
[_src("src", [_sig("Ramp", "float64", 64, formula="ramp")])],
max_payload=256, publishing="Decimate", ratio=4),
mk("s49_mixed_quant_raw", "one source: quant uint8 sine + raw float32 sine",
[_src("src", [
_sig("Q", "float32", 1, quant="uint8", range_min=-1.0, range_max=1.0,
formula="sine", freq=5.0),
_sig("Raw", "float32", 1, formula="sine", freq=5.0),
])]),
]
# ── Trigger + client-check coverage (rising/falling/both swept by the client) ─
_TRIGGER = [
mk("s37_trig_ramp_i32", "trigger on int32 ramp scalar",
[_src("src", [_sig("Ramp", "int32", 1, formula="ramp")])],
checks=("live", "trigger"), trig="src:Ramp"),
mk("s38_trig_f64_sine", "trigger on float64 sine 5 Hz scalar",
[_src("src", [_sig("Sine", "float64", 1, formula="sine", freq=5.0)])],
checks=("live", "zoom", "trigger"), trig="src:Sine"),
mk("s44_window_check", "float32 sine 5 Hz scalar, window time-range check",
[_src("src", [_sig("Sine", "float32", 1, formula="sine", freq=5.0)])],
checks=("live", "zoom", "window")),
mk("s50_trig_quant", "trigger on quantised uint16 sine 10 Hz",
[_src("src", [_sig("Sine", "float32", 1, quant="uint16", range_min=-5.0,
range_max=5.0, formula="sine", freq=10.0)])],
checks=("live", "trigger"), trig="src:Sine"),
]
# ── Misc scalar coverage (unit annotation, counter on float) ─────────────────
_MISC = [
mk("s41_f32_unit", "float32 scalar ramp with Unit=V",
[_src("src", [_sig("Ramp", "float32", 1, formula="ramp", unit="V")])]),
mk("s42_f64_counter", "float64 scalar counter (large integer values)",
[_src("src", [_sig("Cnt", "float64", 1, formula="counter")])]),
]
# ── High sample-rate / high-throughput stress ────────────────────────────────
# Eight signals, each a 1000-element float32 array carrying 1 ms worth of 1 MSps
# data, published once per 1 kHz producer cycle. Aggregate wire rate is
# 8 x 1 MSps x 4 B = 32 MB/s. A single scalar uint64 ns anchor (FirstSample) is
# shared by all eight data signals; the per-packet sample window
# (1000 / 1 MHz = 1 ms) equals the 1 kHz inter-packet gap so the expanded
# per-sample timestamps stay monotonic for the hub's binary-search ring. Ramp
# formula keeps the gate fidelity-only (robust to the UDP loss expected at rate).
#
# Packet sizing — DATA payload is 8 (ns anchor) + 8 x 1000 x 4 = 32008 B, which
# is deliberately kept under the 64 KB single-datagram ceiling. That ceiling is
# the UDPSClient reassembly buffer (UDPSReassemblySlot::payload[65535]) plus its
# offset overflow guard: a packet whose payload exceeds ~64 KB can never be
# reassembled (fragment 1 lands past the buffer and is dropped), so high-rate
# streaming must use single-datagram packets. max_payload is set to the maximum
# valid value (UDP limit - 17 B header) so the 32 KB packet is sent unfragmented.
_HIGHRATE = [
mk("s51_8x1msps_100hz",
"8x float32 10k-elem arrays @1 MSps, FirstSample, 100 Hz packets (~32 MB/s, "
"320 KB/cycle fragmented)",
[_src("src", [
_sig("Tns", "uint64", 1, unit="ns", formula="time_ns", is_time=True),
] + [
_sig(f"S{i}", "float32", 10000, time_mode="FirstSample",
time_signal="Tns", sampling_rate=1.0e6, formula="ramp")
for i in range(8)
])],
max_payload=40000, row_dt=0.01, num_rows=50, producer_hz=100,
checks=("live", "zoom")),
]
SCENARIOS = (_STARTERS + _TYPES + _ARRAYS + _TIMEMODES + _QUANT + _PUBLISH +
_PAYLOAD + _MCAST + _MULTISRC + _INTERACT + _TRIGGER + _MISC +
_HIGHRATE)
if __name__ == "__main__":
import sys
ok = True
seen_ids, seen_ws, seen_udp = set(), set(), set()
for s in SCENARIOS:
errs = validate_scenario(s)
# uniqueness invariants the orchestrator relies on
if s["id"] in seen_ids:
errs.append("duplicate id")
seen_ids.add(s["id"])
if s["ws_port"] in seen_ws:
errs.append(f"duplicate ws_port {s['ws_port']}")
seen_ws.add(s["ws_port"])
for src in s["sources"]:
if src["udp_port"] in seen_udp:
errs.append(f"duplicate udp_port {src['udp_port']}")
seen_udp.add(src["udp_port"])
print(f"{s['id']:32s} {'OK' if not errs else errs}")
ok = ok and not errs
print(f"\n{len(SCENARIOS)} scenarios, {'ALL VALID' if ok else 'INVALID PRESENT'}")
sys.exit(0 if ok else 1)
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
stress.py — Declarative stress matrix for the streaming chain.
Where scenarios.py exercises *correctness* (every option value, exact waveform
fidelity), this module exercises *capacity*: it pushes one axis of load at a time
and records how the two server processes behave. The two components under stress:
* UDPStreamer (datasource) — serialises + sends UDPS packets each RT cycle.
Stress axes: signal size (bytes/packet), signal count, subscriber count
(UDPStreamer fans a unicast source out to up to 16 clients).
* StreamHub (hub) — ring storage, LTTB decimation, WS fan-out, zoom.
Stress axes: signal size, source count (independent UDPStreamer feeds),
WS client count, and client request (zoom) rate.
What is measured (not waveform fidelity — at these rates UDP loss is expected and
the hub decimates to PushRate anyway, so end-to-end sample loss is not the gate):
* survival — neither server crashed/hung for the whole run (hard gate).
* liveness — every WS client kept receiving monotonic, wall-clock-stamped
pushes under the load (hard gate).
* cpu / peakRSS — of marte and hub, captured by proc_perf.py (scaling curves;
soft gate against generous ceilings).
* zoom latency — p50/p95 round-trip of zoom queries issued under load (soft
gate; the headline responsiveness metric for the hub).
Stress-case dict schema (a superset of the scenarios.py scenario dict, so the
gen_data.py / gen_cfg.py generators consume it unchanged):
{ ...all scenario keys (id, network, publishing, max_payload, ws_port,
sources, oracle, client_checks, trig_signal, row_dt/num_rows/...),
"shape": "hub" | "ds_fanout",
"stress": {
"axis": str, # which load axis this case varies (for the report)
"level": number, # the axis value (x for the scaling curve)
"clients": int, # parallel WS clients (hub shape)
"hubs": int, # parallel subscriber hubs (ds_fanout shape)
"reqrate": float, # zoom requests/sec/client (0 = one-shot checks)
"dur": float, # live-load duration (s)
"gate": { "min_frames": int, "max_marte_rss_mb": float,
"max_hub_rss_mb": float, "max_zoom_p95_ms": float },
} }
The matrix keeps every datagram a single UDP fragment (payload < 64 KB): the
UDPSClient reassembly buffer caps a deliverable packet at ~64 KB, so sustained
high-rate streaming must stay below it (see scenarios.py s51). Cases therefore
sweep *count* and *rate*, not oversized single packets.
"""
import itertools
import scenarios as S
# Dedicated port ranges, disjoint from scenarios.py (ws 8101-8151, udp 44610-).
_ws = itertools.count(8300)
_udp = itertools.count(45000, 2)
_data = itertools.count(46000, 2)
# Producer geometry knobs shared across the matrix. 1 kHz producer (default),
# float32 arrays sized so one cycle's array is a single sub-64 KB datagram.
F32 = "float32"
def _f32_arr(name, elements, sampling_rate=1.0e6):
"""A float32 array signal timestamped FirstSample off the shared ns anchor.
sampling_rate defaults to elements*producer_hz so the per-cycle window equals
one 1 kHz producer cycle (keeps the hub ring's time axis monotonic, the same
constraint scenarios.py enforces for First/LastSample)."""
return S._sig(name, F32, elements, time_mode="FirstSample",
time_signal="Tns", sampling_rate=sampling_rate, formula="ramp")
def _source(sid, n_signals, elements, multicast=False):
"""One UDPStreamer source: a uint64 ns anchor + n_signals float32 arrays.
sampling_rate = elements / row_dt(1 ms) = elements * 1000 keeps each array's
1 ms window aligned to the 1 kHz cycle regardless of `elements`."""
rate = elements * 1000.0
sigs = [S._sig("Tns", "uint64", 1, unit="ns", formula="time_ns",
is_time=True)]
sigs += [_f32_arr(f"S{i}", elements, rate) for i in range(n_signals)]
return {
"id": sid, "udp_port": next(_udp),
"data_port": next(_data) if multicast else None,
"multicast_group": S.MCAST_GROUP if multicast else None,
"signals": sigs,
}
def _packet_bytes(n_signals, elements):
"""Approx DATA payload bytes/packet: ns anchor + n_signals*elements*4."""
return 8 + n_signals * elements * 4
def mk_stress(sid, axis, level, sources, shape="hub", clients=1, hubs=1,
reqrate=0.0, dur=6.0, network="unicast", publishing="Strict",
ratio=None, min_refresh_hz=None, gate=None):
case = {
"id": sid, "desc": f"stress {axis}={level}",
"network": network, "publishing": publishing,
"ratio": ratio, "min_refresh_hz": min_refresh_hz,
"max_payload": S.MAX_UDP_PAYLOAD - S.UDPS_HEADER_SIZE,
"ws_port": next(_ws), "sources": sources,
"oracle": "analytic", "client_checks": ["live", "zoom"],
"trig_signal": None, "known_issue": None,
"row_dt": None, "num_rows": None, "producer_hz": None,
"shape": shape,
"stress": {
"axis": axis, "level": level, "clients": clients, "hubs": hubs,
"reqrate": reqrate, "dur": dur,
"gate": gate or {},
},
}
return case
# Default gate: at least a handful of frames per client, generous RSS ceilings,
# and a 1 s p95 zoom round-trip cap. Stress cases tune these per axis.
def _gate(min_frames=5, marte_rss=512.0, hub_rss=1024.0, zoom_p95=1000.0):
return {"min_frames": min_frames, "max_marte_rss_mb": marte_rss,
"max_hub_rss_mb": hub_rss, "max_zoom_p95_ms": zoom_p95}
# ── UDPStreamer (datasource) stress ───────────────────────────────────────────
# Single source + single hub; the load lands on the UDPStreamer serialise/send
# path and is read back through one WS client (proc_perf measures marte).
# size: one float32 array, growing element count → bigger single-datagram packet.
_DS_SIZE = [
mk_stress(f"ds_size_{e}", "ds_signal_elements", e,
[_source("src", 1, e)], gate=_gate())
for e in (1000, 4000, 8000, 15000) # 4 KB → 60 KB packets (sub-64 KB cap)
]
# count: many 1000-element float32 arrays in one source → wider packet + more
# per-cycle serialise work.
_DS_COUNT = [
mk_stress(f"ds_count_{n}", "ds_signal_count", n,
[_source("src", n, 1000)], gate=_gate())
for n in (1, 4, 8, 15) # 15*4 KB ≈ 60 KB packet (sub-64 KB cap)
]
# clients (fan-out): one source, M subscriber hubs (ds_fanout shape). The
# UDPStreamer copies every packet to each unicast client (max 16); CPU should
# scale with M. Each hub gets its own WS port + one client.
_DS_CLIENTS = [
mk_stress(f"ds_clients_{m}", "ds_subscriber_hubs", m,
[_source("src", 4, 2000)], shape="ds_fanout", hubs=m,
gate=_gate(hub_rss=1024.0))
for m in (1, 2, 4, 8)
]
# ── StreamHub (hub) stress ────────────────────────────────────────────────────
# All measured against a single hub; proc_perf measures the hub process.
# size: hub ring/decimation cost vs per-signal width.
_HUB_SIZE = [
mk_stress(f"hub_size_{e}", "hub_signal_elements", e,
[_source("src", 1, e)], gate=_gate())
for e in (1000, 4000, 8000, 15000)
]
# sources: N independent UDPStreamer feeds into one hub (each its own udp_port).
_HUB_SOURCES = [
mk_stress(f"hub_sources_{n}", "hub_source_count", n,
[_source(f"s{i}", 2, 1000) for i in range(n)],
gate=_gate(marte_rss=1024.0))
for n in (1, 2, 4, 8)
]
# clients: one source, C parallel WS clients all recording live + zooming.
_HUB_CLIENTS = [
mk_stress(f"hub_clients_{c}", "hub_ws_clients", c,
[_source("src", 2, 2000)], clients=c, gate=_gate())
for c in (1, 4, 8, 16)
]
# request rate: one source, a few clients each issuing zoom queries at a sustained
# rate; the headline gate is zoom p95 latency under that query load.
_HUB_REQRATE = [
mk_stress(f"hub_reqrate_{r}", "hub_zoom_reqrate_hz", r,
[_source("src", 2, 2000)], clients=4, reqrate=r, dur=8.0,
gate=_gate(zoom_p95=1500.0))
for r in (5, 20, 50)
]
STRESS_CASES = (_DS_SIZE + _DS_COUNT + _DS_CLIENTS +
_HUB_SIZE + _HUB_SOURCES + _HUB_CLIENTS + _HUB_REQRATE)
def validate_case(c):
"""Stress-case validity = scenario validity + stress-specific bounds."""
errs = S.validate_scenario(c)
st = c.get("stress", {})
if c["shape"] == "ds_fanout":
if st["hubs"] < 1 or st["hubs"] > 16:
errs.append(f"{c['id']}: ds_fanout hubs must be 1..16 "
f"(UDPStreamer max unicast clients)")
if c["shape"] == "hub" and (st["clients"] < 1):
errs.append(f"{c['id']}: hub clients must be >= 1")
# Single-datagram ceiling: keep each source's packet < 64 KB so it never
# needs reassembly (the deliverable-packet cap).
for src in c["sources"]:
n_data = sum(1 for s in src["signals"] if not s["is_time"])
elem = max((s["elements"] for s in src["signals"]
if not s["is_time"]), default=0)
pb = _packet_bytes(n_data, elem)
if pb >= 65536:
errs.append(f"{c['id']}: source {src['id']} packet {pb} B exceeds "
f"the 64 KB single-datagram cap")
return errs
if __name__ == "__main__":
import sys
ok = True
seen_ids, seen_ws, seen_udp = set(), set(), set()
by_axis = {}
for c in STRESS_CASES:
errs = validate_case(c)
if c["id"] in seen_ids:
errs.append("duplicate id")
seen_ids.add(c["id"])
if c["ws_port"] in seen_ws:
errs.append(f"duplicate ws_port {c['ws_port']}")
seen_ws.add(c["ws_port"])
for src in c["sources"]:
if src["udp_port"] in seen_udp:
errs.append(f"duplicate udp_port {src['udp_port']}")
seen_udp.add(src["udp_port"])
by_axis.setdefault(c["stress"]["axis"], []).append(c["id"])
print(f"{c['id']:20s} {c['shape']:9s} "
f"{'OK' if not errs else errs}")
ok = ok and not errs
print(f"\n{len(STRESS_CASES)} stress cases across {len(by_axis)} axes, "
f"{'ALL VALID' if ok else 'INVALID PRESENT'}")
sys.exit(0 if ok else 1)
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
stress_run.py — Orchestrator for the streaming-chain stress matrix (stress.py).
Where run_e2e.sh drives scenarios.py for *correctness*, this drives
STRESS_CASES for *capacity*. Per case it:
1. generates the FileReader input + MARTe app cfg + 1..M StreamHub cfgs
(reusing gen_data / gen_cfg unchanged — a stress case is a scenario superset),
2. launches the server stack:
* "hub" shape: 1 MARTe UDPStreamer feed + 1 StreamHub, then N
parallel chain-clients (stress mode) on that one hub,
* "ds_fanout" shape: 1 MARTe feed + M independent StreamHubs all
subscribing to the same UDPStreamer (unicast fan-out), 1 client each,
3. drives the clients in stress mode (liveness + sustained zoom-latency),
4. snapshots CPU / peak-RSS of marte and every hub *while alive* (proc_perf),
5. evaluates the per-case gate (survival + liveness hard gates; RSS + zoom-p95
soft gates against the case's ceilings),
and writes stress_results.json (one record per case, carrying the axis/level for
later scaling-curve plots). Server binaries + LD_LIBRARY_PATH come from the
caller (run_stress.sh sources env.sh); this module only orchestrates.
"""
import argparse
import copy
import json
import os
import signal
import socket
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gen_cfg # noqa: E402
import gen_data # noqa: E402
import proc_perf # noqa: E402
import stress as ST # noqa: E402
# WS port probing bases. StreamHub does not set SO_REUSEADDR, so a leftover
# listener (e.g. an orphaned hub from an aborted run) keeps a fixed port wedged;
# we therefore probe for genuinely-free ports at launch rather than trusting a
# static assignment. ds_fanout hubs probe from one base, single hubs from another.
FANOUT_WS_BASE = 9000
SINGLE_WS_BASE = 9100
def _popen(cmd, log_path):
"""Launch cmd in its own session, stdout+stderr → log_path."""
f = open(log_path, "w")
return subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT,
start_new_session=True), f
def _free_ports(n, base):
"""Find n free TCP ports on 127.0.0.1 at/above base.
Probed ports are closed and handed straight to the hub launch; the race
window is negligible for this sequential localhost harness, and probing is
what makes the suite robust to leftover wedged listeners."""
found = []
cand = base
while len(found) < n and cand < base + 1000:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.bind(("127.0.0.1", cand))
found.append(cand)
except OSError:
pass
finally:
s.close()
cand += 1
if len(found) < n:
raise RuntimeError(f"could not find {n} free ports from {base}")
return found
def _hub_ports(case):
st = case["stress"]
if case["shape"] == "ds_fanout":
return _free_ports(st["hubs"], FANOUT_WS_BASE)
return _free_ports(1, SINGLE_WS_BASE)
def _teardown(procs_files):
for p, _f in procs_files:
if p.poll() is None:
try:
os.killpg(os.getpgid(p.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
pass
deadline = time.time() + 5
for p, _f in procs_files:
try:
p.wait(timeout=max(0.1, deadline - time.time()))
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(p.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
pass
for _p, f in procs_files:
try:
f.close()
except OSError:
pass
def run_case(case, bins, work, out):
sid = case["id"]
st = case["stress"]
input_bin = os.path.join(work, f"sinput_{sid}.bin")
marte_cfg = os.path.join(work, f"sm_{sid}.cfg")
gen_data.write_input(case, input_bin)
gen_cfg.write_marte_cfg(case, marte_cfg, input_bin)
ports = _hub_ports(case)
hub_pf = [] # (proc, file)
for i, p in enumerate(ports):
hc = copy.deepcopy(case)
hc["ws_port"] = p
hcfg = os.path.join(work, f"sh_{sid}_{i}.cfg")
gen_cfg.write_hub_cfg(hc, hcfg)
proc, f = _popen([bins["hub"], "-cfg", hcfg],
os.path.join(out, f"shub_{sid}_{i}.log"))
hub_pf.append((proc, f))
time.sleep(1.0)
marte_proc, marte_f = _popen(
[bins["marte"], "-l", "RealTimeLoader", "-f", marte_cfg, "-s", "Running"],
os.path.join(out, f"smarte_{sid}.log"))
time.sleep(1.5)
dur, reqrate = st["dur"], st["reqrate"]
# clean stale client outputs so a missing file means "client failed".
for i in range(max(st["clients"], st["hubs"])):
sp = os.path.join(work, f"stress_{sid}_c{i}.json")
if os.path.exists(sp):
os.remove(sp)
client_pf = []
if case["shape"] == "ds_fanout":
targets = [(i, ports[i]) for i in range(len(ports))]
else:
targets = [(i, ports[0]) for i in range(st["clients"])]
for cid, wsport in targets:
cmd = [bins["client"], "-mode", "stress",
"-hub", f"127.0.0.1:{wsport}", "-scenario", sid,
"-clientid", str(cid), "-dur", str(dur),
"-reqrate", str(reqrate), "-out", work,
"-timeout", f"{int(dur) + 90}s"]
proc, f = _popen(cmd, os.path.join(out, f"sclient_{sid}_c{cid}.log"))
client_pf.append((proc, f))
deadline = time.time() + dur + 60
for proc, _f in client_pf:
try:
proc.wait(timeout=max(1.0, deadline - time.time()))
except subprocess.TimeoutExpired:
proc.kill()
for _p, f in client_pf:
f.close()
# survival + perf must be read while the stack is still alive.
survival = (marte_proc.poll() is None
and all(h.poll() is None for h, _ in hub_pf))
perf_marte = proc_perf.snapshot(marte_proc.pid)
perf_hubs = [proc_perf.snapshot(h.pid) for h, _ in hub_pf]
_teardown([(marte_proc, marte_f)] + hub_pf)
clients = []
for cid, _ in targets:
jp = os.path.join(work, f"stress_{sid}_c{cid}.json")
if os.path.exists(jp):
with open(jp) as f:
clients.append(json.load(f))
return _evaluate(case, survival, perf_marte, perf_hubs, clients)
def _rss_mb(rec):
return rec.get("peak_rss_kb", 0) / 1024.0 if rec.get("avail") else 0.0
def _evaluate(case, survival, perf_marte, perf_hubs, clients):
g = case["stress"].get("gate", {})
fails = []
if not survival:
fails.append("server process died during run")
if not clients:
fails.append("no client results recorded")
min_frames = g.get("min_frames", 1)
for c in clients:
cid = c.get("clientId")
if c.get("frames", 0) < min_frames:
fails.append(f"c{cid} frames {c.get('frames')} < {min_frames}")
if not c.get("monotonic", False):
fails.append(f"c{cid} non-monotonic time axis")
if not c.get("wallclock", False):
fails.append(f"c{cid} non-wallclock timestamps")
marte_rss = _rss_mb(perf_marte)
if "max_marte_rss_mb" in g and marte_rss > g["max_marte_rss_mb"]:
fails.append(f"marte peakRSS {marte_rss:.0f}MB > {g['max_marte_rss_mb']}MB")
hub_rss_max = max((_rss_mb(h) for h in perf_hubs), default=0.0)
if "max_hub_rss_mb" in g and hub_rss_max > g["max_hub_rss_mb"]:
fails.append(f"hub peakRSS {hub_rss_max:.0f}MB > {g['max_hub_rss_mb']}MB")
p95s = [c["zoomP95ms"] for c in clients if c.get("zoomCount", 0) > 0]
zoom_p95 = max(p95s) if p95s else 0.0
p50s = [c["zoomP50ms"] for c in clients if c.get("zoomCount", 0) > 0]
zoom_p50 = max(p50s) if p50s else 0.0
if "max_zoom_p95_ms" in g and zoom_p95 > g["max_zoom_p95_ms"]:
fails.append(f"zoom p95 {zoom_p95:.0f}ms > {g['max_zoom_p95_ms']}ms")
st = case["stress"]
return {
"id": case["id"], "shape": case["shape"],
"axis": st["axis"], "level": st["level"],
"status": "PASS" if not fails else "FAIL",
"survival": survival,
"clients": len(clients),
"min_frames": min(((c.get("frames", 0)) for c in clients), default=0),
"marte_cpu_s": perf_marte.get("cpu_s", 0.0),
"marte_rss_mb": round(marte_rss, 1),
"hub_cpu_s": round(sum(h.get("cpu_s", 0.0) for h in perf_hubs), 2),
"hub_rss_mb": round(hub_rss_max, 1),
"zoom_count": sum(c.get("zoomCount", 0) for c in clients),
"zoom_fail": sum(c.get("zoomFail", 0) for c in clients),
"zoom_p50_ms": round(zoom_p50, 1),
"zoom_p95_ms": round(zoom_p95, 1),
"fails": fails,
}
def main():
p = argparse.ArgumentParser(description="Run the streaming-chain stress matrix")
p.add_argument("--marte", required=True, help="MARTeApp.ex path")
p.add_argument("--hub", required=True, help="StreamHub.ex path")
p.add_argument("--client", required=True, help="chain-client path")
p.add_argument("--work", required=True, help="scratch dir")
p.add_argument("--out", required=True, help="report/log dir")
p.add_argument("--only", default="", help="run a single case id")
p.add_argument("--axis", default="", help="run only cases on this axis")
args = p.parse_args()
os.makedirs(args.work, exist_ok=True)
os.makedirs(args.out, exist_ok=True)
bins = {"marte": args.marte, "hub": args.hub, "client": args.client}
cases = ST.STRESS_CASES
if args.only:
cases = [c for c in cases if c["id"] == args.only]
if args.axis:
cases = [c for c in cases if c["stress"]["axis"] == args.axis]
if not cases:
print("no stress cases selected", file=sys.stderr)
sys.exit(1)
results = []
for c in cases:
errs = ST.validate_case(c)
if errs:
print(f"══ {c['id']}: INVALID {errs} ══")
results.append({"id": c["id"], "axis": c["stress"]["axis"],
"level": c["stress"]["level"], "status": "FAIL",
"fails": errs})
continue
print(f"\n══ stress {c['id']} ({c['shape']} {c['stress']['axis']}="
f"{c['stress']['level']}) ══")
rec = run_case(c, bins, args.work, args.out)
results.append(rec)
tag = rec["status"]
print(f" {tag} frames>={rec.get('min_frames')} "
f"marteCPU={rec.get('marte_cpu_s', 0):.1f}s "
f"marteRSS={rec.get('marte_rss_mb', 0):.0f}MB "
f"hubRSS={rec.get('hub_rss_mb', 0):.0f}MB "
f"zoom p50/p95={rec.get('zoom_p50_ms', 0):.0f}/"
f"{rec.get('zoom_p95_ms', 0):.0f}ms")
if rec.get("fails"):
for fmsg in rec["fails"]:
print(f" - {fmsg}")
overall = bool(results) and all(r["status"] == "PASS" for r in results)
doc = {"overall": "PASS" if overall else "FAIL", "cases": results}
rp = os.path.join(args.out, "stress_results.json")
with open(rp, "w") as f:
json.dump(doc, f, indent=2)
npass = sum(r["status"] == "PASS" for r in results)
print(f"\nstress_results.json: {npass}/{len(results)} pass → {doc['overall']}")
sys.exit(0 if overall else 1)
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
tests_py.py — Unit tests for the streaming-chain E2E Python framework.
Run directly (``python3 -m unittest tests_py``) or, for coverage, via collect.py
which wraps it in ``coverage run``. These exercise the pure logic of the
generators, the waveform oracle, the config emitter, the RCV1 reader and the
/proc perf sampler — i.e. everything the orchestrator depends on, without needing
a live MARTe/StreamHub stack.
"""
import os
import struct
import sys
import tempfile
import unittest
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S
import gen_data as G
import gen_cfg as C
import validate_waveform as V
import proc_perf as P
class TestScenarios(unittest.TestCase):
def test_all_starter_scenarios_valid(self):
for s in S.SCENARIOS:
self.assertEqual(S.validate_scenario(s), [], f"{s['id']} invalid")
def test_seamless_loop_constraint(self):
s = {
"id": "x", "network": "unicast", "publishing": "Strict",
"ratio": None, "min_refresh_hz": None, "max_payload": 1400,
"ws_port": 9000,
"sources": [{"id": "s", "udp_port": 1, "data_port": None,
"multicast_group": None,
"signals": [S._sig("Bad", "float32", freq=3.0)]}],
"oracle": "analytic", "client_checks": ["live"], "trig_signal": None,
}
errs = S.validate_scenario(s)
self.assertTrue(any("multiple of LOOP_HZ" in e for e in errs), errs)
s["sources"][0]["signals"][0]["freq"] = S.LOOP_HZ * 2
self.assertEqual(S.validate_scenario(s), [])
def test_bad_quant_rejected(self):
s = {
"id": "x", "network": "unicast", "publishing": "Strict",
"ratio": None, "min_refresh_hz": None, "max_payload": 1400,
"ws_port": 9000,
"sources": [{"id": "s", "udp_port": 1, "data_port": None,
"multicast_group": None,
"signals": [S._sig("Q", "int32", quant="uint8",
range_min=0, range_max=1, formula="ramp")]}],
"oracle": "analytic", "client_checks": ["live"], "trig_signal": None,
}
errs = S.validate_scenario(s)
self.assertTrue(any("quant only on float" in e for e in errs), errs)
class TestGenData(unittest.TestCase):
def test_ground_truth_shapes(self):
gt = G.build_ground_truth(S.SCENARIOS[1]) # s02: TimeArr+Wave, 100 elem
wave = gt["src:Wave"]
self.assertEqual(wave["elements"], 100)
self.assertEqual(wave["v"].size, S.NUM_ROWS * 100)
self.assertEqual(wave["t"].size, S.NUM_ROWS * 100)
def test_binary_roundtrip(self):
sc = S.SCENARIOS[0] # s01: Counter(uint32)+Sine(float32)
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "in.bin")
gt = G.write_input(sc, p)
cols = V.read_marte_binary(p)
self.assertIn("Counter", cols)
self.assertIn("Sine", cols)
# counter is row index → first/last match ground truth
self.assertEqual(cols["Counter"].reshape(-1)[0], gt["src:Counter"]["v"][0])
self.assertEqual(int(cols["Counter"].reshape(-1)[5]), 5)
class TestOracle(unittest.TestCase):
def _gt(self, **kw):
t = np.linspace(0, 1, 200)
base = {"v": np.sin(2 * np.pi * 5 * t), "type": "float32", "quant": "none",
"formula": "sine", "freq": 5.0, "range_min": -1.0, "range_max": 1.0,
"elements": 1, "rows": 200, "is_time": False, "t": t,
"dt": t[1] - t[0]}
base.update(kw)
return base, t
def test_quant_tol_is_one_level(self):
gt, _ = self._gt(quant="uint16", range_min=-5.0, range_max=5.0)
tol, step = V._tol(gt)
self.assertAlmostEqual(step, 10.0 / 65535, places=9)
self.assertGreaterEqual(tol, step) # one full level, not half
def test_good_passes_bad_fails(self):
gt, t = self._gt(quant="uint16")
_, step = V._tol(gt)
good = gt["v"] + (np.random.rand(200) - 0.5) * step
bad = gt["v"] + (np.random.rand(200) - 0.5) * step * 50
self.assertTrue(V.compare_signal(gt, t, good)["pass"])
self.assertFalse(V.compare_signal(gt, t, bad)["pass"])
def test_wrong_frequency_fails_shape(self):
gt, t = self._gt()
wrong = np.sin(2 * np.pi * 50 * t) # 10x frequency
m = V.compare_signal(gt, t, wrong)
self.assertFalse(m["shape_ok"], m)
def test_integer_offgrid_fails(self):
gt, t = self._gt(type="uint32", quant="none",
v=np.arange(200, dtype=np.float64), formula="counter")
self.assertTrue(V.compare_signal(gt, t, np.arange(50, 150, dtype=float))["pass"])
self.assertFalse(V.compare_signal(gt, t, np.array([1.5, 999.0]))["pass"])
class TestRCV1(unittest.TestCase):
def test_read_received(self):
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "r.bin")
t = [0.0, 0.1, 0.2]
v = [1.0, 2.0, 3.0]
buf = bytearray(b"RCV1")
buf += struct.pack("<I", 1)
key = b"src:Sig"
buf += struct.pack("<H", len(key)) + key + struct.pack("<I", len(t))
for x in t:
buf += struct.pack("<d", x)
for x in v:
buf += struct.pack("<d", x)
open(p, "wb").write(buf)
r = V.read_received(p)
self.assertIn("src:Sig", r)
tt, vv = r["src:Sig"]
self.assertEqual(list(vv), v)
class TestGenCfg(unittest.TestCase):
def test_tap_routes_through_ddb(self):
sc = S.SCENARIOS[1] # s02, oracle=both
with tempfile.TemporaryDirectory() as d:
mc = os.path.join(d, "m.cfg")
C.write_marte_cfg(sc, mc, os.path.join(d, "in.bin"),
tap_bin=os.path.join(d, "tap.bin"))
cfg = open(mc).read()
self.assertIn("StreamGAM_src", cfg)
self.assertIn("TapGAM", cfg)
# TapGAM must read from the DDB (prefixed name), not the FileReader
self.assertIn("src_Wave", cfg)
tap_block = cfg[cfg.index("+TapGAM"):]
tap_in = tap_block[tap_block.index("InputSignals"):tap_block.index("OutputSignals")]
self.assertNotIn("FileReaderDS", tap_in)
def test_no_tap_direct(self):
sc = S.SCENARIOS[0] # s01, oracle=analytic
with tempfile.TemporaryDirectory() as d:
mc = os.path.join(d, "m.cfg")
C.write_marte_cfg(sc, mc, os.path.join(d, "in.bin"))
cfg = open(mc).read()
self.assertNotIn("TapGAM", cfg)
self.assertIn("ReaderGAM_src", cfg)
def test_hub_cfg_has_ports(self):
sc = S.SCENARIOS[0]
with tempfile.TemporaryDirectory() as d:
hc = os.path.join(d, "h.cfg")
C.write_hub_cfg(sc, hc)
cfg = open(hc).read()
self.assertIn(f"WSPort = {sc['ws_port']}", cfg)
self.assertIn(f"Port = {sc['sources'][0]['udp_port']}", cfg)
class TestProcPerf(unittest.TestCase):
def test_snapshot_self(self):
rec = P.snapshot(os.getpid())
self.assertTrue(rec["avail"])
self.assertIn("cpu_s", rec)
self.assertGreaterEqual(rec["cpu_s"], 0.0)
def test_snapshot_missing(self):
self.assertFalse(P.snapshot(2 ** 30).get("avail", False))
if __name__ == "__main__":
unittest.main(verbosity=2)
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
validate_waveform.py — Per-effect waveform validator for the streaming-chain E2E.
Compares the client-recorded stream (``received_<id>.bin``, RCV1 format written
by chain-client) against the analytic ground truth (rebuilt from gen_data) and,
when present, the fed-reference tap (``tap_<id>.bin``, MARTe binary). It also
rolls in the client behavioural checks (``checks_<id>.json``) and emits
``metrics_<id>.json`` with an overall pass/fail (exit 0/1).
Oracle (per signal)
-------------------
* **Fidelity** (always): every received value must lie within ``tol`` of *some*
ground-truth value. ``tol`` is 0 (bit-exact) for un-quantised integers, a tiny
float epsilon for un-quantised floats, and ``quant_step/2 + 1e-6·range`` for
quantised floats. Catches type corruption and out-of-range quantisation.
* **Shape** (sine signals, ≥8 points): least-squares fit of
``a·sin(ωt)+b·cos(ωt)+c`` at the scenario frequency ω=2πf. A high correlation
(≥0.99) and low normalised RMSE confirm the received waveform is the expected
sinusoid at the expected frequency (the fit recovers the unknown wall-clock
phase offset automatically). nRMSE tolerance is relaxed by the quant step.
* **Fed reference** (when ``--tap`` given): each received value must be within
``tol`` of some tap value too.
* **Continuity** (always, ≥10 points): a stream that stalls (client falling
behind, hub failing to flush a window, ...) can still pass fidelity —
whatever few samples *did* arrive still match the ground truth — while the
plot shows gaping holes. Flags any inter-sample gaps that are >10x the
median spacing and fails when their *summed* duration exceeds 5% of the
capture span. Calibrated against the full scenario matrix: healthy streams
(bursty per-tick live pushes, decimation, fragmentation, multicast, ...) top
out at 0.7% outlier-gap time; a stalled stream showed 55-91%.
"""
import argparse
import json
import os
import struct
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import scenarios as S # noqa: E402
import gen_data as G # noqa: E402
CODE_TO_TYPE = {v: k for k, v in S.TYPE_CODES.items()}
# ── readers ──────────────────────────────────────────────────────────────────
def read_received(path):
"""Read RCV1 → {key: (t ndarray, v ndarray)}."""
with open(path, "rb") as f:
data = f.read()
if data[:4] != b"RCV1":
raise ValueError(f"{path}: bad magic")
off = 4
nsig = struct.unpack_from("<I", data, off)[0]
off += 4
out = {}
for _ in range(nsig):
klen = struct.unpack_from("<H", data, off)[0]
off += 2
key = data[off:off + klen].decode()
off += klen
n = struct.unpack_from("<I", data, off)[0]
off += 4
t = np.frombuffer(data, "<f8", n, off).copy()
off += 8 * n
v = np.frombuffer(data, "<f8", n, off).copy()
off += 8 * n
out[key] = (t, v)
return out
def read_marte_binary(path):
"""Read a MARTe binary file → {name: ndarray[n_rows, elements]} (typed)."""
with open(path, "rb") as f:
data = f.read()
ns = struct.unpack_from("<I", data, 0)[0]
off = 4
descs = []
for _ in range(ns):
tc = struct.unpack_from("<H", data, off)[0]
name = data[off + 2:off + 34].rstrip(b"\0").decode(errors="replace")
ne = struct.unpack_from("<I", data, off + 34)[0]
descs.append((name, tc, ne))
off += 38
row_bytes = sum(struct.calcsize(_npfmt(tc)) * ne for _, tc, ne in descs)
nrows = (len(data) - off) // row_bytes if row_bytes else 0
cols = {name: [] for name, _, _ in descs}
for r in range(nrows):
for name, tc, ne in descs:
dt = np.dtype(S.NP_DTYPE[CODE_TO_TYPE[tc]])
vals = np.frombuffer(data, dt, ne, off)
cols[name].append(vals.astype(np.float64))
off += dt.itemsize * ne
return {name: np.array(cols[name]) for name, _, _ in descs}
def _npfmt(tc):
return {"<i1": "b", "<u1": "B", "<i2": "h", "<u2": "H", "<i4": "i",
"<u4": "I", "<i8": "q", "<u8": "Q", "<f4": "f",
"<f8": "d"}[S.NP_DTYPE[CODE_TO_TYPE[tc]]]
# ── metrics ──────────────────────────────────────────────────────────────────
def _tol(gt):
if gt["quant"] and gt["quant"] != "none":
levels = S.QUANT_LEVELS[gt["quant"]]
rng = gt["range_max"] - gt["range_min"]
step = rng / levels
# One full quantisation level: the correctness bound for lossy quant when
# the encode rounding convention (round vs truncate) is unknown. Gross
# corruption is many levels off; a faithful round-trip is ≤1 level.
return step + 1e-6 * abs(rng), step
if gt["type"] in S.FLOAT_TYPES:
return 1e-3, 0.0 # float round-trip epsilon
return 0.5, 0.0 # integer: rounding-exact (within 0.5)
def nearest_err(recv_v, truth_v):
"""Max distance from each received value to the nearest truth value."""
sv = np.unique(np.sort(truth_v.astype(np.float64)))
idx = np.searchsorted(sv, recv_v)
idx = np.clip(idx, 1, len(sv) - 1) if len(sv) > 1 else np.zeros_like(idx)
lo = sv[np.clip(idx - 1, 0, len(sv) - 1)]
hi = sv[np.clip(idx, 0, len(sv) - 1)]
d = np.minimum(np.abs(recv_v - lo), np.abs(recv_v - hi))
return float(np.max(d)) if d.size else 0.0
def gap_check(t_recv, outlier_mult=10.0, max_outlier_frac=0.05):
"""Detect large discontiguous holes in a received time series.
A handful of gaps a few times the median spacing are normal (bursty
per-tick live pushes, decimation, LTTB). Returns (ok, gap_frac, n_gaps,
max_gap): ``gap_frac`` is the fraction of the total capture span consumed
by gaps larger than ``outlier_mult`` times the median inter-sample gap;
when that adds up to more than ``max_outlier_frac`` of the whole capture,
the stream stalled/dropped a chunk rather than merely being decimated.
"""
if t_recv.size < 10:
return True, 0.0, 0, 0.0
t = np.sort(t_recv.astype(np.float64))
dt = np.diff(t)
span = float(t[-1] - t[0])
med = float(np.median(dt))
if span <= 0.0 or med <= 0.0:
return True, 0.0, 0, 0.0
outliers = dt[dt > outlier_mult * med]
gap_frac = float(outliers.sum() / span)
return gap_frac <= max_outlier_frac, gap_frac, int(outliers.size), float(dt.max())
def sine_shape(t, v, freq):
"""Return (corr, nrmse, amp_fit) for a sinusoid fit at ``freq``."""
w = 2.0 * np.pi * freq
A = np.column_stack([np.sin(w * t), np.cos(w * t), np.ones_like(t)])
coef, *_ = np.linalg.lstsq(A, v, rcond=None)
fit = A @ coef
span = float(np.max(v) - np.min(v)) or 1.0
nrmse = float(np.sqrt(np.mean((v - fit) ** 2)) / span)
corr = float(np.corrcoef(v, fit)[0, 1]) if np.std(v) > 0 else 1.0
amp = float(np.hypot(coef[0], coef[1]))
return corr, nrmse, amp
def compare_signal(gt, t_recv, v_recv, tap_v=None):
tol, step = _tol(gt)
truth_v = gt["v"].astype(np.float64)
m = {
"type": gt["type"], "quant": gt["quant"], "formula": gt["formula"],
"n_truth": int(truth_v.size), "n_recv": int(v_recv.size),
"quant_step": step, "tol": tol,
}
if v_recv.size == 0:
m["pass"] = False
m["reason"] = "no received samples"
return m
max_err = nearest_err(v_recv, truth_v)
m["max_abs_err"] = max_err
fidelity_ok = max_err <= tol
m["fidelity_ok"] = bool(fidelity_ok)
gap_ok, gap_frac, n_gaps, max_gap = gap_check(t_recv)
m.update(gap_ok=bool(gap_ok), gap_frac=round(gap_frac, 4),
n_gaps=n_gaps, max_gap=max_gap)
if not gap_ok:
m["reason"] = (f"data hole: {gap_frac:.1%} of capture span in "
f"{n_gaps} gaps >10x median spacing (max={max_gap:.4g}s)")
shape_ok = True
if gt["formula"] == "sine" and v_recv.size >= 8 and gt["freq"]:
corr, nrmse, amp = sine_shape(t_recv, v_recv, gt["freq"])
# Shape is a *gross frequency-sanity gate* plus a *tracked quality
# metric*, not a tight correctness gate. Signal values are bit-faithful
# (the fidelity oracle proves that); the gap from a perfect fit is
# almost entirely x-axis timestamp jitter: the hub assigns wall-clock
# times without per-sample calibration (Phase-A) and the FULL_ARRAY
# packed-timestamp decode is incomplete (Phase-A4) — both pending. For a
# correct sinusoid that yields corr ~0.82-0.98 (more for arrays); a
# wrong-frequency or corrupted signal collapses to corr ~0.00. So the
# gate (corr>=0.5, nRMSE<=0.30) reliably rejects gross corruption with a
# wide margin, while corr/nRMSE are recorded so the report can trend
# them toward 1.0/0.0 as the timestamping work lands (progression).
nrmse_tol = 0.30 + (step / (gt["range_max"] - gt["range_min"])
if gt["quant"] != "none" else 0.0)
shape_ok = corr >= 0.5 and nrmse <= nrmse_tol
m.update(corr=corr, nrmse=nrmse, amp_fit=amp,
nrmse_tol=nrmse_tol, shape_ok=bool(shape_ok),
shape_gate="gross")
fed_ok = True
if tap_v is not None and tap_v.size:
fed_err = nearest_err(v_recv, tap_v.astype(np.float64))
fed_ok = fed_err <= tol
m.update(fed_err=fed_err, fed_ok=bool(fed_ok))
m["pass"] = bool(fidelity_ok and shape_ok and fed_ok and gap_ok)
return m
# ── driver ────────────────────────────────────────────────────────────────────
def validate(scenario, received, tap, checks):
gt = G.build_ground_truth(scenario)
recv = read_received(received)
tap_cols = read_marte_binary(tap) if tap and os.path.exists(tap) else None
sigs = {}
overall = True
for key, (t, v) in sorted(recv.items()):
# key is "src:sig" or "src:sig[i]" (per-element array push)
base = key.split("[")[0]
if base not in gt:
sigs[key] = {"pass": True, "note": "no ground truth (skipped)",
"n_recv": int(v.size)}
continue
tap_v = None
if tap_cols is not None:
sig_name = base.split(":", 1)[1]
if sig_name in tap_cols:
tap_v = tap_cols[sig_name].reshape(-1)
m = compare_signal(gt[base], t, v, tap_v)
sigs[key] = m
overall = overall and m.get("pass", False)
# roll in client checks
client = {}
if checks and os.path.exists(checks):
with open(checks) as f:
client = json.load(f)
live_ok = client.get("live", {}).get("ok", False)
zoom_ok = all(z.get("inrange", False) for z in client.get("zoom", [])) \
if client.get("zoom") else True
win = client.get("window", {})
window_ok = win.get("ok", True) if win.get("returned", 0) else True
trig = client.get("trigger", [])
trig_ok = all(t.get("fired", False) and t.get("windowOk", False)
for t in trig) if trig else True
overall = overall and live_ok and zoom_ok and window_ok and trig_ok
client["_rollup"] = {"live_ok": live_ok, "zoom_ok": zoom_ok,
"window_ok": window_ok, "trigger_ok": trig_ok}
return {"scenario": scenario["id"], "signals": sigs,
"client": client, "pass": bool(overall)}
def _selftest():
import math
t = np.linspace(0, 1, 200)
truth = np.sin(2 * np.pi * 3 * t)
gt = {"v": truth, "type": "float32", "quant": "uint16", "formula": "sine",
"freq": 3.0, "range_min": -1.0, "range_max": 1.0, "elements": 1,
"rows": 200, "is_time": False, "t": t, "dt": t[1] - t[0]}
_, step = _tol(gt)
good = truth + (np.random.rand(200) - 0.5) * step # ≤ step/2
bad = truth + (np.random.rand(200) - 0.5) * step * 8 # ≫ step
mg = compare_signal(gt, t, good)
mb = compare_signal(gt, t, bad)
assert mg["pass"], f"good should pass: {mg}"
assert not mb["pass"], f"bad should fail: {mb}"
# bit-exact integer
gi = dict(gt); gi.update(type="uint32", quant="none",
v=np.arange(200, dtype=np.float64), formula="counter")
mi = compare_signal(gi, t, np.arange(50, 150, dtype=np.float64))
assert mi["pass"], f"int subset should pass: {mi}"
mi2 = compare_signal(gi, t, np.array([1.5, 250.0]))
assert not mi2["pass"], f"int off-grid should fail: {mi2}"
print("selftest OK")
def main():
p = argparse.ArgumentParser(description="Validate received waveform")
p.add_argument("--selftest", action="store_true")
p.add_argument("--scenario")
p.add_argument("--received")
p.add_argument("--tap", default=None)
p.add_argument("--checks", default=None)
p.add_argument("--out", default=None)
args = p.parse_args()
if args.selftest:
_selftest()
sys.exit(0)
sc = next((s for s in S.SCENARIOS if s["id"] == args.scenario), None)
if sc is None:
print(f"unknown scenario {args.scenario}", file=sys.stderr)
sys.exit(2)
res = validate(sc, args.received, args.tap, args.checks)
if args.out:
with open(args.out, "w") as f:
json.dump(res, f, indent=2)
print(f"{sc['id']}: {'PASS' if res['pass'] else 'FAIL'}")
for k, m in res["signals"].items():
print(f" {k}: pass={m.get('pass')} "
f"err={m.get('max_abs_err','-')} corr={m.get('corr','-')}")
sys.exit(0 if res["pass"] else 1)
if __name__ == "__main__":
main()