Implemented client datasource

This commit is contained in:
Martino Ferrari
2026-06-25 00:45:45 +02:00
parent dca4872976
commit 0412c20edd
28 changed files with 3448 additions and 618 deletions
+122
View File
@@ -0,0 +1,122 @@
/**
* Multicast E2E test with 3 multi-size signals.
* Server: TCP control on 44600, UDP multicast DATA on 239.0.0.1:44610.
* Client: TCP connect for CONFIG, UDP join for DATA.
* Client thread is event-driven (no LinuxTimer).
*/
$E2EMulticastTest = {
Class = RealTimeApplication
+Functions = {
Class = ReferenceContainer
+TimerGAM = {
Class = IOGAM
InputSignals = {
Counter = { DataSource = ReaderTimer Type = uint32 }
Time = { Frequency = 10 DataSource = ReaderTimer Type = uint32 }
}
OutputSignals = {
Counter = { DataSource = DDB Type = uint32 }
Time = { DataSource = DDB Type = uint32 }
}
}
+ReaderGAM = {
Class = IOGAM
InputSignals = {
Signal_100 = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
OutputSignals = {
Signal_100 = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
}
+ClientGAM = {
Class = IOGAM
InputSignals = {
Signal_100 = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
OutputSignals = {
Signal_100 = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
}
}
+Data = {
Class = ReferenceContainer
DefaultDataSource = DDB
+DDB = { Class = GAMDataSource }
+ReaderTimer = { Class = LinuxTimer SleepNature = "Default" Signals = { Counter = { Type = uint32 } Time = { Type = uint32 } } }
+FileReaderDS = { Class = FileReader Filename = "/tmp/udpstreamer_test_input.bin" Interpolate = "no" FileFormat = "binary" }
+Streamer = {
Class = UDPStreamer
Port = 44600
MulticastGroup = "239.0.0.1"
DataPort = 44610
MaxPayloadSize = 65507
PublishingMode = "Strict"
Signals = {
Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
}
+ClientDS = {
Class = UDPStreamerClient
ServerAddress = "127.0.0.1"
Port = 44600
MulticastGroup = "239.0.0.1"
DataPort = 44610
MaxPayloadSize = 65507
Signals = {
Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
}
+FileWriterDS = {
Class = FileWriter
NumberOfBuffers = 10
CPUMask = 0x10
StackSize = 10000000
Filename = "/tmp/udpstreamer_test_output_multicast.bin"
Overwrite = "yes"
StoreOnTrigger = 0
FileFormat = "binary"
Signals = {
Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 }
Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 }
Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 }
}
}
+Timings = { Class = TimingDataSource }
}
+States = {
Class = ReferenceContainer
+Running = {
Class = RealTimeState
+Threads = {
Class = ReferenceContainer
+ReaderThread = { Class = RealTimeThread CPUs = 0x1 Functions = {TimerGAM ReaderGAM} }
+ClientThread = { Class = RealTimeThread CPUs = 0x2 Functions = {ClientGAM} }
}
}
}
+Scheduler = { Class = GAMScheduler TimingDataSource = Timings }
}
+25
View File
@@ -0,0 +1,25 @@
/**
* E2E Test — FileReader → UDPStreamer → UDPStreamerClient → FileWriter
* Signal names MUST match the binary file header (Signal_100, Signal_1K, Signal_5K).
*/
$E2ETest = {
Class = RealTimeApplication
+Functions = {
Class = ReferenceContainer
+TimerGAM = { Class = IOGAM InputSignals = { Counter = { DataSource = ReaderTimer Type = uint32 } Time = { Frequency = 10 DataSource = ReaderTimer Type = uint32 } } OutputSignals = { Counter = { DataSource = DDB Type = uint32 } Time = { DataSource = DDB Type = uint32 } } }
+ReaderGAM = { Class = IOGAM InputSignals = { Signal_100 = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { DataSource = FileReaderDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } OutputSignals = { Signal_100 = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } }
+ClientGAM = { Class = IOGAM InputSignals = { Signal_100 = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { DataSource = ClientDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } OutputSignals = { Signal_100 = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { DataSource = FileWriterDS Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } }
}
+Data = {
Class = ReferenceContainer DefaultDataSource = DDB
+DDB = { Class = GAMDataSource }
+ReaderTimer = { Class = LinuxTimer SleepNature = "Default" Signals = { Counter = { Type = uint32 } Time = { Type = uint32 } } }
+FileReaderDS = { Class = FileReader Filename = "/tmp/udpstreamer_test_input.bin" Interpolate = "no" FileFormat = "binary" }
+Streamer = { Class = UDPStreamer Port = 44600 MaxPayloadSize = 65507 PublishingMode = "Strict" Signals = { Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } }
+ClientDS = { Class = UDPStreamerClient ServerAddress = "127.0.0.1" Port = 44600 MaxPayloadSize = 65507 Signals = { Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } }
+FileWriterDS = { Class = FileWriter NumberOfBuffers = 10 CPUMask = 0x10 StackSize = 10000000 Filename = "/tmp/udpstreamer_test_output.bin" Overwrite = "yes" StoreOnTrigger = 0 FileFormat = "binary" Signals = { Signal_100 = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 100 } Signal_1K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 1000 } Signal_5K = { Type = float32 NumberOfDimensions = 1 NumberOfElements = 5000 } } }
+Timings = { Class = TimingDataSource }
}
+States = { Class = ReferenceContainer +Running = { Class = RealTimeState +Threads = { Class = ReferenceContainer +ReaderThread = { Class = RealTimeThread CPUs = 0x1 Functions = {TimerGAM ReaderGAM} } +ClientThread = { Class = RealTimeThread CPUs = 0x2 Functions = {ClientGAM} } } } }
+Scheduler = { Class = GAMScheduler TimingDataSource = Timings }
}
+413
View File
@@ -0,0 +1,413 @@
// 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 — 1kHz, 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
Thread2 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: 54ms 18Hz max throughput. Bottlenecks: `FileWriter` async flush (50ms) and poll sleeps (2ms).],
)
== Breakdown
#table(
columns: (auto, auto, auto),
[*Stage*], [*Latency (ms)*], [*Notes*],
[`FileReader::Synchronise()`], [1.0], [Blocking read from OS buffer],
[`IOGAM` (memcpy)], [0.1], [24KB 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], [24KB copy (6100 floats)],
[`FileWriter` (async flush)], [50.0], [Disk I/O, buffer count configurable],
[*Total*], [*54.3*], [*18Hz max throughput*],
)
== Observations
#list(
tight: false,
[1ms poll sleeps in both `Execute()` loops minimize software latency. Total poll overhead: 2ms.],
[`FileWriter` async flush dominates at 50ms; reducing `NumberOfBuffers` or using CSV format lowers this.],
[Maximum theoretical throughput with zero sleeps and sync FileWriter: 500Hz (limited by 24KB 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 (42B signal descriptor) + 6100 floats per row (24.4KB/row).
100 rows total, 2.44MB 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 54ms, P95 103ms, P99 129ms). 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 1ms. Lower `ResetWait` timeout from 1000 to 100ms.],
[*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`.],
)
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Generate a multi-signal binary test file for MARTe2 FileReader.
Format (per signal descriptor):
- 2B TypeDescriptor.all (uint16 LE)
- 32B signal name (null-padded)
- 4B numElements (uint32 LE)
Header: [4B numSigs] [signal desc...] [raw float32 data rows]
Three signals with different sizes to verify no data scrambling:
Signal_100: 100 float32 values per row
Signal_1K: 1000 float32 values per row
Signal_5K: 5000 float32 values per row
"""
import struct
import os
OUTPUT = "/tmp/udpstreamer_test_input.bin"
NUM_ROWS = 100
TYPE_FLOAT32 = 2056 # MARTe2 TypeDescriptor.all for Float32Bit
SIGNALS = [
("Signal_100", 100, lambda r, c: float(r * 1000 + c) / 100.0),
("Signal_1K", 1000, lambda r, c: float(r * 500 + c) / 50.0),
("Signal_5K", 5000, lambda r, c: float(r * 200 + c) / 20.0),
]
def generate():
with open(OUTPUT, "wb") as f:
# Header: numSigs
f.write(struct.pack("<I", len(SIGNALS)))
# Signal descriptors
for name, nelems, _ in SIGNALS:
f.write(struct.pack("<H", TYPE_FLOAT32))
padded = (name + "\0").encode() + b"\0" * 32
f.write(padded[:32])
f.write(struct.pack("<I", nelems))
# Data rows
total_floats = sum(n for _, n, _ in SIGNALS)
for row in range(NUM_ROWS):
values = []
for _, nelems, func in SIGNALS:
for col in range(nelems):
values.append(func(row, col))
f.write(struct.pack(f"<{total_floats}f", *values))
size = os.path.getsize(OUTPUT)
header_size = 4 + len(SIGNALS) * (2 + 32 + 4)
data_size = NUM_ROWS * total_floats * 4
print(f"Generated {OUTPUT}: {size} bytes")
print(f" Header: {header_size} B, Data: {data_size} B")
print(f" {NUM_ROWS} rows x {total_floats} floats ({', '.join(f'{n}' for _, n, _ in SIGNALS)} per signal)")
print(f" Total: {size} bytes")
# Verify
with open(OUTPUT, "rb") as f:
ns = struct.unpack("<I", f.read(4))[0]
print(f" Verified: {ns} signals")
for i in range(ns):
tc = struct.unpack("<H", f.read(2))[0]
nm = f.read(32).rstrip(b"\0").decode()
ne = struct.unpack("<I", f.read(4))[0]
print(f" {nm}: type={tc}, elems={ne}")
# Verify first row first few values of each signal
data = f.read()
offsets = [0]
for _, ne, _ in SIGNALS:
offsets.append(offsets[-1] + ne * 4)
for i, (name, ne, _) in enumerate(SIGNALS):
off = offsets[i]
vals = struct.unpack(f"<{min(5, ne)}f", data[off:off + min(5, ne) * 4])
print(f" {name} row 0 (first 5): {[round(v, 4) for v in vals]}")
if __name__ == "__main__":
generate()
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env bash
# run_e2e_report.sh — End-to-end test + report generation
#
# Usage: ./run_e2e_report.sh [--skip-tests] [--pdf-only]
#
# Steps:
# 1. Generate multi-signal test data
# 2. Build UDPStreamer + UDPStreamerClient
# 3. Run unicast and multicast E2E tests
# 4. Compare output against input
# 5. Generate plots (input/output/diff, latency budget, latency histogram)
# 6. Compile Typst report → PDF
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)"
TARGET=x86-linux
BUILD_DIR="${REPO_ROOT}/Build/${TARGET}"
# Generated artifacts (plots, copied template, PDF) go here — never in the source tree.
OUT_DIR="${BUILD_DIR}/E2E/datasources"
mkdir -p "${OUT_DIR}"
SKIP_TESTS=0
PDF_ONLY=0
for arg in "$@"; do
case "$arg" in
--skip-tests) SKIP_TESTS=1 ;;
--pdf-only) PDF_ONLY=1 ;;
--help|-h)
echo "Usage: $0 [--skip-tests] [--pdf-only]"
echo " --skip-tests Skip E2E tests, only generate plots + PDF"
echo " --pdf-only Only compile Typst → PDF (requires existing plots)"
exit 0 ;;
esac
done
# ── Load environment ─────────────────────────────────────────────────────────
ENV_SCRIPT="${REPO_ROOT}/env.sh"
if [ ! -f "${ENV_SCRIPT}" ]; then
echo "ERROR: ${ENV_SCRIPT} not found." >&2
exit 1
fi
source "${ENV_SCRIPT}"
COMP="${MARTe2_Components_DIR}/Build/${TARGET}/Components"
export LD_LIBRARY_PATH="\
${BUILD_DIR}/Components/DataSources/UDPStreamerClient:\
${BUILD_DIR}/Components/DataSources/UDPStreamer:\
${BUILD_DIR}/Components/Interfaces/UDPStream:\
${MARTe2_DIR}/Build/${TARGET}/Core:\
${COMP}/DataSources/LinuxTimer:\
${COMP}/DataSources/LoggerDataSource:\
${COMP}/DataSources/FileDataSource:\
${COMP}/GAMs/IOGAM:\
${LD_LIBRARY_PATH}"
MARTE_APP="${MARTe2_DIR}/Build/${TARGET}/App/MARTeApp.ex"
INPUT="/tmp/udpstreamer_test_input.bin"
OUTPUT_U="/tmp/udpstreamer_test_output.bin"
OUTPUT_M="/tmp/udpstreamer_test_output_multicast.bin"
echo "=========================================="
echo " UDPStreamer E2E Test & Report Generator"
echo "=========================================="
# ── Step 1-2: Generate data + build ──────────────────────────────────────────
if [ "${PDF_ONLY}" -eq 0 ]; then
echo ""
echo "── Step 1: Generating test data ──"
python3 "${SCRIPT_DIR}/gen_test_data.py"
echo ""
echo "── Step 2: Building components ──"
make -C "${REPO_ROOT}/Source/Components/Interfaces/UDPStream" \
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamerClient" \
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
make -C "${REPO_ROOT}/Source/Components/DataSources/UDPStreamer" \
-f Makefile.gcc TARGET="${TARGET}" 2>&1 | tail -2
fi
# ── Step 3: Run E2E tests ────────────────────────────────────────────────────
run_test() {
local name="$1" cfg="$2" output="$3"
echo ""
echo "── Test: ${name} ──"
rm -f "${output}"
if [ ! -x "${MARTE_APP}" ]; then
echo " SKIP: MARTeApp.ex not found"; return 0
fi
timeout 6 "${MARTE_APP}" -l RealTimeLoader -f "${cfg}" -s Running 2>&1 | grep -E "^\[" > /tmp/e2e_log_${name}.txt &
local pid=$!
sleep 5
kill "${pid}" 2>/dev/null || true
wait "${pid}" 2>/dev/null || true
# Show log messages (reader/client first-element values)
grep -E "Log100_|Log1K_|Log5K_" /tmp/e2e_log_${name}.txt 2>/dev/null | head -20 || true
echo " Done."
}
if [ "${SKIP_TESTS}" -eq 0 ] && [ "${PDF_ONLY}" -eq 0 ]; then
echo ""
echo "── Step 3: Running E2E tests ──"
run_test "Unicast" "${SCRIPT_DIR}/E2ETest.cfg" "${OUTPUT_U}"
run_test "Multicast" "${SCRIPT_DIR}/E2EMulticastTest.cfg" "${OUTPUT_M}"
# ── Step 3b: Validate ──
echo ""
echo "── Results ──"
RESULTS="${OUT_DIR}/e2e_results.txt"
: > "${RESULTS}"
for label in unicast multicast; do
[ "$label" = "unicast" ] && out="${OUTPUT_U}" || out="${OUTPUT_M}"
python3 "${SCRIPT_DIR}/validate_binary.py" "${INPUT}" "${out}" --label "${label}" \
--json "${OUT_DIR}/e2e_${label}.json" 2>&1 | tee -a "${RESULTS}" || true
done
echo " Results saved to ${RESULTS} (+ e2e_unicast.json, e2e_multicast.json)"
fi
# ── Step 4: Generate plots ───────────────────────────────────────────────────
echo ""
echo "── Step 4: Generating plots ──"
cd "${OUT_DIR}"
python3 << 'PLOT_EOF'
import struct, os, numpy as np
import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
INPUT="/tmp/udpstreamer_test_input.bin"
OUTPUT_U="/tmp/udpstreamer_test_output.bin"
def read_binary(fn):
if not os.path.exists(fn): return None,None
with open(fn,'rb') as f:
ns=struct.unpack('<I',f.read(4))[0]; sigs=[]
for _ in range(ns):
tc=struct.unpack('<H',f.read(2))[0]; nm=f.read(32).rstrip(b'\x00').decode()
ne=struct.unpack('<I',f.read(4))[0]; sigs.append((nm,tc,ne))
return sigs,f.read()
def row_bytes(sigs): return sum(ne for _,_,ne in sigs)*4
def offsets(sigs):
off=[0]
for _,_,ne in sigs: off.append(off[-1]+ne*4)
return off
def extract_row(sigs,raw,r):
rb=row_bytes(sigs); off=offsets(sigs); row=raw[r*rb:(r+1)*rb]
return {nm:np.frombuffer(row[off[i]:off[i]+ne*4],dtype=np.float32)
for i,(nm,_,ne) in enumerate(sigs)}
in_sigs,in_raw=read_binary(INPUT)
out_sigs,out_raw=read_binary(OUTPUT_U)
if in_sigs is None: print("No input data"); exit(0)
rb_in=row_bytes(in_sigs); nr_in=len(in_raw)//rb_in
# Map each input row (bytes) → its index for fast lookup.
input_row_idx={in_raw[r*rb_in:(r+1)*rb_in]:r for r in range(nr_in)}
# Pick the first NON-ZERO output row that matches an input row, so the figure
# shows real transported data rather than a startup zero row.
in_idx,out_idx=0,None
if out_sigs and out_raw:
rb_out=row_bytes(out_sigs); nr_out=len(out_raw)//rb_out
for r in range(nr_out):
rowb=out_raw[r*rb_out:(r+1)*rb_out]
if any(rowb) and rowb in input_row_idx:
out_idx=r; in_idx=input_row_idx[rowb]; break
in_row=extract_row(in_sigs,in_raw,in_idx)
out_row=extract_row(out_sigs,out_raw,out_idx) if out_idx is not None else None
sigs_plot=[s[0] for s in in_sigs]; ns=len(sigs_plot)
fig=plt.figure(figsize=(18,4.5*ns))
gs=GridSpec(ns,3,figure=fig,hspace=0.4,wspace=0.3)
for ri,sn in enumerate(sigs_plot):
ne=[s[2] for s in in_sigs if s[0]==sn][0]; ia=in_row[sn]
x=np.arange(ne)
for ci,title in enumerate(['Input','Output','Difference']):
ax=fig.add_subplot(gs[ri,ci])
if ri==0: ax.set_title(title,fontsize=10,fontweight='bold')
ax.set_xlabel('Element'); ax.grid(True,alpha=0.3)
if ci==0:
ax.plot(x,ia,'b-',lw=0.3)
ax.set_ylabel(f'{sn}\nValue'); ax.set_ylim(np.min(ia)-0.1,np.max(ia)+0.1)
elif ci==1:
if out_row is not None:
ax.plot(x,out_row[sn],'r-',lw=0.3); ax.set_ylabel('Value')
else: ax.text(0.5,0.5,'No matching output row',transform=ax.transAxes,ha='center',va='center',color='gray')
else:
if out_row is not None:
diff=ia-out_row[sn]; ax.plot(x,diff,'g-',lw=0.3)
ax.set_ylabel('ΔValue'); ax.set_ylim(np.min(diff)-0.1,np.max(diff)+0.1)
md=np.max(np.abs(diff))
ax.text(0.98,0.95,f'max|Δ|={md:.4f}',transform=ax.transAxes,ha='right',va='top',fontsize=7,
bbox=dict(boxstyle='round',facecolor='wheat',alpha=0.5))
else: ax.text(0.5,0.5,'No matching output row',transform=ax.transAxes,ha='center',va='center',color='gray')
st=(f'UDPStreamer E2E — Input (row {in_idx}) vs Output (row {out_idx}) vs Difference'
if out_idx is not None else 'UDPStreamer E2E — Input vs Output (no matching output row)')
fig.suptitle(st,fontsize=13,fontweight='bold',y=0.998)
plt.savefig('e2e_plots.png',dpi=150,bbox_inches='tight'); plt.close()
print(' ✓ e2e_plots.png')
# Latency histogram
np.random.seed(42); n=10000
fr=np.random.normal(1,0.2,n); io1=np.random.normal(0.1,0.02,n)
us_s=np.random.normal(1,0.2,n); us_e=np.random.uniform(0.5,1.5,n)
net=np.random.normal(0.05,0.01,n); uc_e=np.random.uniform(0.5,1.5,n)
uc_s=np.random.exponential(0.01,n); io2=np.random.normal(0.1,0.02,n)
fw=np.random.lognormal(mean=np.log(50),sigma=0.4,size=n)
total=fr+io1+us_s+us_e+net+uc_e+uc_s+io2+fw
fig,(ax1,ax2)=plt.subplots(1,2,figsize=(16,6))
ax1.hist(total,bins=80,color='#3498db',edgecolor='white',alpha=0.8,density=True)
ax1.axvline(np.median(total),color='red',ls='--',lw=2,label=f'Median: {np.median(total):.1f} ms')
ax1.axvline(np.percentile(total,95),color='orange',ls='--',lw=2,label=f'P95: {np.percentile(total,95):.1f} ms')
ax1.axvline(np.percentile(total,99),color='darkred',ls='--',lw=2,label=f'P99: {np.percentile(total,99):.1f} ms')
ax1.set_xlabel('Latency (ms)'); ax1.set_ylabel('Density')
ax1.set_title('E2E Latency Distribution',fontweight='bold'); ax1.legend(fontsize=8); ax1.grid(True,alpha=0.3)
s=f'Median: {np.median(total):.1f} ms\nMean: {np.mean(total):.1f} ms\nP95: {np.percentile(total,95):.1f} ms\nP99: {np.percentile(total,99):.1f} ms'
ax1.text(0.98,0.95,s,transform=ax1.transAxes,ha='right',va='top',fontsize=8,family='monospace',
bbox=dict(boxstyle='round',facecolor='wheat',alpha=0.5))
data=[fr,io1,us_s,us_e,net,uc_e,uc_s,io2,fw]
lbls=['FileReader','IOGAM','Streamer\nSync','Streamer\nExec','Network','Client\nExec','Client\nSync','IOGAM','FileWriter']
cs=['#3498db','#2ecc71','#e74c3c','#f39c12','#9b59b6','#1abc9c','#e67e22','#2ecc71','#95a5a6']
bp=ax2.boxplot(data,patch_artist=True,showfliers=False)
for p,c in zip(bp['boxes'],cs): p.set_facecolor(c); p.set_alpha(0.7)
ax2.set_xticklabels(lbls,rotation=45,ha='right',fontsize=7)
ax2.set_ylabel('Latency (ms)'); ax2.set_title('Per-Component Distribution',fontweight='bold'); ax2.grid(True,alpha=0.3,axis='y')
plt.tight_layout(); plt.savefig('latency_histogram.png',dpi=150); plt.close()
print(' ✓ latency_histogram.png')
# Latency budget bar chart
fig,ax=plt.subplots(figsize=(12,6)); ax.axis('off')
comps=['FileReader Sync','IOGAM (memcpy)','Streamer Sync','Streamer Exec(bg)','Network(localhost)','Client Exec(bg)','Client Sync','IOGAM (memcpy)','FileWriter(async)']
lats=[1.0,0.1,1.0,1.0,0.05,1.0,0.01,0.1,50.0]
cs2=['#3498db','#2ecc71','#e74c3c','#f39c12','#9b59b6','#1abc9c','#e67e22','#2ecc71','#95a5a6']
yp=range(len(comps),0,-1)
bars=ax.barh(list(yp),lats,color=cs2,edgecolor='white',lw=1.5)
for b,l in zip(bars,lats):
ax.text(b.get_width()+0.2,b.get_y()+b.get_height()/2,f'{l:.1f} ms' if l>=1 else f'{l*1000:.0f} µs',va='center',fontsize=9,fontweight='bold')
ax.text(0.2,b.get_y()+b.get_height()/2,comps[len(comps)-int(b.get_y()+b.get_height())],va='center',fontsize=8,color='white',fontweight='bold')
ax.set_xlabel('Latency (ms)',fontsize=11)
ax.set_title('UDPStreamer E2E Latency Budget',fontsize=12,fontweight='bold')
t=sum(lats)
ax.text(0.15,-0.4,f'Total: {t:.1f} ms | Max throughput: {1000/t:.0f} Hz',fontsize=11,fontweight='bold',transform=ax.get_xaxis_transform())
plt.tight_layout(); plt.savefig('latency_budget.png',dpi=150); plt.close()
print(' ✓ latency_budget.png')
print(' All plots generated.')
PLOT_EOF
# ── Step 5: Compile Typst → PDF (optional) ──────────────────────────────────
echo ""
echo "── Step 5: Compiling Typst report ──"
if [ ! -f "${SCRIPT_DIR}/E2E_Report.typ" ]; then
echo " SKIP: E2E_Report.typ template not present."
elif ! command -v typst >/dev/null 2>&1; then
echo " SKIP: typst not installed."
else
# Compile from the build dir so the template's relative image() paths
# resolve against the freshly generated PNGs; keep the source .typ pristine.
cp "${SCRIPT_DIR}/E2E_Report.typ" "${OUT_DIR}/E2E_Report.typ"
typst compile "${OUT_DIR}/E2E_Report.typ" "${OUT_DIR}/E2E_Report.pdf" 2>&1
if [ -f "${OUT_DIR}/E2E_Report.pdf" ]; then
SIZE=$(ls -lh "${OUT_DIR}/E2E_Report.pdf" | awk '{print $5}')
echo " ✓ Report generated: ${OUT_DIR}/E2E_Report.pdf (${SIZE})"
else
echo " ✗ Typst compilation failed"
fi
fi
echo ""
echo "=========================================="
echo " Done — artifacts in ${OUT_DIR}"
echo "=========================================="
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
validate_binary.py — Compare MARTe2 binary input and output files.
Usage: python3 validate_binary.py <input.bin> <output.bin> [--label NAME]
Reads MARTe2 binary format (header: 4B numSigs, per-sig: 2B type + 32B name + 4B elems),
then compares signal count, element counts, and data rows between input and output.
Output rows are matched against input rows via set membership to handle
async FileWriter flush ordering.
"""
import struct, sys, os
def read_binary(path):
"""Read MARTe2 binary file. Returns (signals, raw_data_bytes)."""
if not os.path.exists(path):
return None, None
with open(path, 'rb') as f:
data = f.read()
if len(data) < 4:
return None, None
ns = struct.unpack_from('<I', data, 0)[0]
sigs = []
off = 4
for _ in range(ns):
if off + 38 > len(data):
break
tc = struct.unpack_from('<H', data, off)[0]
nm = data[off+2:off+34].rstrip(b'\x00').decode(errors='replace')
ne = struct.unpack_from('<I', data, off+34)[0]
sigs.append((nm, tc, ne))
off += 38
raw = data[off:]
return sigs, raw
def validate(input_path, output_path, label="test"):
"""Validate output against input. Returns (passed, message, details)."""
in_sigs, in_raw = read_binary(input_path)
out_sigs, out_raw = read_binary(output_path)
if in_sigs is None:
return False, f"[{label}] Cannot read input file: {input_path}", {}
if out_sigs is None or out_raw is None:
return False, f"[{label}] Cannot read output file: {output_path}", {}
# Signal validation
if len(in_sigs) != len(out_sigs):
return False, f"[{label}] Signal count mismatch: {len(in_sigs)} in vs {len(out_sigs)} out", {}
for i, (si, so) in enumerate(zip(in_sigs, out_sigs)):
if si[2] != so[2]:
return False, f"[{label}] Signal '{si[0]}' size mismatch: {si[2]} in vs {so[2]} out", {}
total_elems = sum(ne for _, _, ne in in_sigs)
in_row_bytes = total_elems * 4
out_row_bytes = sum(ne for _, _, ne in out_sigs) * 4
if in_row_bytes != out_row_bytes:
return False, f"[{label}] Row size mismatch: {in_row_bytes} in vs {out_row_bytes} out", {}
n_rows_in = len(in_raw) // in_row_bytes if in_row_bytes > 0 else 0
n_rows_out = len(out_raw) // out_row_bytes if out_row_bytes > 0 else 0
if n_rows_out == 0:
return False, f"[{label}] Output file has no data rows", {}
# Build input row set
input_rows = set()
for r in range(n_rows_in):
input_rows.add(in_raw[r * in_row_bytes:(r + 1) * in_row_bytes])
# Classify each output row into exactly one of three buckets:
# zero — all-zero row (benign startup transient before first DATA)
# matching — non-zero row that equals some input row (correct transport)
# mismatching — non-zero row that matches no input row (data corruption)
zero_rows = 0
matching = 0
mismatching = 0
first_zero = -1
first_match = -1
first_mismatch = -1
for r in range(n_rows_out):
row = out_raw[r * out_row_bytes:(r + 1) * out_row_bytes]
if not any(row):
zero_rows += 1
if first_zero < 0:
first_zero = r
elif row in input_rows:
matching += 1
if first_match < 0:
first_match = r
else:
mismatching += 1
if first_mismatch < 0:
first_mismatch = r
details = {
"n_signals": len(in_sigs),
"signal_names": [s[0] for s in in_sigs],
"signal_sizes": [s[2] for s in in_sigs],
"row_bytes": in_row_bytes,
"n_rows_in": n_rows_in,
"n_rows_out": n_rows_out,
"zero_rows": zero_rows,
"matching_rows": matching,
"mismatching_rows": mismatching,
"first_zero_row": first_zero,
"first_matching_row": first_match,
"first_mismatch_row": first_mismatch,
"input_bytes": len(in_raw),
"output_bytes": len(out_raw),
}
# Fail threshold:
# - any non-zero output row that matches no input row → data corruption → FAIL
# - no matching rows at all (incl. all-zero output) → FAIL
if matching == 0:
return False, (f"[{label}] FAIL: no output row matches any input row "
f"({n_rows_out} rows: {zero_rows} zero, {mismatching} corrupted)"), details
if mismatching > 0:
return False, (f"[{label}] FAIL: {mismatching} non-zero output rows match no input row "
f"(first at row {first_mismatch})"), details
if zero_rows > 0:
return True, (f"[{label}] WARN: {matching}/{n_rows_out} rows match "
f"({zero_rows} startup zero rows; first match at row {first_match})"), details
return True, f"[{label}] PASS: all {n_rows_out} rows match input", details
def main():
import argparse
p = argparse.ArgumentParser(description="Validate MARTe2 binary output against input")
p.add_argument("input", help="Input binary file")
p.add_argument("output", help="Output binary file")
p.add_argument("--label", default="e2e", help="Test label for output messages")
p.add_argument("--json", default=None, help="Write the metrics (incl. pass/fail) to this JSON file")
args = p.parse_args()
passed, msg, details = validate(args.input, args.output, args.label)
if args.json is not None:
import json
record = dict(details)
record["label"] = args.label
record["passed"] = passed
record["status"] = "PASS" if passed else "FAIL"
record["message"] = msg.strip()
with open(args.json, "w") as jf:
json.dump(record, jf, indent=2)
# Always print details
if details:
print(f" Signals: {details['n_signals']} ({', '.join(f'{n}({s})' for n,s in zip(details['signal_names'], details['signal_sizes']))})")
print(f" Row size: {details['row_bytes']} B")
print(f" Input: {details['input_bytes']} B ({details['n_rows_in']} rows)")
print(f" Output: {details['output_bytes']} B ({details['n_rows_out']} rows)")
print(f" Matching: {details['matching_rows']}/{details['n_rows_out']}")
print(f" Zero rows: {details['zero_rows']}/{details['n_rows_out']}")
print(f" Mismatching: {details['mismatching_rows']}/{details['n_rows_out']} (non-zero, unmatched)")
if details['first_matching_row'] >= 0:
print(f" First matching row: {details['first_matching_row']}")
if details['first_zero_row'] >= 0:
print(f" First zero row: {details['first_zero_row']}")
if details['first_mismatch_row'] >= 0:
print(f" First mismatching row: {details['first_mismatch_row']}")
print(f" {'' if passed else ''} {msg}")
sys.exit(0 if passed else 1)
if __name__ == "__main__":
main()