External instrument series: the import contract
Any bench instrument that samples on its own clock (power analyzer, DAQ, logic analyzer, thermal camera ROI, battery cycler, ...) can land its data on a recording's time axis. Nothing in ViewAlyzer knows any instrument by name: an adapter converts the instrument's native capture into this NDJSON contract, and viewalyzer-cli import does the rest. This is the sync recipe from the instrument-port vision doc (joulescope-demo/docs/instrument-port-vision.md section 2) made concrete. How the alignment actually works, from first principles: docs/TIME-SYNC.md.
The sync recipe
- The firmware raises a GPIO edge and logs a
VA_LogTrace("jsync", seq)event back to back with interrupts masked, at LFSR-dithered intervals (400..700 ms in 20 ms steps; seeNucleo_C071_VA/Core/Src/main.cfor the reference block). The dither makes the interval sequence a unique fingerprint, so alignment is unambiguous no matter when either capture started. - The instrument records that edge on its own clock (any tick unit).
importmatches edges to events by interval pattern, rejects noise edges and missed marks (including mid-train insertions), fits a piecewise-linear map from instrument ticks to recording cycles, and inserts the series.
On a BKPT probe with the instrument port, hardware probe_trigger events replace the firmware marks automatically (stream-position exact).
Instruments that cannot see a pin can still import with --offset-s <t> (pure linear map, no drift correction): honest about being unsynchronized.
File format (NDJSON, one JSON object per line)
{"type":"meta","source":"bench-daq","rate_hz":100000}
{"type":"channel","id":0,"name":"rail_v","unit":"V","display":"graph","group":"Bench"}
{"type":"sync","edges":[171234, 215002, ...]}
{"type":"block","channel":0,"t0":0,"dt":200,"v":[3.301, 3.299, null, 3.302]}
{"type":"sample","channel":0,"t":171000,"v":3.3}
meta(required, once):sourcenames the producer (re-imports with the same source replace, never duplicate);rate_hzis instrument ticks per second (1e6 for microsecond sample ids, 1 for seconds).channel: one per series;unit/display/groupland in the objectextraexactly like a Trace Domain descriptor would set them (displayuses the same vocabulary; defaultgraph).sync: edge times in instrument ticks (may repeat / accumulate lines).block: uniform-rate run; sampleiis att0 + (i + 0.5) * dt. Anullkeeps its time slot (instrument dropout) and inserts nothing.sample: single point, for sparse or irregular series.
What lands in the .vadb
va_objects(ns='external', va_id=<channel id>, name, extra={display, unit, group, source}) plus va_events(kind='external_trace', t_cycles, value_f). Both are ordinary store rows: charts, stats, query, SQL and fingerprints see them like any firmware channel, and the file stays valid with nothing installed. A .vadomain descriptor can additionally claim ns='external' channels for branding and overrides, but is not required: the file is self-describing.
CLI
viewalyzer-cli import demo.vadb --series daq.ndjson [--sync-name jsync]
[--sync-tol-ms 40] [--max-resid-us 500] [--offset-s t]
Or in one step with the capture (spawns the instrument's own recorder for the capture window, waits for it, then imports):
viewalyzer-cli capture --config board.vacf --elf app.elf --output demo.vadb \
--duration 15 \
--instrument-cmd "python js_record.py -o run.ndjson --duration 20" \
--instrument-series run.ndjson
The import prints matched pairs, clock drift (ppm) and fit residuals; use --max-resid-us as a quality gate in CI.