fix: store the stream clock as f64, so a stamp's precision does not decay with uptime

The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.

A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.

Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 10:41:57 +02:00
1 parent eb4372242c
commit ec93a9831d
10 files changed
+159 -81

No files matched your search

+1 -1
View File
@@ -518,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
time_acc_ += rel;
++time_acc_n_;
if (tick % g == g - 1) {
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
time_acc_ = 0.0;
time_acc_n_ = 0;
}
+8 -8
View File
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
// --- ChannelBuffer ----------------------------------------------------------
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
f64 epoch, f64 nominal_dt)
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
epoch_(epoch), dt_(nominal_dt) {}
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
void ChannelBuffer::invalidate() noexcept {
published_ = 0;
window_x0_ = 0.0f;
window_x0_ = 0.0;
pyramid_.clear();
}
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// Channel sample `j` is produced on the tick that also pushes time slot
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
// time ring every `time_stride` ticks and the channel every `stride` of those,
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// whole trace's geometry collapsing.
const u64 base = times_->base();
const u64 produced = times_->produced();
if (produced == 0) return 0.0f;
if (produced == 0) return 0.0;
u64 slot = index < base ? base : index;
if (slot >= produced) slot = produced - 1;
f32 out = 0.0f;
f64 out = 0.0;
times_->copy_from(slot, 1, &out);
return out;
}
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(window_x0_);
return epoch_ + window_x0_;
}
f64 ChannelBuffer::x_at(u64 i) const {
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
// ring-span too new made it refuse the whole visible range -- which left the renderer
// gathering the entire ring at a coarse level and drawing it off the axis.
if (i == 0) return x0();
return epoch_ + f64(time_of(absolute(i)));
return epoch_ + time_of(absolute(i));
}
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
plot::Range ChannelBuffer::x_bounds() const {
const u64 n = pyramid_.size();
if (n == 0) return plot::Range{epoch_, epoch_};
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
}
plot::Range ChannelBuffer::y_bounds() const {
+6 -4
View File
@@ -34,8 +34,10 @@ using plot::u64;
/// undecimated case and the common one.
class ChannelBuffer final : public plot::data::Series {
public:
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
/// alone needs the wider type.
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
f64 nominal_dt);
ChannelBuffer(const ChannelBuffer&) = delete;
@@ -94,10 +96,10 @@ class ChannelBuffer final : public plot::data::Series {
/// Absolute (channel) index of retained-window index `i`.
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
f32 time_of(u64 absolute_index) const noexcept;
f64 time_of(u64 absolute_index) const noexcept;
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
u64 stride_ = 1;
f64 epoch_ = 0.0;
f64 dt_ = 1.0;
@@ -114,7 +116,7 @@ class ChannelBuffer final : public plot::data::Series {
/// the "live plot flickers as if there were no data" defect. The cached value is also the
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
f32 window_x0_ = 0.0f;
f64 window_x0_ = 0.0;
plot::data::MinMaxPyramid pyramid_;
};
+7 -1
View File
@@ -26,7 +26,13 @@ using plot::u64;
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop.
struct EventSnapshot {
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
std::vector<f64> stamps;
std::vector<std::vector<f32>> values; ///< one array per channel, same length
double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept
+6 -6
View File
@@ -109,9 +109,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (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));
const std::vector<f64>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), 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);
@@ -122,7 +122,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
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.time[i] = st[first + i];
table.stream_time[i] = edge + table.time[i];
}
for (const int c : chans) {
@@ -151,12 +151,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (n == 0) return table;
BufferStore& store = model.live();
std::vector<f32> stamps(static_cast<std::size_t>(n));
std::vector<f64> 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.stream_time[i] = stamps[i];
table.time[i] = epoch + table.stream_time[i];
}
for (const int c : chans) {
+27 -29
View File
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
u64 Partition::total_samples() const noexcept {
u64 total = time_capacity;
for (u64 c : value_capacity) total += c;
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) {
Partition p;
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32);
double weight = 1.0; // the time slice itself
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
// 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
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = budget / static_cast<u64>(std::llround(weight));
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel");
@@ -102,11 +99,13 @@ 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;
for (u64 s : p.stride) total += capacity_for(ticks, s);
return total * sizeof(f32);
u64 total = ticks * sizeof(f64);
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total;
};
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
while (bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2;
}
p.time_capacity = time_capacity;
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
BufferStore::BufferStore(const Partition& part, f64 span_s)
: part_(part),
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
(void)span_s;
const std::size_t n = part.value_capacity.size();
values_.reserve(n);
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time();
plot::data::RingBuffer<f64>& tsrc = src.time();
// The slice is one absolute index range, found in the time ring and then shared by
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
// truncations that could disagree.
const u64 tbase = tsrc.produced() - tsrc.size();
const u64 tend = tsrc.produced();
// The bounds are rounded to the ring's own `f32` before they are compared against
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
// comparison decides a sample that sits exactly on a bound by which way its stamp
// happened to round -- the same nominal window then keeps or drops its first sample
// depending on how large the stream time is (`f32(38.001) < 38.001` but
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
// sample include it, always.
const f32 t_lo = static_cast<f32>(lo_s);
const f32 t_hi = static_cast<f32>(hi_s);
// The bounds are compared in the ring's own type, which is the `f64` they arrive in,
// so a bound that lands exactly on a sample includes it. This used to need rounding
// *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
// a sample by which way its stamp happened to round, and the same nominal window kept
// or dropped its first sample depending on the magnitude of the stream time
// (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
// question does not arise.
const f64 t_lo = lo_s;
const f64 t_hi = hi_s;
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
// stamps is the whole point: the window is in seconds and this ring is the only
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
last = a;
for (std::size_t c = 0; c < channels_.size(); ++c) {
plot::data::RingBuffer<f32>& from = src.values(c);
plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
first = std::max(first, from.produced() - from.size());
last = std::min(last, from.produced());
}
@@ -554,7 +552,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>();
BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time();
plot::data::RingBuffer<f64>& tsrc = src.time();
u64 first = 0, last = 0;
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
@@ -566,7 +564,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
// edge at exactly 0, with no axis-level shift any other code path could
// disagree with.
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
for (auto& t : snap->stamps) t -= edge;
}
snap->values.resize(channels_.size());
+12 -7
View File
@@ -93,8 +93,6 @@ struct Partition {
double base_dt = 1.0; ///< seconds per base tick
double span_s = 0.0; ///< stream seconds one buffer covers
u64 total_samples() const noexcept;
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// 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.
@@ -125,8 +123,15 @@ class BufferStore {
/// axis read seconds from the trigger event.
void set_epoch(f64 epoch) noexcept;
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
/// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
/// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
/// magnitude grows without bound over a run's life while the interval that has to be
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
/// 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_; }
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
@@ -141,7 +146,7 @@ class BufferStore {
private:
Partition part_;
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
};
@@ -278,8 +283,8 @@ class ScopeModel {
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
/// clamped to what the time ring and *every* value ring still hold. One range for the
/// time ring and all the channels, which is what keeps a cut index-aligned by
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
/// lands on a sample includes it whatever the magnitude of the stream time.
/// construction; the bounds are compared in the ring's own type, which is now the
/// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
///
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
+21 -19
View File
@@ -2030,18 +2030,18 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f32>& st = snap->stamps;
const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[channel];
if (vals.size() != st.size()) return false;
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));
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
const std::size_t count = static_cast<std::size_t>(hi - lo);
if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(lo - st.begin());
time.resize(count);
value.resize(count);
for (std::size_t i = 0; i < count; ++i) {
time[i] = static_cast<double>(st[off + i]);
time[i] = st[off + i];
value[i] = static_cast<double>(vals[off + i]);
}
return true;
@@ -2058,21 +2058,23 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (n == 0) return false;
time.resize(static_cast<std::size_t>(n));
value.resize(static_cast<std::size_t>(n));
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full
// window is millions of samples, and the temporaries cost a full write and read of it on top
// of the result. `kChunk` keeps the scratch in cache.
// In bounded chunks rather than through two whole-window temporaries: the value ring holds
// f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not --
// a full window is millions of samples, and the temporaries cost a full write and read of it
// on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the
// clock ring is f64 and the values f32 (see `BufferStore::time()`).
constexpr u64 kChunk = 8192;
std::vector<f32> scratch(kChunk);
std::vector<f64> t_scratch(kChunk);
std::vector<f32> v_scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, scratch.data());
store.time().copy_from(first + at, take, t_scratch.data());
for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(at + i)] = epoch + static_cast<double>(scratch[i]);
time[static_cast<std::size_t>(at + i)] = epoch + t_scratch[i];
}
store.values(channel).copy_from(first + at, take, scratch.data());
store.values(channel).copy_from(first + at, take, v_scratch.data());
for (u64 i = 0; i < take; ++i) {
value[static_cast<std::size_t>(at + i)] = static_cast<double>(scratch[i]);
value[static_cast<std::size_t>(at + i)] = static_cast<double>(v_scratch[i]);
}
}
return true;
@@ -2098,10 +2100,10 @@ class MeasureWindowReader {
const plot::Range view = r0.plot->x_range(r0.axis_x);
if ((snap_ = app.model->display_snapshot()) != nullptr) {
if (channel >= snap_->values.size()) return;
const std::vector<f32>& st = snap_->stamps;
const std::vector<f64>& st = snap_->stamps;
if (snap_->values[channel].size() != st.size()) return;
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));
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
if (hi <= lo) return;
off_ = static_cast<std::size_t>(lo - st.begin());
count_ = static_cast<std::size_t>(hi - lo);
@@ -2174,10 +2176,10 @@ class MeasureWindowReader {
/// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment.
double time_of(std::size_t i) const {
if (snap_vals_ != nullptr) return static_cast<double>(snap_->stamps[off_ + i]);
f32 stamp = 0.0f;
if (snap_vals_ != nullptr) return snap_->stamps[off_ + i];
f64 stamp = 0.0;
store_->time().copy_from(first_ + i, 1, &stamp);
return epoch_ + static_cast<double>(stamp);
return epoch_ + stamp;
}
private: