test(e2e-chain): orchestrator + end-to-end fixes (Task 7)

Full-chain orchestrator (run_chain_e2e.sh) now runs the starter set green
(3 pass). Fixes found bringing the chain up end-to-end:

- client: gorilla/websocket cannot survive a read deadline (next ReadMessage
  panics "repeated read on failed connection"); replace the poll-with-deadline
  loop with a background reader goroutine + mutex-guarded state.
- orchestrator: guard env.sh's unbound LD_LIBRARY_PATH under set -u.
- scenarios/gen_data: centralize NUM_ROWS/ROW_DT and enforce sine freq to be a
  multiple of the buffer fundamental (LOOP_HZ=5 Hz) so the looped FileReader
  buffer is a seamless waveform; align starter freqs (5/5/10 Hz).
- gen_cfg: FileReader allows exactly one consuming Function, so route tapped
  (oracle=fed/both) sources through the DDB (ReaderGAM->DDB, then StreamGAM and
  TapGAM both read DDB) instead of a second FileReader consumer.
- validate_waveform: fidelity gates correctness (bit-exact / within one quant
  level); sine shape becomes a gross frequency-sanity gate (corr>=0.5) plus a
  tracked corr/nRMSE quality metric, since per-sample wall-clock calibration
  (Phase-A) and FULL_ARRAY packed timestamps (Phase-A4) are still pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-25 13:11:35 +02:00
parent d4fa84d482
commit f256a4def5
6 changed files with 400 additions and 40 deletions
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env bash
# run_chain_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_chain_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
while [ $# -gt 0 ]; do
case "$1" in
--skip-build) SKIP_BUILD=1 ;;
--only) shift; ONLY="$1" ;;
--pdf-only) PDF_ONLY=1 ;;
--help|-h) echo "Usage: $0 [--skip-build] [--only <id>] [--pdf-only]"; 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/chain/scenarios.py")))
sys.path.insert(0, os.path.join(os.getcwd(), "Test/E2E/chain"))
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
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
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 ───────────────────────────────────────────────────
WORK="${WORK}" OUT_DIR="${OUT_DIR}" SCEN_IDS="${SCEN_IDS}" ${PY} - <<'PY'
import json, os
work = os.environ["WORK"]; out = os.environ["OUT_DIR"]
ids = os.environ["SCEN_IDS"].split()
results = []
for sid in ids:
rec = {"id": sid}
st = os.path.join(work, f"status_{sid}.txt")
rec["status"] = open(st).read().strip() if os.path.exists(st) else "UNKNOWN"
mp = os.path.join(work, f"metrics_{sid}.json")
if os.path.exists(mp):
rec["metrics"] = json.load(open(mp))
results.append(rec)
overall = all(r["status"] in ("PASS", "SKIP") for r in results) and bool(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)
print(f"\nresults.json: {sum(r['status']=='PASS' for r in results)} pass, "
f"{sum(r['status']=='FAIL' for r in results)} fail, "
f"{sum(r['status']=='SKIP' for r in results)} skip → {doc['overall']}")
PY
# ── Optional 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
(cd "${OUT_DIR}" && typst compile E2E_Report.typ E2E_Report.pdf 2>/dev/null) \
&& echo "PDF: ${OUT_DIR}/E2E_Report.pdf"
fi
echo "Done — artifacts in ${OUT_DIR} and ${WORK}"