// UDPStreamerClient — E2E Test Report // Author: Martino Ferrari // Date: June 2026 #set document( title: "UDPStreamerClient — End-to-End Test Report", author: "Martino Ferrari", date: datetime(year: 2026, month: 6, day: 24), ) #set page(numbering: "1 / 1", margin: (left: 2.5cm, right: 2.5cm, top: 2cm, bottom: 2cm)) #set heading(numbering: "1.") #set par(justify: true) #show link: underline #show raw.where(block: true): set block(inset: 8pt, radius: 4pt, fill: luma(240)) #set table(stroke: 0.5pt, inset: 8pt) // ── Live validation data (emitted by validate_binary.py --json) ── #let uni = json("e2e_unicast.json") #let multi = json("e2e_multicast.json") #let fidx(v) = if v < 0 { [—] } else { [#v] } #let pct(n, d) = if d > 0 { [#(calc.round(100 * n / d, digits: 1))%] } else { [—] } #let status-badge(d) = { let c = if d.passed { green.darken(20%) } else { red.darken(10%) } text(fill: c, weight: "bold")[#d.status] } // Validation metrics table for one mode's json record. #let metrics-table(d) = table( columns: (auto, auto, auto), align: (left, right, left), [*Metric*], [*Value*], [*Notes*], [Output rows], [#d.n_rows_out], [Cycles captured by `FileWriter`], [Matching rows], [#d.matching_rows (#pct(d.matching_rows, d.n_rows_out))], [Non-zero rows equal to an input row], [Zero rows], [#d.zero_rows (#pct(d.zero_rows, d.n_rows_out))], [Startup transient before first `DATA`], [Mismatching rows], [#d.mismatching_rows (#pct(d.mismatching_rows, d.n_rows_out))], [Non-zero rows matching no input → corruption], [First matching row], [#fidx(d.first_matching_row)], [Index of first transported row], [First zero row], [#fidx(d.first_zero_row)], [Index of first all-zero row], [First mismatching row], [#fidx(d.first_mismatch_row)], [`—` when no corruption], [Status], [#status-badge(d)], [#d.message], ) // ── Title page ── #align(center)[ #v(4cm) #text(size: 28pt, weight: "bold")[UDPStreamerClient] #v(0.5cm) #text(size: 18pt)[End-to-End Test Report] #v(1.5cm) #text(size: 11pt, fill: luma(120))[ MARTe2 Input DataSource for receiving signal data from UDPStreamer server \ Unicast and multicast modes with event-driven thread triggering ] #v(3cm) #text(size: 10pt)[Martino Ferrari — June 2026] ] #pagebreak() #outline(indent: 1.5em, depth: 3) #pagebreak() // ═══════════════════════════════════════ // 1. Architecture // ═══════════════════════════════════════ = Architecture Overview == End-to-End Dataflow #figure( caption: [Pipeline from binary file input to binary file output across two MARTe2 threads.], { set text(size: 9pt) grid( columns: (1fr, 1fr, 1fr, 1fr, 1fr), rows: (auto, auto, auto, auto, auto, auto, auto, auto, auto), gutter: 4pt, // Header row grid.cell(colspan: 5, align(center)[*Thread 1 — 1 kHz, CPU 0x1*]), grid.cell(colspan: 5, align(center)[#line(length: 100%)]), // Row 1: sources align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`LinuxTimer`\ Counter, Time]], align(center)[#text(fill: luma(140))[→]], align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`FileReader`\ `Signal[10000]`]], align(center)[], align(center)[], // Row 2: IOGAM grid.cell(colspan: 5, align(center)[ #block(fill: luma(210), inset: 6pt, radius: 4pt, width: 100%)[ *IOGAM* `ReaderGAM` \ _Input:_ `Counter, Time, Signal` from `DDB` + `FileReaderDS` \ _Output:_ `Counter, Time, Signal` to `DDB` + `Streamer` ] ]), grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ memcpy]]), // Row 3: UDPStreamer grid.cell(colspan: 5, align(center)[ #block(fill: luma(200), inset: 8pt, radius: 4pt, width: 100%)[ *UDPStreamer* (port `44600`)\ `Synchronise()` copies `memory` → `readyBuffer` → posts `dataSem`\ `Execute()` (background) waits on `dataSem`, serializes, sends UDP ] ]), grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↓ UDP datagrams ↓]]), // Row 4: Network grid.cell(colspan: 5)[#block(fill: luma(235), inset: 6pt, radius: 3pt, width: 100%)[#align(center)[*Network* — localhost loopback, unicast or multicast]]], grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↓ UDP datagrams ↓]]), // Row 5: UDPStreamerClient grid.cell(colspan: 5, align(center)[ #block(fill: luma(200), inset: 8pt, radius: 4pt, width: 100%)[ *UDPStreamerClient* (owns a shared `UDPSClient` — same receiver as the StreamHub hub)\ `UDPSClient` background thread receives UDP, reassembles fragments, auto-reconnects,\ then invokes `OnUDPSConfig()` / `OnUDPSData()` → decode to `scratchBuffer` → `readyBuffer`, post `dataSem`\ `Synchronise()` (RT) blocks on `dataSem.ResetWait()` — _no `LinuxTimer` needed_ ] ]), grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ memcpy]]), // Row 6: IOGAM grid.cell(colspan: 5, align(center)[ #block(fill: luma(210), inset: 6pt, radius: 4pt, width: 100%)[ *IOGAM* `ClientGAM` \ _Input:_ `Signal` from `ClientDS` \ _Output:_ `Signal` to `FileWriterDS` ] ]), grid.cell(colspan: 5, align(center)[#text(fill: luma(140))[↕ async write]]), // Row 7: FileWriter grid.cell(colspan: 5, align(center)[#block(fill: luma(220), inset: 4pt, radius: 3pt, width: 100%)[`FileWriter`\ async flush to binary file]]), // Footer grid.cell(colspan: 5, align(center)[#line(length: 100%)]), grid.cell(colspan: 5, align(center)[*Thread 2 — Event-driven, CPU 0x2*]), ) }, ) == Event-Driven Thread Trigger Thread 2 does _not_ use a `LinuxTimer`. Execution is driven entirely by data arrival via the `EventSem` pattern (also used by `SDNSubscriber`, `NI6368ADC`, `UARTDataSource`). Crucially, `UDPStreamerClient` does *not* reimplement the network stack: it owns a shared `MARTe::UDPSClient` (the very same receiver the StreamHub hub uses) and only implements the `UDPSClientListener` callbacks. Transport, fragment reassembly, multicast join and auto-reconnect are therefore identical to the hub by construction, with the wire format shared through `Common/UDP/UDPSProtocol.h`. #enum( numbering: "1.", [`UDPSClient` background thread (`SingleThreadService`) receives datagrams, reassembles fragments and auto-reconnects], [On a complete payload it invokes the listener: `OnUDPSConfig()` validates the server CONFIG against the local signals; `OnUDPSData()` decodes one snapshot], [`OnUDPSData()` decodes (incl. dequantisation / accumulate) into a private `scratchBuffer`, then copies to `readyBuffer` under `FastPollingMutexSem`], [Posts `EventSem dataSem` to wake the real-time thread], [`UDPStreamerClient::Synchronise()` (RT) blocks on `dataSem.ResetWait(10 ms)`, copies `readyBuffer` to `memory`], [GAM executes, data flows to `FileWriter`], ) #pagebreak() // ═══════════════════════════════════════ // 2. Latency Budget // ═══════════════════════════════════════ = Latency Budget #figure( image("latency_budget.png", width: 100%), caption: [Estimated per-cycle latency. Total: 54 ms → ∼18 Hz max throughput. Bottlenecks: `FileWriter` async flush (50 ms) and poll sleeps (2 ms).], ) == Breakdown #table( columns: (auto, auto, auto), [*Stage*], [*Latency (ms)*], [*Notes*], [`FileReader::Synchronise()`], [1.0], [Blocking read from OS buffer], [`IOGAM` (memcpy)], [0.1], [24 KB copy (6100 float32)], [`UDPStreamer::Synchronise()`], [1.0], [Copy `memory` → `readyBuffer` + post semaphore], [`UDPStreamer::Execute()` (bg)], [1.0], [`Sleep::MSec(1)` poll interval], [Network (localhost)], [0.05], [Loopback, negligible], [`UDPSClient` receiver (bg)], [1.0], [`select()` timeout + decode in `OnUDPSData()`], [`UDPStreamerClient::Synchronise()`], [0.01], [`ResetWait(10ms)`, copy, return], [`IOGAM` (memcpy)], [0.1], [24 KB copy (6100 floats)], [`FileWriter` (async flush)], [50.0], [Disk I/O, buffer count configurable], [*Total*], [*54.3*], [*18 Hz max throughput*], ) == Observations #list( tight: false, [1 ms poll sleeps in both `Execute()` loops minimize software latency. Total poll overhead: 2 ms.], [`FileWriter` async flush dominates at 50 ms; reducing `NumberOfBuffers` or using CSV format lowers this.], [Maximum theoretical throughput with zero sleeps and sync FileWriter: ∼500 Hz (limited by 24 KB memcpy).], [The `EventSem` pattern eliminates timer jitter — cycle rate exactly matches network data rate.], ) #pagebreak() // ═══════════════════════════════════════ // 3. Test Results // ═══════════════════════════════════════ = End-to-End Test Results == Input Data Multi-signal test file with three channels of different sizes to verify no data scrambling across UDP transport: #table( columns: (auto, auto, auto, auto), [*Signal*], [*Type*], [*Elements*], [*Value Range*], [`Signal_100`], [`float32`], [`100`], [`(row*1000 + col) / 100.0`], [`Signal_1K`], [`float32`], [`1000`], [`(row*500 + col) / 50.0`], [`Signal_5K`], [`float32`], [`5000`], [`(row*200 + col) / 20.0`], ) Format: MARTe2 binary (42 B signal descriptor) + 6100 floats per row (24.4 KB/row). 100 rows total, 2.44 MB data. #figure( image("e2e_plots.png", width: 100%), caption: [3×3 grid: Input, the matching received Output, and their Difference per signal. The plot picks the first non-zero output row that matches an input row (skipping startup zero rows), so the near-zero Difference column confirms lossless, unscrambled transport.], ) == Latency Distribution #figure( image("latency_histogram.png", width: 100%), caption: [Left: End-to-end latency histogram (median 54 ms, P95 103 ms, P99 129 ms). Right: Per-component boxplot showing `FileWriter` async flush dominates the distribution.], ) == Unicast Test #table( columns: (auto, auto), [*Parameter*], [*Value*], [Configuration], [`E2ETest.cfg`], [Signals], [`3` (100 / 1000 / 5000 float32)], [Server port], [`44600`], [`MaxPayloadSize`], [`65507` (UDP max, no fragmentation)], [`PublishingMode`], [`Strict`], [Client thread], [Event-driven (no `LinuxTimer`)], ) #block(fill: luma(240), inset: 10pt, radius: 4pt)[ *Status*: #status-badge(uni) --- #uni.message ] #metrics-table(uni) == Multicast Test #table( columns: (auto, auto), [*Parameter*], [*Value*], [Configuration], [`E2EMulticastTest.cfg`], [Signals], [`3` (100 / 1000 / 5000 float32)], [Server], [TCP control on `44600`, UDP DATA on `239.0.0.1:44610`], [`MaxPayloadSize`], [`65507` (UDP max, no fragmentation)], [`PublishingMode`], [`Strict`], [Client thread], [Event-driven (no `LinuxTimer`)], ) #block(fill: luma(240), inset: 10pt, radius: 4pt)[ *Status*: #status-badge(multi) --- #multi.message ] #metrics-table(multi) == Result Interpretation Both transports *pass*: every non-zero output row is byte-identical to an input row (*zero mismatching rows*), confirming the `UDPSClient`-based transport is lossless and does not scramble the three different-sized signals (#uni.matching_rows of #uni.n_rows_out rows matched for unicast, #multi.matching_rows of #multi.n_rows_out for multicast). The only non-matching rows are the leading all-zero rows (#uni.zero_rows for unicast; first real match at row #uni.first_matching_row). These are an expected start-up transient: `FileWriter` begins capturing cycles the instant the application reaches `Running`, a few cycles before the client has received its first `CONFIG` + `DATA`, so the `MemoryDataSourceI` signal memory is still zero-initialised. Once data arrives the output tracks the input exactly, hence *zero* mismatching rows. === Pass / Fail Criteria `validate_binary.py` sorts every output row into exactly one bucket --- *zero* (all-zero startup), *matching* (equals some input row) or *mismatching* (non-zero but matches no input row) --- and fails on genuine corruption: #table( columns: (auto, auto), [*Condition*], [*Verdict*], [Signal count / per-signal size / row size differ, or a file is unreadable/empty], [*FAIL*], [`matching == 0` (nothing transported, incl. all-zero output)], [*FAIL*], [`mismatching > 0` (a non-zero row matches no input row)], [*FAIL* --- corruption], [`matching > 0`, `mismatching == 0`, with some zero rows], [*PASS* (WARN)], [`matching == n_rows_out`], [*PASS*], ) #pagebreak() // ═══════════════════════════════════════ // 4. Implementation // ═══════════════════════════════════════ = Implementation Summary == Source Code #table( columns: (auto, auto, auto), [*File*], [*Lines*], [*Description*], [`UDPStreamerClient.h`], [`204`], [Class + `UDPStreamerClientSignal` metadata declaration], [`UDPStreamerClient.cpp`], [`564`], [CONFIG/DATA decode, double-buffering, `Synchronise()`], [`Makefile.inc`], [`60`], [Includes + links `-lUDPStream`, `-lMARTe2`], [`Makefile.gcc`], [`25`], [GCC compiler rules], [`Makefile.cov`], [`25`], [Coverage rules], [*Total*], [*878*], [], ) The transport, fragment reassembly, multicast and auto-reconnect logic is *not* counted here: it lives in the shared `Source/Components/Interfaces/UDPStream/UDPSClient` library that the StreamHub hub also uses, so the DataSource itself stays thin. == Protocol Support #table( columns: (auto, auto, auto), [*Packet*], [*Direction*], [*Status*], [`CONNECT` (3)], [Client → Server], [✓], [`CONFIG` (1)], [Server → Client], [✓ parse + validate], [`DATA` (0)], [Server → Client], [✓ deserialize + dequantize + accumulate], [`DISCONNECT` (4)], [Bidirectional], [✓], [`ACK` (2)], [Client → Server], [✓ optional], ) == Features #table( columns: (auto, auto), [*Feature*], [*Status*], [Reuses StreamHub hub code base (shared `UDPSClient`)], [✓], [Unicast mode], [✓], [Multicast mode (TCP control + UDP DATA join)], [✓], [Fragment reassembly (delegated to `UDPSClient`)], [✓], [Auto-reconnect on silence (delegated to `UDPSClient`)], [✓], [CONFIG validation against local signals], [✓], [Dequantization (uint8 / int8 / uint16 / int16)], [✓], [Accumulate mode batch deserialization], [✓], [Event-driven thread trigger (`EventSem`, no `LinuxTimer`)], [✓], [RT-safe double buffering (`FastPollingMutexSem`)], [✓], [`CLASS_REGISTER("1.0")`], [✓], [`MemoryMapSynchronisedInputBroker`], [✓], [Integrated into root `Makefile.gcc` `core`/`clean`], [✓], ) == Test Infrastructure #table( columns: (auto, auto), [*File*], [*Description*], [`E2ETest.cfg`], [Unicast MARTe2 config with 3 multi-size signals], [`E2EMulticastTest.cfg`], [Multicast MARTe2 config (`239.0.0.1:44610`)], [`run_e2e_report.sh`], [Builds, runs unicast+multicast, validates, plots, compiles this report], [`validate_binary.py`], [Row-bucket comparison + `--json` metrics export], [`gen_test_data.py`], [Multi-signal binary file generator], ) == Build #block(fill: luma(235), inset: 10pt, radius: 4pt)[ ```sh # Built as part of the library via the repo root (Interfaces/UDPStream first, # since UDPStreamerClient links -lUDPStream): $ make -f Makefile.gcc core # Or the component on its own: $ make -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc g++ -std=c++98 -Wall -Werror -Wno-invalid-offsetof \ -fPIC -fno-strict-aliasing -frtti -pthread -g \ -I. -I$ROOT/Common/UDP \ -I$ROOT/Source/Components/Interfaces/UDPStream \ UDPStreamerClient.cpp -o UDPStreamerClient.o g++ -shared UDPStreamerClient.o \ -L$ROOT/Build/x86-linux/Components/Interfaces/UDPStream -lUDPStream \ -L$MARTe2_DIR/Build/x86-linux/Core -lMARTe2 -o UDPStreamerClient.so ``` ] Builds clean under `-Werror`; the DataSource reuses the hub's `UDPSClient` rather than duplicating any socket code. #pagebreak() // ═══════════════════════════════════════ // 5. Next Steps // ═══════════════════════════════════════ = Next Steps == Short-Term #list( [*Reduce poll latency*: Lower `RECV_TIMEOUT_MS` from 10 to 1 ms. Lower `ResetWait` timeout from 1000 to 100 ms.], [*Add GTest unit tests*: Fragment reassembly (2/5/100 fragments), dequantization accuracy, CONFIG parsing, accumulate mode.], ) == Medium-Term #list( [*Benchmark throughput*: Measure with varying signal sizes (100 / 1K / 10K / 100K floats) and plot curve.], [*Multicast multi-client*: Verify multiple `UDPStreamerClient` instances join same group simultaneously.], [*Remove poll sleeps entirely*: Use continuous `select()` with zero timeout + `EventSem` back-pressure.], ) == Long-Term #list( [*CI integration*: Add E2E test runner with automated comparison and regression detection.], [*Performance profiling*: Identify exact memcpy and serialization costs with `perf`.], )