#!/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": "= 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)