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

+28 -2
View File
@@ -89,7 +89,7 @@ src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotS
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
src/trigger.* the trigger configuration and the producer-side state machine
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
src/export.* the visible range as a table, and the .npy / .csv writers
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
src/layout_store.* the per-source session layout file (TOML), written and read back
src/event_series.hpp an immutable captured event as a plot::data::Series
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
@@ -2261,16 +2261,42 @@ magnitudes, so consecutive differences wobble by a few ULP of the stream's uptim
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
was *microseconds*, four orders of magnitude up).
Two formats, picked by the extension the save dialog ends up with:
Three formats, picked by the extension the save dialog ends up with:
| file | what it is |
|---|---|
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
| `.mf3`, `.mdf` | **MDF 3.30**, one channel group with every channel record-interleaved, written by hand — see *The MDF export* below. |
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
dependency for a feature whose output a three-line reader already handles.
**The MDF export** is the format for handing a window to another tool, and specifically to
[ScryLab](https://docs.scrylab.de/docs/data-sources/), whose data-source contract is three
rules this file is shaped by: *every data channel becomes a signal* — the channel name is the
signal name — *the master channel of each channel group is the x axis*, and *the unit is taken
from the channel metadata*, which in MDF 3 means the channel's conversion block (`CCBLOCK`) and
nowhere else. A second occurrence of a name is dropped by that reader, so the channel names are
de-duplicated against each other and the master's.
Two things follow from that, and both differ from what the `.npy`/`.csv` table holds:
- **The master is `abs_seconds`, named `time`**, not the axis the plot showed. The relative
column is 0 at a different sample in each display mode, so a master off it would put a live
export and a held event's export on two different timelines — the one thing that column
exists to avoid. The relative axis goes out as an ordinary channel, **`time_relative`**,
right after the master, and both carry the unit `s`. The signal columns follow in the same
order the `.npy`/`.csv` file writes them.
- **The unit travels with a column**, taken from the stream's CONFIG (or a math channel's own),
and is written into the conversion block. The `.npy`/`.csv` writers are unchanged: their
readers get the names and the numbers, and their bytes are what they always were.
There is **no MDF writer library here and no new dependency**: the blocks are spelled out in
`src/export.cpp` against the field list the `asammdf` 8.8.27 reader walks the format with, and
`docs/claude/invariants-session-and-export.md` records the layout, the version decision (MDF
3.30, not MF4 — that is HDF5) and what was verified against a known-good reference file.
The table is collected when the menu item is clicked, not when the dialog is
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`