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.
103 lines
5.7 KiB
C++
103 lines
5.7 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include "model.hpp"
|
|
#include "plot_host.hpp"
|
|
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
|
|
/// What the plots are showing, as a table: one row per sample of the visible x window,
|
|
/// one column per plotted signal.
|
|
///
|
|
/// The samples are the *stored* ones, never the LOD pyramid's buckets: the pyramid is
|
|
/// what makes a million samples drawable in a few hundred pixels, and exporting it would
|
|
/// hand back the envelope the renderer drew rather than the acquisition. So an export is
|
|
/// at the full rate the ring holds, however far the view is zoomed out.
|
|
struct ExportTable {
|
|
struct Column {
|
|
std::string name; ///< the legend name, sanitised for a column label
|
|
/// The signal's unit exactly as the CONFIG carried it, the empty string when it
|
|
/// carried none. The `.npy`/`.csv` writers do not spell it out (their readers get the
|
|
/// names and the numbers); MDF does, because the unit *is* part of what a channel is
|
|
/// there, and it is the only thing ScryLab reads a signal's unit from.
|
|
std::string unit;
|
|
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
|
};
|
|
|
|
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
|
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
|
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
|
/// instant of the freeze on a paused view.
|
|
std::vector<f64> time;
|
|
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
|
/// source stamped the samples with -- so two exports taken in different modes, or from
|
|
/// different runs, line up on one timeline.
|
|
///
|
|
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
|
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
|
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
|
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
|
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
|
/// legitimately not single-valued for that reason.
|
|
std::vector<f64> abs_seconds;
|
|
std::vector<Column> columns;
|
|
|
|
std::size_t rows() const noexcept { return time.size(); }
|
|
bool empty() const noexcept { return columns.empty() || time.empty(); }
|
|
};
|
|
|
|
/// Collects every signal bound to a visible row, over the shared x window, at the stored
|
|
/// rate. A signal on several rows is one column; the columns come out in channel order,
|
|
/// which is the signal list's order.
|
|
///
|
|
/// Takes `model.mutex()` for the live cut, so call it from the GTK thread like any other
|
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
|
|
|
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
|
|
/// acquisition, every channel the event holds, independent of which traces a row happens to show
|
|
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
|
|
/// columns come out in, so `--open` restores the same signals.
|
|
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
|
|
|
|
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
|
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
|
/// about half the size of an all-`f8` one and invents no precision.
|
|
///
|
|
/// `pd.DataFrame(np.load(path))` is the whole reader.
|
|
bool write_npy(const std::string& path, const ExportTable& table, std::string& error);
|
|
|
|
/// Writes the same table as CSV: a header row of column names, then `%.*g` fields --
|
|
/// enough digits to round-trip each column's own type and no more.
|
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
|
|
|
/// Writes the same table as **MDF 3.30** (`.mf3`/`.mdf`), byte for byte by hand -- there is no
|
|
/// writer library here and MF4 (HDF5) is the one format this app will not link.
|
|
///
|
|
/// One channel group, all channels record-interleaved, master first, which is the shape the
|
|
/// [ScryLab data sources](https://docs.scrylab.de/docs/data-sources/) contract asks for: *every
|
|
/// data channel becomes a signal* (the CNBLOCK's `short_name`), *the master channel of each
|
|
/// group is the x axis* (our master is `abs_seconds`, named `time`), and *the unit is taken from
|
|
/// the channel metadata* (MDF 3 keeps it in the channel's conversion block, the CCBLOCK). A
|
|
/// second occurrence of a name is dropped by that reader, so the labels are de-duplicated and cut
|
|
/// to the 31 bytes a CNBLOCK's name field holds.
|
|
///
|
|
/// The master is the **absolute** clock and the axis the plot showed is an ordinary channel,
|
|
/// `time_relative`: the relative column is `0` at a different sample in every mode, so a master
|
|
/// off it would put two exports from different modes on two timelines, while `abs_seconds` is the
|
|
/// number the stream itself stamped the samples with. Both carry the unit `s`.
|
|
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error);
|
|
|
|
/// Writes `table` with the writer **`path`'s extension** names: `.csv` -> `write_csv`,
|
|
/// `.mf3`/`.mdf` -> `write_mdf`, anything else -> `write_npy` (the default, as the save dialog's
|
|
/// default filter is). The export dialog and the self-check's export probe both come through
|
|
/// here, so the two cannot offer different formats.
|
|
bool write_export(const std::string& path, const ExportTable& table, std::string& error);
|
|
|
|
} // namespace udpscope
|