Implemented client datasource
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 — 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`.],
|
||||
)
|
||||
@@ -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()
|
||||
Executable
+278
@@ -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 "=========================================="
|
||||
Executable
+174
@@ -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()
|
||||
Reference in New Issue
Block a user