feat: the export writes an MDF 3.30 file, so a window loads in ScryLab

A third format beside `.npy` and `.csv`, written by hand in `src/export.cpp` --
MF4 is HDF5 and would mean a library, MF3 is a handful of fixed blocks. The
layout is the asammdf 8.8.27 reader's own field list, byte-diffed against a
known-good 3.30 file it wrote; the shape is the one ScryLab's data-source
contract asks for: one channel group, record-interleaved, the master as the x
axis, and the unit in the channel's conversion block.

So in MDF the master is `abs_seconds`, named `time` -- the clock that is the
same in every display mode -- and the plotted axis goes out as an ordinary
channel, `time_relative`. `ExportTable::Column` gained the CONFIG's unit, which
only this writer spells out; the `.npy`/`.csv` bytes are unchanged, and
`write_export()` now picks the writer for both the dialog and the probe.
This commit is contained in:
Martino Ferrari committed 2026-10-14 15:56:27 +02:00
1 parent 761615c2fe
commit 908286ee89
10 files changed
+505 -28

No files matched your search

+18 -1
View File
@@ -126,10 +126,27 @@ Two lines of the self-check output are assertions in disguise:
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
the post window has filled — the export is what is on screen, and during a capture that is a
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
The two `bytes=` lines are the writers: the `.npy` is
The three `bytes=` lines are the writers: the `.npy` is
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
either writer.
The `.mf3` line's size is the MDF 3.30 layout the writer spells out, and every term of it is
observable: `272 + comment + rows·record + 28 + channels·274 + 30`, where the ID block is 64,
the HD block 208, the comment is a `TX` block of its text plus 5 bytes, `record` is
`16 + 4·cols` (two `f64` time columns then one `f32` per signal), the data group is 28, each of
`channels = cols + 2` gets a 46-byte conversion block and a 228-byte channel block, and the
channel group is 30. Read the file back with asammdf: `MDF(path).info()` must show one group
whose `channel 0` is `name="time" type=master`, and `get(name, group=0).unit` must be the unit
the next line reports.
`[export-probe] mdf master=time master_unit=s channels=N record=B units=['s' 's' 'V' …]`
is the other half, and it is the assertion on the units: `channels` and `record` are the two
numbers the `.mf3` size above is built from (so `record` must be `16 + 4·cols` and `channels`
`cols + 2`), and `units` must be **the CONFIG's**, in the table's column order, behind the
master's and the relative axis's two `s` — one `'s' 's'` pair plus one entry per exported
column, each exactly the unit the stream sent (an empty one reads `''`). A `V` where the mock
sent `s`, or a missing entry, is the unit not reaching the conversion block, which is the only
thing ScryLab reads a signal's unit from; `write_npy`/`write_csv` must be unchanged either way
(their bytes are pinned by the two lines above).
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
`event_span`** on every line (the trigger's event span is the display window; a capture