412 lines
16 KiB
Typst
412 lines
16 KiB
Typst
// E2E_Report.typ — Streaming-chain E2E report.
|
||
//
|
||
// Renders report_data.json (produced by report_build.py) into a single PDF:
|
||
// headline KPIs, per-field progression/regression vs the previous run, unit-test
|
||
// suites, code coverage, performance (CPU/peak-RSS/throughput), per-scenario
|
||
// waveform fidelity, trend plots, and embedded per-scenario waveform images.
|
||
//
|
||
// Compile from the directory holding report_data.json + the *.png artifacts:
|
||
// typst compile E2E_Report.typ E2E_Report.pdf
|
||
// Missing inputs degrade to "n/a" / skipped sections so a partial run still
|
||
// renders.
|
||
|
||
#let data = json("report_data.json")
|
||
#let meta = data.meta
|
||
#let e2e = data.e2e
|
||
#let ut = data.unit_tests
|
||
#let cov = data.coverage
|
||
#let reg = data.regression
|
||
#let hl = data.headline
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
#let fmt(v, suffix: "") = if v == none { "n/a" } else { str(v) + suffix }
|
||
#let fnum(v, digits: 3, suffix: "") = {
|
||
if v == none { "n/a" } else {
|
||
[#calc.round(v, digits: digits)] + suffix
|
||
}
|
||
}
|
||
#let ok_color = rgb("#1a7f37")
|
||
#let bad_color = rgb("#cf222e")
|
||
#let neutral = rgb("#57606a")
|
||
#let fail_bg = rgb("#ffebe9") // light-red row background for FAIL rows in scenario tables
|
||
#let fail_row_fill(is_fail) = (x, y) => if y > 0 and is_fail(y - 1) { fail_bg } else { none }
|
||
|
||
#let warn_color = rgb("#9a6700") // XFAIL — expected/known failure
|
||
#let xpass_color = rgb("#8250df") // XPASS — stale marker, needs attention
|
||
#let status_badge(s) = {
|
||
let c = if s == "PASS" { ok_color }
|
||
else if s == "FAIL" { bad_color }
|
||
else if s == "XFAIL" { warn_color }
|
||
else if s == "XPASS" { xpass_color }
|
||
else { neutral }
|
||
box(fill: c, inset: (x: 6pt, y: 2pt), radius: 3pt, text(fill: white, weight: "bold", size: 8pt)[#s])
|
||
}
|
||
|
||
#let yesno(b) = {
|
||
if b == true { text(fill: ok_color)[✓] }
|
||
else if b == false { text(fill: bad_color)[✗] }
|
||
else { text(fill: neutral)[—] }
|
||
}
|
||
|
||
// progression arrow for a regression row
|
||
#let trend_arrow(r) = {
|
||
if r.delta == none or r.delta == 0 { text(fill: neutral)[—] }
|
||
else {
|
||
let up = r.delta > 0
|
||
let good = r.better == true
|
||
let c = if good { ok_color } else { bad_color }
|
||
let arrow = if up { "▲" } else { "▼" }
|
||
text(fill: c)[#arrow #calc.round(r.delta, digits: 4)]
|
||
}
|
||
}
|
||
|
||
// ── page setup ───────────────────────────────────────────────────────────────
|
||
#set page(
|
||
paper: "a4", margin: (x: 1.8cm, y: 1.8cm),
|
||
header: align(right, text(size: 8pt, fill: neutral)[Streaming-chain E2E · #meta.git_sha]),
|
||
footer: context align(center, text(size: 8pt, fill: neutral)[#counter(page).display("1 / 1", both: true)]),
|
||
)
|
||
#set text(font: "DejaVu Sans", size: 9.5pt)
|
||
#set heading(numbering: "1.1")
|
||
#show heading.where(level: 1): it => block(above: 14pt, below: 8pt, text(size: 14pt, weight: "bold", it))
|
||
#show heading.where(level: 2): it => block(above: 10pt, below: 6pt, text(size: 11pt, weight: "bold", it))
|
||
|
||
// ── title ────────────────────────────────────────────────────────────────────
|
||
#align(center)[
|
||
#text(size: 20pt, weight: "bold")[MARTe2 Streaming-Chain E2E Report] \
|
||
#v(2pt)
|
||
#text(size: 10pt, fill: neutral)[
|
||
#meta.timestamp · commit #raw(meta.git_sha) · target #meta.target ·
|
||
run \##data.history_len
|
||
]
|
||
]
|
||
|
||
#v(6pt)
|
||
#align(center, status_badge(e2e.overall) + h(8pt) + text(size: 11pt)[
|
||
E2E #hl.e2e_pass/#hl.e2e_total · Units #hl.unit_pass/#hl.unit_total
|
||
#if hl.at("e2e_xfail", default: 0) > 0 [ · #hl.e2e_xfail xfail]
|
||
#if hl.at("e2e_xpass", default: 0) > 0 [ · #text(fill: xpass_color)[#hl.e2e_xpass xpass!]]
|
||
])
|
||
|
||
// ── headline KPIs ────────────────────────────────────────────────────────────
|
||
#v(10pt)
|
||
#let kpi(label, value) = box(width: 100%, inset: 8pt, radius: 4pt, fill: rgb("#f6f8fa"))[
|
||
#align(center)[
|
||
#text(size: 14pt, weight: "bold")[#value] \
|
||
#text(size: 8pt, fill: neutral)[#label]
|
||
]
|
||
]
|
||
#grid(columns: 4, gutter: 6pt,
|
||
kpi("E2E passed", [#hl.e2e_pass/#hl.e2e_total]),
|
||
kpi("Unit passed", [#hl.unit_pass/#hl.unit_total]),
|
||
kpi("Mean sine corr", fnum(hl.mean_corr, digits: 4)),
|
||
kpi("Throughput", fnum(hl.mean_throughput_sps, digits: 0, suffix: " sp/s")),
|
||
kpi("Python cov", fmt(hl.cov_python, suffix: "%")),
|
||
kpi("Go cov", fmt(hl.cov_go, suffix: "%")),
|
||
kpi("Mean peak RSS", fnum(hl.mean_peak_rss_mb, digits: 1, suffix: " MB")),
|
||
kpi("Mean CPU", fnum(hl.mean_cpu_s, digits: 2, suffix: " s")),
|
||
)
|
||
|
||
// ── progression / regression ─────────────────────────────────────────────────
|
||
= Progression / regression
|
||
#if data.is_first_run [
|
||
_First recorded run — baseline established; no previous run to compare against._
|
||
] else [
|
||
Comparison against the previous run (#text(fill: ok_color)[▲] better,
|
||
#text(fill: bad_color)[▼] worse, — unchanged/unavailable).
|
||
#v(4pt)
|
||
#table(
|
||
columns: (1.6fr, 1fr, 1fr, 1.2fr),
|
||
align: (left, right, right, right),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 5pt,
|
||
table.header([*Metric*], [*Current*], [*Previous*], [*Δ (trend)*]),
|
||
..reg.map(r => (
|
||
[#r.name],
|
||
fnum(r.current, digits: 4),
|
||
fnum(r.previous, digits: 4),
|
||
trend_arrow(r),
|
||
)).flatten()
|
||
)
|
||
]
|
||
|
||
// ── unit tests ───────────────────────────────────────────────────────────────
|
||
= Unit tests
|
||
#table(
|
||
columns: (2fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr, 0.8fr),
|
||
align: (left, center, right, right, right, right, right),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 5pt,
|
||
table.header([*Suite*], [*Status*], [*Total*], [*Pass*], [*Fail*], [*Skip*], [*Time (s)*]),
|
||
..ut.suites.map(s => (
|
||
[#s.name],
|
||
if not s.avail { status_badge("SKIP") } else if s.ok { status_badge("PASS") } else { status_badge("FAIL") },
|
||
[#s.total], [#s.passed], [#s.failed], [#s.skipped], fnum(s.time_s, digits: 2),
|
||
)).flatten(),
|
||
table.cell(colspan: 2)[*Totals*],
|
||
[#ut.totals.total], [#ut.totals.passed], [#ut.totals.failed], [#ut.totals.skipped], [],
|
||
)
|
||
|
||
// ── coverage ─────────────────────────────────────────────────────────────────
|
||
= Code coverage
|
||
#table(
|
||
columns: (1fr, 1fr, 2fr),
|
||
align: (left, right, left),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 5pt,
|
||
table.header([*Language*], [*Coverage*], [*Source*]),
|
||
..cov.languages.map(l => (
|
||
[#l.name],
|
||
if l.pct == none { text(fill: neutral)[n/a] } else { [#l.pct%] },
|
||
text(size: 8pt, fill: neutral)[#l.note],
|
||
)).flatten()
|
||
)
|
||
|
||
// per-file C++ detail — the streaming chain's most critical code, so the report
|
||
// shows line coverage for every project C++ source (worst-covered first).
|
||
#let cpp = cov.languages.find(l => l.name == "C++")
|
||
#if cpp != none and cpp.at("files", default: ()).len() > 0 [
|
||
== C++ source detail
|
||
#let cf = cpp.files
|
||
#text(size: 8pt, fill: neutral)[
|
||
#cpp.at("lines_hit", default: 0) of #cpp.at("lines_found", default: 0)
|
||
lines covered across #cf.len() project files (worst-covered first;
|
||
green ≥75%, amber 50–75%, red under 50%).
|
||
]
|
||
#v(4pt)
|
||
#let covcell(p) = {
|
||
let c = if p == none { neutral }
|
||
else if p >= 75 { ok_color }
|
||
else if p >= 50 { warn_color }
|
||
else { bad_color }
|
||
text(fill: c, weight: "bold")[#if p == none { "n/a" } else [#p%]]
|
||
}
|
||
#table(
|
||
columns: (3.2fr, 1fr, 1fr, 1.1fr),
|
||
align: (left, right, right, right),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 4pt,
|
||
table.header([*Source file*], [*Lines*], [*Hit*], [*Coverage*]),
|
||
..cf.map(f => (
|
||
text(size: 8pt)[#f.path],
|
||
[#f.lines_found],
|
||
[#f.lines_hit],
|
||
covcell(f.pct),
|
||
)).flatten()
|
||
)
|
||
]
|
||
|
||
// ── performance ──────────────────────────────────────────────────────────────
|
||
= Performance
|
||
Per-scenario CPU time and peak resident memory (VmHWM) of the StreamHub and
|
||
MARTe2 processes, plus sustained client throughput (recorded samples ÷ duration).
|
||
#v(4pt)
|
||
#table(
|
||
columns: (1.8fr, 1fr, 1fr, 1fr, 1fr, 1.1fr),
|
||
align: (left, right, right, right, right, right),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 5pt,
|
||
fill: fail_row_fill(i => e2e.scenarios.at(i).status == "FAIL"),
|
||
table.header([*Scenario*], [*Hub CPU (s)*], [*Hub RSS (MB)*],
|
||
[*MARTe CPU (s)*], [*MARTe RSS (MB)*], [*Throughput (sp/s)*]),
|
||
..e2e.scenarios.map(sc => {
|
||
let h = sc.perf.at("hub", default: (:))
|
||
let m = sc.perf.at("marte", default: (:))
|
||
(
|
||
[#sc.id],
|
||
fnum(h.at("cpu_s", default: none), digits: 2),
|
||
fnum(h.at("peak_rss_mb", default: none), digits: 1),
|
||
fnum(m.at("cpu_s", default: none), digits: 2),
|
||
fnum(m.at("peak_rss_mb", default: none), digits: 1),
|
||
fnum(sc.throughput_sps, digits: 0),
|
||
)
|
||
}).flatten()
|
||
)
|
||
|
||
// ── per-scenario waveform fidelity ───────────────────────────────────────────
|
||
= Scenarios
|
||
#for sc in e2e.scenarios [
|
||
== #sc.id #h(6pt) #status_badge(sc.status)
|
||
#if sc.at("desc", default: none) != none [
|
||
#v(1pt)
|
||
#text(size: 9pt, fill: neutral, style: "italic")[#sc.desc]
|
||
#v(2pt)
|
||
]
|
||
#if sc.at("known_issue", default: none) != none [
|
||
#v(2pt)
|
||
#box(fill: rgb("#fff8c5"), inset: 5pt, radius: 3pt, width: 100%,
|
||
text(size: 8pt, fill: rgb("#7d4e00"))[*Known issue:* #sc.known_issue])
|
||
]
|
||
#let rb = sc.rollup
|
||
Client checks:
|
||
live #yesno(rb.at("live_ok", default: none)) ·
|
||
zoom #yesno(rb.at("zoom_ok", default: none)) ·
|
||
window #yesno(rb.at("window_ok", default: none)) ·
|
||
trigger #yesno(rb.at("trigger_ok", default: none))
|
||
#if sc.live_frames != none [ · #sc.live_frames live frames]
|
||
#v(3pt)
|
||
#table(
|
||
columns: (1.6fr, 0.6fr, 0.8fr, 0.8fr, 1fr, 0.9fr, 0.9fr, 0.8fr, 0.8fr),
|
||
align: (left, center, left, left, right, right, right, center, center),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 4pt,
|
||
fill: fail_row_fill(i => sc.signals.at(i).pass == false),
|
||
table.header([*Signal*], [*Pass*], [*Type*], [*Quant*], [*Max abs err*],
|
||
[*Corr*], [*nRMSE*], [*Fidelity*], [*Shape*]),
|
||
..sc.signals.map(g => (
|
||
raw(g.key),
|
||
yesno(g.pass),
|
||
text(size: 8pt)[#g.type],
|
||
text(size: 8pt)[#g.quant],
|
||
fnum(g.max_abs_err, digits: 6),
|
||
fnum(g.corr, digits: 4),
|
||
fnum(g.nrmse, digits: 4),
|
||
yesno(g.fidelity_ok),
|
||
yesno(g.shape_ok),
|
||
)).flatten()
|
||
)
|
||
// zoom / window / trigger behavioural detail (real WS-driven results)
|
||
#let zooms = sc.at("zoom", default: ())
|
||
#let win = sc.at("window", default: (:))
|
||
#let trigs = sc.at("trigger", default: ())
|
||
#if zooms.len() > 0 or win.len() > 0 [
|
||
#v(3pt)
|
||
#text(size: 8.5pt, weight: "bold")[Zoom / window queries]
|
||
#table(
|
||
columns: (1fr, 1.2fr, 1fr, 0.8fr, 0.8fr),
|
||
align: (left, left, right, right, center),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 4pt,
|
||
table.header([*Query*], [*Signal*], [*Span (s)*], [*Pts*], [*OK*]),
|
||
..zooms.enumerate().map(((i, z)) => {
|
||
let r = z.at("range", default: (0, 0))
|
||
(
|
||
[zoom #str(i + 1)],
|
||
raw(z.at("key", default: "")),
|
||
fnum(r.at(1) - r.at(0), digits: 4),
|
||
[#z.at("returned", default: 0)],
|
||
yesno(z.at("inrange", default: none)),
|
||
)
|
||
}).flatten(),
|
||
..(if win.len() > 0 and win.at("returned", default: 0) > 0 {(
|
||
[window #fnum(win.at("windowSec", default: 0), digits: 2)],
|
||
raw(win.at("key", default: "")),
|
||
fnum(win.at("span", default: 0), digits: 4),
|
||
[#win.at("returned", default: 0)],
|
||
yesno(win.at("ok", default: none)),
|
||
)} else {()}),
|
||
)
|
||
]
|
||
#if trigs.len() > 0 [
|
||
#v(3pt)
|
||
#text(size: 8.5pt, weight: "bold")[Trigger captures] #h(4pt)
|
||
#text(size: 8pt, fill: neutral)[(signal #raw(trigs.at(0).at("key", default: "")))]
|
||
#table(
|
||
columns: (1fr, 1fr, 0.8fr, 1fr, 1fr, 0.8fr, 0.8fr),
|
||
align: (left, left, center, right, right, center, center),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 4pt,
|
||
table.header([*Edge*], [*Mode*], [*Fired*], [*Pre/Post (s)*],
|
||
[*Capture pts*], [*Win OK*], [*Edge OK*]),
|
||
..trigs.map(t => (
|
||
text(size: 8pt)[#t.at("edge", default: "")],
|
||
text(size: 8pt)[#t.at("mode", default: "")],
|
||
yesno(t.at("fired", default: none)),
|
||
text(size: 8pt)[#fnum(t.at("preSec", default: 0), digits: 3) / #fnum(t.at("postSec", default: 0), digits: 3)],
|
||
[#t.at("capturePts", default: 0)],
|
||
yesno(t.at("windowOk", default: none)),
|
||
yesno(t.at("edgeOk", default: none)),
|
||
)).flatten()
|
||
)
|
||
]
|
||
// embed the waveform overview if report_build.py found one in the artifacts
|
||
#if sc.at("wave_img", default: none) != none [
|
||
#v(3pt)
|
||
#align(center, image(sc.wave_img, width: 92%))
|
||
]
|
||
#v(6pt)
|
||
]
|
||
|
||
// ── per-kind sections (direct/recorder/debug/tcplogger) ─────────────────────
|
||
// Raw scenario records here come straight from results.json (not e2e.scenarios'
|
||
// reshaping), so they only carry id/kind/status/known_issue/metrics — no
|
||
// waveform-fidelity breakdown (already covered, where applicable, in the
|
||
// Scenarios section above for chain-kind scenarios; these kinds run through
|
||
// dedicated non-chain harnesses in run_e2e.sh).
|
||
#let kind_table(title, block) = {
|
||
[= #title]
|
||
[#block.n_pass/#block.n_total passed.]
|
||
v(4pt)
|
||
if block.n_total == 0 {
|
||
text(fill: neutral)[_No scenarios of this kind in this run._]
|
||
} else {
|
||
table(
|
||
columns: (1.4fr, 0.8fr, 2fr),
|
||
align: (left, center, left),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 5pt,
|
||
fill: fail_row_fill(i => block.scenarios.at(i).status == "FAIL"),
|
||
table.header([*Scenario*], [*Status*], [*Known issue*]),
|
||
..block.scenarios.map(s => (
|
||
[#s.id],
|
||
status_badge(s.status),
|
||
if s.at("known_issue", default: none) != none {
|
||
text(size: 8pt, fill: rgb("#7d4e00"))[#s.known_issue]
|
||
} else { text(fill: neutral)[—] },
|
||
)).flatten()
|
||
)
|
||
}
|
||
}
|
||
|
||
#kind_table("Direct Round-Trip (UDPStreamer ↔ UDPStreamerClient)", data.direct)
|
||
#kind_table("Recorder (BinaryRecorder disk output)", data.recorder)
|
||
#kind_table("Debug Service E2E", data.debug)
|
||
#kind_table("TCPLogger E2E", data.tcplogger)
|
||
#kind_table("Debug Service PAUSE/RESUME E2E", data.debug_pause_resume)
|
||
|
||
// ── stress tests ─────────────────────────────────────────────────────────────
|
||
= Stress Tests
|
||
#if data.stress == none [
|
||
_Not run this session._
|
||
] else [
|
||
#align(center, status_badge(data.stress.overall) + h(8pt) + text(size: 11pt)[
|
||
#hl.at("stress_pass", default: 0) passed ·
|
||
#hl.at("stress_fail", default: 0) failed
|
||
])
|
||
#v(4pt)
|
||
#for (axis, cases) in data.stress.by_axis [
|
||
== Axis: #raw(axis)
|
||
#table(
|
||
columns: (0.8fr, 0.8fr, 1fr, 1fr, 1fr, 1fr),
|
||
align: (right, center, right, right, right, right),
|
||
stroke: 0.4pt + rgb("#d0d7de"),
|
||
inset: 4pt,
|
||
table.header([*Level*], [*Status*], [*Hub RSS (MB)*], [*MARTe RSS (MB)*],
|
||
[*Zoom p50 (ms)*], [*Zoom p95 (ms)*]),
|
||
..cases.map(c => (
|
||
[#c.at("level", default: "n/a")],
|
||
status_badge(c.at("status", default: "?")),
|
||
fnum(c.at("hub_rss_mb", default: none), digits: 1),
|
||
fnum(c.at("marte_rss_mb", default: none), digits: 1),
|
||
fnum(c.at("zoom_p50_ms", default: none), digits: 1),
|
||
fnum(c.at("zoom_p95_ms", default: none), digits: 1),
|
||
)).flatten()
|
||
)
|
||
]
|
||
#if data.stress_plots.len() > 0 [
|
||
#v(4pt)
|
||
== Scaling curves
|
||
#grid(columns: 2, gutter: 8pt,
|
||
..data.stress_plots.map(p => image(p, width: 100%))
|
||
)
|
||
]
|
||
]
|
||
|
||
// ── trend plots ──────────────────────────────────────────────────────────────
|
||
#if data.trend_plots.len() > 0 [
|
||
= Trends over runs
|
||
#grid(columns: 2, gutter: 8pt,
|
||
..data.trend_plots.map(p => image(p, width: 100%))
|
||
)
|
||
]
|