// 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`.],
)
