274 lines
11 KiB
C++
274 lines
11 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#include "export.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <bit>
|
|
#include <cerrno>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
|
|
namespace udpscope {
|
|
namespace {
|
|
|
|
// The writers memcpy `f64`/`f32` straight out and label them `<f8`/`<f4`, so a
|
|
// big-endian host would produce a file whose bytes contradict its own dtype. There is no
|
|
// such host in this app's reach and a byte-swapping path nobody runs is a path nobody
|
|
// tests, so the assumption is stated instead of handled.
|
|
static_assert(std::endian::native == std::endian::little,
|
|
"the .npy writer labels its fields little-endian and does not swap");
|
|
|
|
/// A column label a reader can use: NumPy rejects duplicate field names outright, and a
|
|
/// quote or a brace in one would break the header dict it is spelled into. Signal names
|
|
/// come from the stream, so neither is this app's to promise.
|
|
std::string sanitise(const std::string& name) {
|
|
std::string out;
|
|
out.reserve(name.size());
|
|
for (const char ch : name) {
|
|
const bool ok = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
|
|
(ch >= '0' && ch <= '9') || ch == '_';
|
|
out.push_back(ok ? ch : '_');
|
|
}
|
|
if (out.empty()) out = "col";
|
|
return out;
|
|
}
|
|
|
|
/// The final labels: sanitised, and de-duplicated against each other *and* against the
|
|
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
|
/// easily sanitise to the same string.
|
|
std::vector<std::string> column_labels(const ExportTable& table) {
|
|
std::vector<std::string> taken{"time", "stream_time"};
|
|
std::vector<std::string> out;
|
|
out.reserve(table.columns.size());
|
|
for (const ExportTable::Column& col : table.columns) {
|
|
const std::string base = sanitise(col.name);
|
|
std::string label = base;
|
|
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
|
label = base + "_" + std::to_string(n);
|
|
}
|
|
taken.push_back(label);
|
|
out.push_back(std::move(label));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
struct File {
|
|
explicit File(const std::string& path) : f(std::fopen(path.c_str(), "wb")) {}
|
|
~File() {
|
|
if (f != nullptr) std::fclose(f);
|
|
}
|
|
File(const File&) = delete;
|
|
File& operator=(const File&) = delete;
|
|
std::FILE* f = nullptr;
|
|
};
|
|
|
|
bool opened(const File& out, const std::string& path, std::string& error) {
|
|
if (out.f != nullptr) return true;
|
|
error = "cannot write '" + path + "': " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|
ExportTable table;
|
|
const RowSet::Row& r0 = rows.row(0);
|
|
if (r0.plot == nullptr) return table;
|
|
const plot::Range view = r0.plot->x_range(r0.axis_x);
|
|
|
|
// Which channels: every bound slot on a visible row, de-duplicated, then sorted. A
|
|
// signal dropped on two rows is one column, and channel order is the signal list's
|
|
// order, so the columns read in the same order as the panel.
|
|
std::vector<int> chans;
|
|
for (int i = 0; i < rows.row_count(); ++i) {
|
|
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
|
|
const int c = rows.channel_of(i, s);
|
|
if (c >= 0 && std::find(chans.begin(), chans.end(), c) == chans.end()) {
|
|
chans.push_back(c);
|
|
}
|
|
}
|
|
}
|
|
std::sort(chans.begin(), chans.end());
|
|
if (chans.empty()) return table;
|
|
|
|
const std::vector<ChannelInfo>& infos = model.channels();
|
|
const auto name_of = [&](int c) {
|
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
|
: std::string("ch") + std::to_string(c);
|
|
};
|
|
|
|
// What the rows bind to is what gets exported, which is the same branch `RowSet::bind()`
|
|
// takes: the frozen event in trigger mode, the live ring otherwise. Reading the ring
|
|
// while an event is on screen would export data the user cannot see.
|
|
std::shared_ptr<const EventSnapshot> snap = model.event();
|
|
if (model.trigger_enabled() && snap != nullptr) {
|
|
// The snapshot is immutable and already index-aligned, so the visible window is two
|
|
// binary searches over its stamps and the same index range for every channel.
|
|
const std::vector<f32>& st = snap->stamps;
|
|
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
|
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
|
if (hi <= lo) return table;
|
|
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
|
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
|
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
|
// second column -- the one case where the visible x is not the stream's own.
|
|
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
|
|
|
table.time.resize(n);
|
|
table.stream_time.resize(n);
|
|
for (std::size_t i = 0; i < n; ++i) {
|
|
table.time[i] = static_cast<f64>(st[first + i]);
|
|
table.stream_time[i] = edge + table.time[i];
|
|
}
|
|
for (const int c : chans) {
|
|
const std::size_t ch = static_cast<std::size_t>(c);
|
|
if (ch >= snap->values.size()) continue;
|
|
const std::vector<f32>& src = snap->values[ch];
|
|
if (src.size() < first + n) continue;
|
|
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
|
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
|
}
|
|
return table;
|
|
}
|
|
|
|
// Live: one absolute index range over the shared time ring, used for every channel --
|
|
// `live_window_locked()` is the same cut a latch makes, so an export and a capture of
|
|
// the same window hold the same samples. The lock is against a structural change
|
|
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
|
std::lock_guard<std::mutex> lock(model.mutex());
|
|
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
|
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
|
// what keeps this correct rather than merely true today.
|
|
const f64 epoch = model.display_epoch();
|
|
u64 first = 0, last = 0;
|
|
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
|
const u64 n = last - first;
|
|
if (n == 0) return table;
|
|
|
|
BufferStore& store = model.live();
|
|
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
|
store.time().copy_from(first, n, stamps.data());
|
|
table.time.resize(static_cast<std::size_t>(n));
|
|
table.stream_time.resize(static_cast<std::size_t>(n));
|
|
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
|
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
|
table.time[i] = epoch + table.stream_time[i];
|
|
}
|
|
for (const int c : chans) {
|
|
const std::size_t ch = static_cast<std::size_t>(c);
|
|
if (ch >= model.channel_count()) continue;
|
|
std::vector<f32> values(static_cast<std::size_t>(n));
|
|
store.values(ch).copy_from(first, n, values.data());
|
|
table.columns.push_back({name_of(c), std::move(values)});
|
|
}
|
|
return table;
|
|
}
|
|
|
|
bool write_npy(const std::string& path, const ExportTable& table, std::string& error) {
|
|
if (table.empty()) {
|
|
error = "nothing to export: no signal is plotted in the visible range";
|
|
return false;
|
|
}
|
|
const std::vector<std::string> labels = column_labels(table);
|
|
|
|
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
|
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
|
// are field widths back to back with no gaps.
|
|
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
|
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
|
descr += "]";
|
|
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
|
std::to_string(table.rows()) + ",), }";
|
|
// v1.0: a 6-byte magic, 2 version bytes and a 2-byte little-endian header length, and
|
|
// the whole preamble padded so the data starts on a 64-byte boundary.
|
|
constexpr std::size_t kPreamble = 10;
|
|
const std::size_t unpadded = kPreamble + header.size() + 1;
|
|
const std::size_t padded = (unpadded + 63) / 64 * 64;
|
|
header.append(padded - unpadded, ' ');
|
|
header.push_back('\n');
|
|
if (header.size() > 0xFFFFu) {
|
|
error = "too many columns for a v1.0 .npy header";
|
|
return false;
|
|
}
|
|
|
|
File out(path);
|
|
if (!opened(out, path, error)) return false;
|
|
const unsigned char len[2] = {static_cast<unsigned char>(header.size() & 0xFFu),
|
|
static_cast<unsigned char>((header.size() >> 8) & 0xFFu)};
|
|
std::fwrite("\x93NUMPY\x01\x00", 1, 8, out.f);
|
|
std::fwrite(len, 1, 2, out.f);
|
|
std::fwrite(header.data(), 1, header.size(), out.f);
|
|
|
|
// One record at a time, into a reused buffer: a column-major staging array would be a
|
|
// second copy of the whole export, which at a full ring is tens of megabytes.
|
|
std::vector<unsigned char> record(2 * sizeof(f64) + table.columns.size() * sizeof(f32));
|
|
for (std::size_t i = 0; i < table.rows(); ++i) {
|
|
std::size_t at = 0;
|
|
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
|
at += sizeof(f64);
|
|
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
|
at += sizeof(f64);
|
|
for (const ExportTable::Column& col : table.columns) {
|
|
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
|
std::memcpy(record.data() + at, &v, sizeof(f32));
|
|
at += sizeof(f32);
|
|
}
|
|
if (std::fwrite(record.data(), 1, record.size(), out.f) != record.size()) {
|
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
}
|
|
if (std::fflush(out.f) != 0) {
|
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error) {
|
|
if (table.empty()) {
|
|
error = "nothing to export: no signal is plotted in the visible range";
|
|
return false;
|
|
}
|
|
File out(path);
|
|
if (!opened(out, path, error)) return false;
|
|
|
|
const std::vector<std::string> labels = column_labels(table);
|
|
std::string line = "time,stream_time";
|
|
for (const std::string& label : labels) line += "," + label;
|
|
line += "\n";
|
|
std::fwrite(line.data(), 1, line.size(), out.f);
|
|
|
|
// `%.17g` for the f64 times and `%.9g` for the f32 values: the shortest widths that
|
|
// round-trip each type exactly. Anything less silently rewrites the samples, which is
|
|
// the one thing an export may not do.
|
|
char buf[64];
|
|
for (std::size_t i = 0; i < table.rows(); ++i) {
|
|
line.clear();
|
|
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
|
line += buf;
|
|
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
|
line += ",";
|
|
line += buf;
|
|
for (const ExportTable::Column& col : table.columns) {
|
|
std::snprintf(buf, sizeof(buf), "%.9g",
|
|
double(i < col.values.size() ? col.values[i] : 0.0f));
|
|
line += ",";
|
|
line += buf;
|
|
}
|
|
line += "\n";
|
|
if (std::fwrite(line.data(), 1, line.size(), out.f) != line.size()) {
|
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
}
|
|
if (std::fflush(out.f) != 0) {
|
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
} // namespace udpscope
|