Files
MARTe-Integrated-Components/Test/E2E/suite/scenarios.py
T
2026-07-02 10:10:57 +02:00

685 lines
33 KiB
Python

#!/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 = []
kind = s.get("kind", "chain")
if kind == "direct":
if "cfg" not in s:
errs.append(f"direct scenario {s.get('id')} missing cfg")
return errs
if kind == "recorder":
if "hub_cfg" not in s or "marte_cfg" not in s:
errs.append(f"recorder scenario {s.get('id')} missing hub_cfg/marte_cfg")
return errs
if kind in ("debug", "tcplogger", "debug_pause_resume"):
if not all(k in s for k in ("cfg", "cmd_port", "udp_port", "log_port")):
errs.append(f"{kind} scenario {s.get('id')} missing cfg/cmd_port/udp_port/log_port")
return errs
if kind != "chain":
errs.append(f"unknown scenario kind: {kind}")
return 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)
for _s in SCENARIOS:
_s.setdefault("kind", "chain")
# ── Non-chain scenario kinds ──────────────────────────────────────────────────
# "direct" and "recorder" scenarios exercise the other two data paths described
# in ARCHITECTURE.md (direct UDPStreamer<->UDPStreamerClient, and StreamHub's
# BinaryRecorder disk sink) and intentionally do not carry the chain-only keys
# (sources/network/oracle/...): they have their own orchestration in run_e2e.sh.
_DIRECT = [
{
"id": "s52_direct_unicast",
"desc": "Direct UDPStreamer->UDPStreamerClient round-trip, unicast",
"kind": "direct",
"cfg": "Test/E2E/datasources/E2ETest.cfg",
"client_checks": [],
"known_issue": None,
},
{
"id": "s53_direct_multicast",
"desc": "Direct UDPStreamer->UDPStreamerClient round-trip, multicast",
"kind": "direct",
"cfg": "Test/E2E/datasources/E2EMulticastTest.cfg",
"client_checks": [],
"known_issue": None,
},
]
_RECORDER = [
{
"id": "s54_recorder",
"desc": "StreamHub BinaryRecorder disk-output round-trip",
"kind": "recorder",
"hub_cfg": "Test/E2E/recorder/StreamHubRec.cfg",
"marte_cfg": "Test/E2E/recorder/RecorderStreamer.cfg",
"client_checks": [],
"known_issue": None,
},
]
_DEBUG = [
{
"id": "s55_debug_force_trace_break",
"desc": "DebugService FORCE/TRACE/BREAK over real TCP 8080 + UDP 8081",
"kind": "debug",
"cfg": "Test/E2E/suite/debug_e2e.cfg",
"cmd_port": 8080, "udp_port": 8081, "log_port": 9090,
"client_checks": [],
"known_issue": None,
},
]
_TCPLOGGER = [
{
"id": "s56_tcplogger_delivery",
"desc": "TCPLogger delivers a log line for a triggered DebugService event",
"kind": "tcplogger",
"cfg": "Test/E2E/suite/debug_e2e.cfg",
"cmd_port": 8080, "udp_port": 8081, "log_port": 9090,
"client_checks": [],
"known_issue": None,
},
]
_DEBUG_PAUSE_RESUME = [
{
"id": "s57_debug_pause_resume",
"desc": "DebugService PAUSE/RESUME halts and resumes the RT loop, "
"verified via live VALUE polling (not just command acks)",
"kind": "debug_pause_resume",
"cfg": "Test/E2E/suite/debug_e2e.cfg",
"cmd_port": 8080, "udp_port": 8081, "log_port": 9090,
"client_checks": [],
"known_issue": None,
},
]
SCENARIOS = SCENARIOS + _DIRECT + _RECORDER + _DEBUG + _TCPLOGGER + _DEBUG_PAUSE_RESUME
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["kind"] == "chain":
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)