Data capture
Explain the path from a firmware printf to a typed result, and the reset
and readiness steps that run before it, so a capture failure can be placed
at the lifecycle, the transport, the stream or the parser.
Diagram
Section titled “Diagram”- Target lifecycle
- ensure powered
- reset strategy
- readiness poll
- Transportone per run
- rttdefault
- usb_cdc
- uart
- swo
- Wire stream
- sentinels
- metadata lines
- heartbeats
- CSV rows
- Parser
- extract metadata
- group presets
- aggregate iterations
- merge presets
- Power capturewhen enabled
- driverjoulescope, ina228
- sync controller
- gated window
- terminal record
- Results
- PmuResult
- PowerResult
Text description of the diagram
- Target lifecycle: ensure powered, reset strategy, readiness poll
- Transport (one per run): rtt (default), usb_cdc, uart, swo
- Wire stream: sentinels, metadata lines, heartbeats, CSV rows
- Parser: extract metadata, group presets, aggregate iterations, merge presets
- Power capture (when enabled): driver (joulescope, ina228), sync controller, gated window, terminal record
- Results:
PmuResult,PowerResult
The blocks
Section titled “The blocks”Target lifecycle
Section titled “Target lifecycle”Before either capture phase, prepare_target_for_phase() in
target/lifecycle.py applies a TargetLifecyclePlan: it may power-cycle
the board through the power driver, then applies a ResetStrategy through
the ResetController the probe stage installed, and returns the actions
it took so the run metadata records them. EnsureBoardPoweredStage ran
earlier so the rail is up before the probe is touched. After a reset the
transports poll for readiness with a deadline (open_jlink_with_retry() in
target/probe/jlink.py for the J-Link attach) instead of sleeping blindly.
Transport backends
Section titled “Transport backends”transport/__init__.py holds a registry keyed by the Transport enum;
resolve_transport() returns the backend and register_transport() lets
tests swap one in. Every backend implements the CaptureTransport protocol
in transport/base.py: prepare(ctx, args), then collect(ctx)
returning the captured lines. The shared line-collection
loop is transport/protocol.py: it reads until the end sentinel or a
timeout, and any HPX_HEARTBEAT line resets the inactivity deadline
(HEARTBEAT_TIMEOUT_S), with the clean_window_begin heartbeat extending
it to cover the announced window. Which transport to choose, and what each
needs on the bench, is on Capture transports.
The wire stream
Section titled “The wire stream”The firmware prints one line-oriented text stream, declared once in
src/helia_profiler/wire/ for both the templates and the host. The
elements a reader meets:
| Line form | Meaning |
|---|---|
--- HPX_START --- |
start of profiling output; everything before it is ignored |
HPX_<KEY>=<value> |
metadata (HPX_VERSION, HPX_ENGINE, HPX_MODEL_SIZE, HPX_ARENA_SIZE, HPX_ALLOCATED_ARENA, HPX_NUM_PRESETS, HPX_PRESETS, the clean-window fields) |
HPX_HEARTBEAT ... |
progress marker; resets the inactivity timeout, never parsed as data |
--- HPX_PRESET <name> --- |
start of one counter pass |
--- HPX_ITER <n> --- |
start of iteration n; the next line is the CSV header |
"Layer","Op",<counters>,"overflow" |
header naming this pass’s counter columns |
<idx>,<op>,<c1>,...,<overflow> |
one layer row |
--- HPX_END --- |
all profiling complete |
The full vocabulary, including the fault and warning catalogues and the power binary’s terminal record, is the wire protocol reference, marked internal because it is a contract between hpx’s own firmware and host, not a user surface.
The parser
Section titled “The parser”parse_firmware_output(lines, aggregation) in capture/parser.py runs
four steps: metadata lines become a FirmwareMeta; lines are grouped by
preset marker; each iteration’s CSV rows are parsed and, per layer,
reduced across iterations by profiling.aggregation (median by default,
mean or trimmed) after structurally invalid samples (uint32 wrap,
all-zero rows in a pass containing ARM_PMU_CPU_CYCLES) are rejected; and the per-preset PresetResults are
merged into one PmuResult with every counter column on each
LayerResult, plus per-compute-unit groups keyed by the <group>_<index>
pass naming. Layers are matched by explicit id with the same operator name
required across iterations and presets; a missing, duplicate or
conflicting identity raises CaptureError rather than publishing a
position-shifted aggregate. A missing end sentinel, missing or repeated announced pass, or missing iteration raises CaptureError. Truncated captures fail the run instead of publishing partial aggregates.
Multiple presets exist because a SoC exposes a fixed number of PMU
counters; platform/counters.py plans the passes from the requested
groups and the SoC’s capability record, and the firmware runs them in
sequence in one boot.
Power capture
Section titled “Power capture”When power.enabled is true, capture_power() in capture/__init__.py
resolves the PowerDriver (power/joulescope/ or power/ina228_driver.py),
builds the sync controller that owns the READY, GO and GATE handshake
(power/sync.py, power/joulescope/sync.py),
runs the lifecycle plan, and calls the driver’s capture_gated() for the
GPIO-gated window or capture() for a free-form one. The result is a
PowerResult with the accepted window, the energy integration and the
gating diagnostics; CollectPowerTerminalStage then reads the one status
record the power firmware prints after the gate closes
(capture/power_terminal.py). The measurement scopes and their health
signals are on Power measurement and
Verifying a capture.
Failure handling
Section titled “Failure handling”| Scenario | Behaviour |
|---|---|
| no output at all | the overall timeout expires and CaptureError names the transport |
| firmware stalls mid-run | no line for HEARTBEAT_TIMEOUT_S raises CaptureError with the last line seen |
| silent clean window | the clean_window_begin heartbeat carries the estimated duration and extends the deadline |
| firmware reports a fault | the HPX_ERROR line maps to a CaptureError carrying the catalogue’s hint |
| identity mismatch across iterations | CaptureError; nothing partial is published |
Where the code lives
Section titled “Where the code lives”| Block | Path under src/helia_profiler/ |
|---|---|
| target lifecycle | target/lifecycle.py, target/probe/, stages/ensure_powered.py |
| transports | transport/{rtt,usb_cdc,uart,swo}.py, transport/base.py, transport/protocol.py, transport/timing.py |
| wire stream | wire/ |
| parser | capture/parser.py, results/models.py |
| power capture | capture/__init__.py (capture_power), power/, capture/power_terminal.py |
| stages | stages/capture_pmu.py, stages/capture_power.py, stages/collect_power_terminal.py |
What next
Section titled “What next”Firmware generation for the side of the stream the templates print; Pipeline for where the capture stages sit; PMU counters for what the columns mean.
Capture backends respect target.heartbeat.overall_timeout_s and target.heartbeat.host_timeout_s. SWO retries share one overall deadline. Only complete lines refresh progress deadlines. Passes without a cycle counter retain all-zero rows.