feat: relative mode keeps the stream's absolute clock for the export

This commit is contained in:
Martino Ferrari committed 2026-10-08 17:20:47 +02:00
1 parent d11e7d48c4
commit 57e2f6aa8b
6 files changed
+87 -9

No files matched your search

+28
View File
@@ -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<int>(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<std::uint32_t>(time_signal), k, 0)
: udps_frame_value(frame, static_cast<std::uint32_t>(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<double>(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<f64>* abs = store.abs_time(); abs != nullptr) {
abs->push(abs_time_acc_ / static_cast<double>(time_acc_n_));
}
abs_time_acc_ = 0.0;
time_acc_n_ = 0;
}
+2
View File
@@ -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<double> stamps_;
std::vector<double> 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<f32> math_sample_;
std::vector<std::uint64_t> 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.
+4
View File
@@ -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<f64> 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<f64> abs_stamps;
std::vector<std::vector<f32>> 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
+11 -2
View File
@@ -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<std::size_t>(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<std::size_t>(n));
table.abs_seconds.resize(static_cast<std::size_t>(n));
std::vector<f64> abs_stamps;
if (const plot::data::RingBuffer<f64>* abs_ring = store.abs_time(); abs_ring != nullptr) {
abs_stamps.resize(static_cast<std::size_t>(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<std::size_t>(c);
+24 -5
View File
@@ -43,7 +43,7 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
Partition plan_partition(const std::vector<double>& 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<double>& 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<u64>(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<double>(sizeof(f64)); // the time slice itself
double weight = static_cast<double>(sizeof(f64)) * (with_abs_time ? 2.0 : 1.0); // the time slice(s)
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
@@ -99,7 +101,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
return std::max<u64>(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<plot::data::RingBuffer<f64>>(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<plot::data::RingBuffer<f64>>(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<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
@@ -358,7 +365,7 @@ bool ScopeModel::set_target_span(double seconds) {
std::vector<double> 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<f64>* abs = src.abs_time(); abs != nullptr) {
snap->abs_stamps.resize(static_cast<std::size_t>(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<f64>* abs = src.abs_time(); abs != nullptr) {
snap->abs_stamps.resize(static_cast<std::size_t>(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});
+18 -2
View File
@@ -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<double>(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<double>& 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<f64>& time() noexcept { return *time_; }
const plot::data::RingBuffer<f64>& 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<f64>* abs_time() noexcept { return abs_time_.get(); }
const plot::data::RingBuffer<f64>* abs_time() const noexcept { return abs_time_.get(); }
plot::data::RingBuffer<f32>& 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<plot::data::RingBuffer<f64>> time_;
std::shared_ptr<plot::data::RingBuffer<f64>> abs_time_;
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
};