diff --git a/src/acquisition.cpp b/src/acquisition.cpp index 3c8d900..8f8b3a6 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -135,6 +135,7 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign n = std::max(n, udps_signal_num_elements(&frame->signals[i])); } stamps_.assign(n, 0.0); + abs_stamps_.assign(n, 0.0); // Relative mode: the stamp is a pure function of position and the *declared* rate. Nothing // below this block runs -- not the arrival-cadence override, not the degeneracy guard, not @@ -168,6 +169,21 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign stamps_[k] = double(slot_base_ + k) * dt; } slot_base_ += n; + // The absolute clock is still read, for export only. When the stream carries no usable + // time signal there is no absolute clock to record and the relative stamp is the honest + // answer -- an export then says what the counter says, which is all anyone had. + if (time_signal >= 0 && time_signal < static_cast(frame->num_signals)) { + const udps_signal_t& ts = frame->signals[time_signal]; + const bool scalar_time = udps_signal_num_elements(&ts) == 1; + for (std::uint32_t k = 0; k < n; ++k) { + abs_stamps_[k] = + scalar_time + ? udps_frame_value(frame, static_cast(time_signal), k, 0) + : udps_frame_value(frame, static_cast(time_signal), 0, k); + } + } else { + for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k]; + } return n; } @@ -413,6 +429,10 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign for (std::uint32_t k = 0; k < n; ++k) stamps_[k] *= unit_scale; } + // Stream mode: `time()` already holds the absolute clock, so the parallel ring repeats it + // rather than carrying a second timeline. One rule for the export either way. + for (std::uint32_t k = 0; k < n; ++k) abs_stamps_[k] = stamps_[k]; + return n; } @@ -476,6 +496,7 @@ void Acquisition::on_data(const udps_frame_t* frame) { value_acc_n_.assign(channels.size(), 0); math_sample_.assign(channels.size(), std::nanf("")); time_acc_ = 0.0; + abs_time_acc_ = 0.0; time_acc_n_ = 0; } @@ -595,10 +616,17 @@ void Acquisition::on_data(const udps_frame_t* frame) { // boxcar's group delay puts the averaged value, and stamping it at the end of the // group would shift every trace half a stored sample late. time_acc_ += rel; + abs_time_acc_ += abs_stamps_[k] - epoch; ++time_acc_n_; if (tick % g == g - 1) { store.time().push(time_acc_ / static_cast(time_acc_n_)); time_acc_ = 0.0; + // The absolute clock on the same slot and under the same group mean, so an export can + // pair value, relative stamp and absolute stamp by index with no interpolation. + if (plot::data::RingBuffer* abs = store.abs_time(); abs != nullptr) { + abs->push(abs_time_acc_ / static_cast(time_acc_n_)); + } + abs_time_acc_ = 0.0; time_acc_n_ = 0; } diff --git a/src/acquisition.hpp b/src/acquisition.hpp index 2fa6fd4..7d044cf 100644 --- a/src/acquisition.hpp +++ b/src/acquisition.hpp @@ -102,6 +102,7 @@ class Acquisition { /// Latches the one warning about a frame whose length disagrees with the stream's first. bool warned_batch_ = false; std::vector stamps_; + std::vector abs_stamps_; /// The decimation filter's state: a running sum per channel (and one for the shared /// clock) over the group of base samples that becomes one stored sample. A boxcar /// average, which is what turns decimation into a *resampling* rather than a stride: @@ -114,6 +115,7 @@ class Acquisition { std::vector math_sample_; std::vector value_acc_n_; double time_acc_ = 0.0; + double abs_time_acc_ = 0.0; std::uint64_t time_acc_n_ = 0; /// The partition the accumulators were sized for, so a re-plan resets them rather than /// averaging across the boundary. diff --git a/src/event_series.hpp b/src/event_series.hpp index 528fc6c..cf2b707 100644 --- a/src/event_series.hpp +++ b/src/event_series.hpp @@ -32,6 +32,10 @@ struct EventSnapshot { /// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/ /// `publish_pause_view()` a straight copy. std::vector stamps; + /// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no + /// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives + /// the epoch that produced it, so it stores the absolute value outright. + std::vector abs_stamps; std::vector> values; ///< one array per channel, same length /// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the /// data arrived on: the edge for a trigger event, the newest sample at the moment of the diff --git a/src/export.cpp b/src/export.cpp index 1fa269c..e59e5fe 100644 --- a/src/export.cpp +++ b/src/export.cpp @@ -127,7 +127,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { table.abs_seconds.resize(n); for (std::size_t i = 0; i < n; ++i) { table.time[i] = st[first + i]; - table.abs_seconds[i] = abs_base + table.time[i]; + table.abs_seconds[i] = snap->abs_stamps.empty() + ? abs_base + table.time[i] + : snap->abs_stamps[first + i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); @@ -159,9 +161,16 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) { store.time().copy_from(first, n, stamps.data()); table.time.resize(static_cast(n)); table.abs_seconds.resize(static_cast(n)); + std::vector abs_stamps; + if (const plot::data::RingBuffer* abs_ring = store.abs_time(); abs_ring != nullptr) { + abs_stamps.resize(static_cast(n)); + abs_ring->copy_from(first, n, abs_stamps.data()); + } for (std::size_t i = 0; i < stamps.size(); ++i) { table.time[i] = stamps[i] - s0; - table.abs_seconds[i] = abs_base + table.time[i]; + table.abs_seconds[i] = abs_stamps.empty() + ? abs_base + table.time[i] + : model.epoch() + abs_stamps[i]; } for (const int c : chans) { const std::size_t ch = static_cast(c); diff --git a/src/model.cpp b/src/model.cpp index a82012e..bd52978 100644 --- a/src/model.cpp +++ b/src/model.cpp @@ -43,7 +43,7 @@ constexpr std::uint64_t kMathCostSample = 1024; } // namespace Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, - double target_span_s) { + double target_span_s, bool with_abs_time) { Partition p; const std::size_t n = channel_dt.size(); p.stride.assign(n, 1); @@ -69,12 +69,14 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by // affects every channel equally and goes through the ingest filter. for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1; + p.abs_time = with_abs_time; + const u64 budget_bytes = static_cast(std::max(1.0, buffer_bytes)); // Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32 // (see `BufferStore::time()`), so a time slot costs twice a value sample and the two // slices are not interchangeable. Counting both as one f32 is what would make // `--buffer-mb` quietly understate the time ring by its own size. - double weight = static_cast(sizeof(f64)); // the time slice itself + double weight = static_cast(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s) for (u64 s : p.stride) weight += static_cast(sizeof(f32)) / static_cast(s); const u64 slots = static_cast(static_cast(budget_bytes) / weight); @@ -99,7 +101,7 @@ Partition plan_partition(const std::vector& channel_dt, double buffer_by return std::max(pow2_floor(ticks / stride), 1); }; const auto bytes_for = [&](u64 ticks) { - u64 total = ticks * sizeof(f64); + u64 total = ticks * sizeof(f64) * (with_abs_time ? 2u : 1u); for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32); return total; }; @@ -139,6 +141,11 @@ BufferStore::BufferStore(const Partition& part, f64 span_s) : part_(part), time_(std::make_shared>(part.time_capacity)) { (void)span_s; + // Same capacity as the clock it parallels: the two are written on the same slots, so a + // smaller ring would make an export's stamps run out before its values do. + if (part.abs_time) { + abs_time_ = std::make_shared>(part.time_capacity); + } const std::size_t n = part.value_capacity.size(); values_.reserve(n); channels_.reserve(n); @@ -340,7 +347,7 @@ void ScopeModel::rebuild_locked() { } part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, - target_span_.load(std::memory_order_relaxed)); + target_span_.load(std::memory_order_relaxed), cfg_.relative_time); store_ = std::make_unique(part_, part_.span_s); if (trigger_.channel >= static_cast(channels_.size())) trigger_.channel = -1; @@ -358,7 +365,7 @@ bool ScopeModel::set_target_span(double seconds) { std::vector dts; dts.reserve(channels_.size()); for (const ChannelInfo& c : channels_) dts.push_back(c.dt); - const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds); + const Partition want = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0, seconds, cfg_.relative_time); if (want.time_stride == part_.time_stride) return false; std::fprintf(stderr, "[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate " @@ -524,6 +531,12 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) { src.time().copy_from(first, n, snap->stamps.data()); for (f64& t : snap->stamps) t -= x_offset; } + if (const plot::data::RingBuffer* abs = src.abs_time(); abs != nullptr) { + snap->abs_stamps.resize(static_cast(n)); + abs->copy_from(first, n, snap->abs_stamps.data()); + // Absolute seconds outright -- deliberately not rebased like `stamps`. + for (f64& t : snap->abs_stamps) t += epoch_; + } snap->values.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); snap->pyramids.resize(channels_.size()); @@ -588,6 +601,12 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) { // disagree with. for (auto& t : snap->stamps) t -= edge; } + if (const plot::data::RingBuffer* abs = src.abs_time(); abs != nullptr) { + snap->abs_stamps.resize(static_cast(n)); + abs->copy_from(first, n, snap->abs_stamps.data()); + // Absolute seconds outright -- deliberately not rebased like `stamps`. + for (f64& t : snap->abs_stamps) t += epoch_; + } snap->values.resize(channels_.size()); snap->y.resize(channels_.size(), plot::Range{0.0, 0.0}); diff --git a/src/model.hpp b/src/model.hpp index bbd8b1d..afeae87 100644 --- a/src/model.hpp +++ b/src/model.hpp @@ -93,6 +93,11 @@ struct Partition { double base_dt = 1.0; ///< seconds per base tick double span_s = 0.0; ///< stream seconds one buffer covers + /// True when a second f64 ring parallel to the clock was planned and paid for in the + /// byte budget. Relative mode only -- in stream mode `time()` already *is* the absolute + /// clock and a copy would cost ~22% of the user's `--buffer-mb` for nothing. + bool abs_time = false; + /// Seconds between two time-base slots (the sample interval of every channel). double slot_dt() const noexcept { return base_dt * static_cast(time_stride); } /// The rate samples are actually stored at, in samples per second. @@ -109,9 +114,10 @@ struct Partition { /// Plans a partition for `channel_dt` cadences within `buffer_bytes`, covering at least /// `target_span_s` seconds of stream -- decimating (with the ingest filter) by whatever /// power of two that takes. Throws std::runtime_error when the budget cannot hold even -/// one sample per channel. +/// one sample per channel. `with_abs_time` adds a second f64 per time slot to the budget +/// and sets `Partition::abs_time`. Partition plan_partition(const std::vector& channel_dt, double buffer_bytes, - double target_span_s); + double target_span_s, bool with_abs_time = false); /// One of the two identical sample buffers: a shared time-base ring plus one /// value ring and one `ChannelBuffer` per channel. @@ -132,6 +138,15 @@ class BufferStore { /// bits is plenty; a *timestamp's* is a property of how long the scope has been running. plot::data::RingBuffer& time() noexcept { return *time_; } const plot::data::RingBuffer& time() const noexcept { return *time_; } + /// The stream's **absolute** clock, parallel to `time()` and on the same slots, or + /// `nullptr` when the store was planned without it. + /// + /// There is exactly one time ring per store, shared by every channel, and relative mode + /// fills it with counter-derived stamps -- so without this ring, selecting relative mode + /// would silently drop absolute time from an export, the one output `todo.md` item 2 says + /// must keep it. Allocated in relative mode only; read only by the export path. + plot::data::RingBuffer* abs_time() noexcept { return abs_time_.get(); } + const plot::data::RingBuffer* abs_time() const noexcept { return abs_time_.get(); } plot::data::RingBuffer& values(std::size_t channel) { return *values_[channel]; } ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; } /// Shared handle to a channel view: the caller keeps the storage alive even if @@ -147,6 +162,7 @@ class BufferStore { private: Partition part_; std::shared_ptr> time_; + std::shared_ptr> abs_time_; std::vector>> values_; std::vector> channels_; };