The loader pushed one slot per file row while the plan decimated by eight, so a 10 M-row file overran the ring and only its last 4.19 s survived -- a 20 s recording that read as a 4 s one. Each group of `time_stride` rows is now averaged into one slot at the group's mean stamp, as the packet path already does, and the load line reports rows and decimation so a filtered file cannot be mistaken for a truncated one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
92 lines
4.0 KiB
C++
92 lines
4.0 KiB
C++
// SPDX-License-Identifier: MIT
|
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
|
|
|
#pragma once
|
|
|
|
#include "model.hpp"
|
|
|
|
#include <cstdio>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace udpscope {
|
|
|
|
/// A `.npy` written by `write_npy()`: one packed structured array whose *dtype names the columns*
|
|
/// (`time`, `abs_seconds`, then one field per signal). That is what makes an export readable again
|
|
/// without a sidecar: the signal names are in the file.
|
|
///
|
|
/// Read one record at a time, not into a table: an export of a wide window on a fast stream is
|
|
/// hundreds of megabytes, and the model's rings are the copy that matters -- a second full copy in
|
|
/// a vector would double the peak for nothing.
|
|
class WaveformReader {
|
|
public:
|
|
WaveformReader() = default;
|
|
~WaveformReader();
|
|
|
|
WaveformReader(const WaveformReader&) = delete;
|
|
WaveformReader& operator=(const WaveformReader&) = delete;
|
|
|
|
/// Parses the header and rewinds to the first record. On failure `error` says what the file is
|
|
/// (not a NumPy file, not a udpscope export, truncated) and nothing else is usable.
|
|
bool open(const std::string& path, std::string* error);
|
|
void close() noexcept;
|
|
|
|
std::size_t rows() const noexcept { return rows_; }
|
|
/// The *signal* columns, in file order: `time` and `abs_seconds` are the two time columns and are
|
|
/// read through `next()`, not listed here.
|
|
const std::vector<std::string>& names() const noexcept { return names_; }
|
|
/// Seconds between the first two records: an export's `time` column is the ring's own stamps, which
|
|
/// are uniform at the stored rate. 0 when there are fewer than two rows.
|
|
double sample_dt() const noexcept { return sample_dt_; }
|
|
|
|
/// Reads the next record into `time`, `abs_seconds` and `values` (`names().size()` of them).
|
|
/// False at the end of the file or on a short read.
|
|
bool next(double* time, double* abs_seconds, float* values);
|
|
|
|
private:
|
|
std::FILE* f_ = nullptr;
|
|
std::size_t rows_ = 0;
|
|
std::size_t rows_read_ = 0;
|
|
std::size_t record_bytes_ = 0;
|
|
double sample_dt_ = 0.0;
|
|
std::vector<std::string> names_;
|
|
/// Bytes per *field*, in file order: the two time columns then the signals. Kept so a column an
|
|
/// external tool widened to `<f8` is read as one rather than mis-sliced by an assumed width.
|
|
std::vector<std::size_t> field_bytes_;
|
|
};
|
|
|
|
/// What a load put into the model, for the log and the status line.
|
|
struct WaveformLoadReport {
|
|
std::size_t signals = 0;
|
|
/// Slots actually stored, which is the file's row count divided by `decimation`.
|
|
std::size_t samples = 0;
|
|
/// Records the file held, and the group size each stored slot is the mean of. They differ when the
|
|
/// recording does not fit the buffer at its own rate: `samples * decimation == rows` (up to the
|
|
/// last, partial group), and a `decimation` above 1 is the only honest way to read a `samples`
|
|
/// below `rows`.
|
|
std::size_t rows = 0;
|
|
std::uint64_t decimation = 1;
|
|
double span_s = 0.0;
|
|
double rate_hz = 0.0;
|
|
double first_time = 0.0;
|
|
double last_time = 0.0;
|
|
};
|
|
|
|
/// Loads a `.npy` export into `model` as if it had been streamed: one signal and channel per
|
|
/// column, every sample pushed into the live ring, and the display epoch pinned to the file's last
|
|
/// stamp so the axis reads `[-window, 0]` exactly as the export did.
|
|
///
|
|
/// Takes `model.mutex()` for the whole load. There is no acquisition behind this -- nothing arrives
|
|
/// afterwards -- so the caller must not start one, and the UI must not expect frames.
|
|
bool load_waveform_into_model(ScopeModel& model, const std::string& path,
|
|
WaveformLoadReport* report, std::string* error);
|
|
|
|
/// The waveform files opened before, newest first: one path per line in
|
|
/// `<config_dir>/waveforms.txt`. Written by `remember_waveform()`, which is called on every
|
|
/// successful open -- including the one the command line asked for, so the list fills up from
|
|
/// either entry point.
|
|
std::vector<std::string> recent_waveforms();
|
|
void remember_waveform(const std::string& path);
|
|
|
|
} // namespace udpscope
|