// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "export.hpp" #include #include #include #include #include namespace udpscope { namespace { // The writers memcpy `f64`/`f32` straight out and label them `= '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 column_labels(const ExportTable& table) { std::vector taken{"time", "stream_time"}; std::vector 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 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& infos = model.channels(); const auto name_of = [&](int c) { return static_cast(c) < infos.size() ? infos[static_cast(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 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& st = snap->stamps; const auto lo = std::lower_bound(st.begin(), st.end(), static_cast(view.min)); const auto hi = std::upper_bound(st.begin(), st.end(), static_cast(view.max)); if (hi <= lo) return table; const std::size_t first = static_cast(lo - st.begin()); const std::size_t n = static_cast(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(st[first + i]); table.stream_time[i] = edge + table.time[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); if (ch >= snap->values.size()) continue; const std::vector& src = snap->values[ch]; if (src.size() < first + n) continue; table.columns.push_back({name_of(c), std::vector(src.begin() + static_cast(first), src.begin() + static_cast(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 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 stamps(static_cast(n)); store.time().copy_from(first, n, stamps.data()); table.time.resize(static_cast(n)); table.stream_time.resize(static_cast(n)); for (std::size_t i = 0; i < stamps.size(); ++i) { table.stream_time[i] = static_cast(stamps[i]); table.time[i] = epoch + table.stream_time[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); if (ch >= model.channel_count()) continue; std::vector values(static_cast(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 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', ' 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(header.size() & 0xFFu), static_cast((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 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 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