Correct downsampling and better layout

This commit is contained in:
Martino Ferrari committed 2026-10-02 08:19:10 +02:00
1 parent 0d741c401b
commit 30db44eaa0
13 files changed
+1511 -252

No files matched your search

+59 -11
View File
@@ -7,6 +7,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <stdexcept>
#include <string>
#include <unordered_map>
@@ -29,7 +30,8 @@ u64 Partition::total_samples() const noexcept {
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes) {
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) {
Partition p;
const std::size_t n = channel_dt.size();
p.stride.assign(n, 1);
@@ -48,16 +50,12 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
if (!(base_dt > 0.0)) base_dt = 1.0e-3;
p.base_dt = base_dt;
// No per-channel decimation, and none on the shared clock either. A stride of
// `round(dt_i / dt_fastest)` throws away all but one sample per stride, and when
// that stride equals the signal's own period -- a 2 kHz sine on a 1 MHz clock,
// stride 500 -- every kept sample sits at the same phase: the channel plots as a
// flat line. Decimating the shared clock to make a 600 s window fit did the same
// to *every* channel. The LOD min/max pyramid is what preserves the extremes at
// draw time, and it needs the real samples; the buffer simply holds as many as
// it holds, and the window dropdown is capped to that span.
// No *per-channel* decimation. A stride of `round(dt_i / dt_fastest)` keeps one sample
// in every stride, and when that stride equals the signal's own period -- a 2 kHz sine
// on a 1 MHz clock, stride 500 -- every kept sample sits at the same phase and the
// channel plots as a flat line. The shared clock is decimated below instead, which
// affects every channel equally and goes through the ingest filter.
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
p.time_stride = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32);
@@ -97,6 +95,23 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) {
p.value_capacity[i] = capacity_for(p.time_capacity, p.stride[i]);
}
// Only now, with the ring as long as the budget can buy, decimate the shared clock
// enough for the requested window to fit in it: deciding this earlier would over-
// decimate by whatever factor the growth loop was about to add. A power of two keeps
// the ring capacities (themselves powers of two) in step and keeps `tick % period`
// cheap. The cap stops a nonsense request -- a window far past `--max-window` -- from
// spinning here; at 2^20 the stored rate is six decades below the wire rate and the
// picture is averages of averages anyway.
u64 m = 1;
if (target_span_s > 0.0) {
while (m < (u64(1) << 20) &&
static_cast<double>(time_capacity) * static_cast<double>(m) * base_dt <
target_span_s) {
m *= 2;
}
}
p.time_stride = m;
p.span_s = static_cast<double>(p.time_capacity) * static_cast<double>(p.time_stride) * base_dt;
return p;
}
@@ -184,12 +199,37 @@ void ScopeModel::rebuild_locked() {
return;
}
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0);
part_ = plan_partition(dts, cfg_.buffer_mb * 1024.0 * 1024.0,
target_span_.load(std::memory_order_relaxed));
store_ = std::make_unique<BufferStore>(part_, part_.span_s);
if (trigger_.channel >= static_cast<int>(channels_.size())) trigger_.channel = -1;
}
bool ScopeModel::set_target_span(double seconds) {
if (!(seconds > 0.0)) return false;
std::lock_guard<std::mutex> lock(mutex_);
const double previous = target_span_.exchange(seconds, std::memory_order_relaxed);
if (channels_.empty()) return false;
// Re-plan only if the factor moves. The planner is cheap, so ask it rather than
// reimplementing its arithmetic here -- two expressions for the same decision is how
// the buffer and the view end up disagreeing about the stored rate.
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);
if (want.time_stride == part_.time_stride) return false;
std::fprintf(stderr,
"[udpscope] window %.4f s -> %.4f s: decimation %llu -> %llu, stored rate "
"%.4g -> %.4g S/s (anti-alias %.4g Hz); buffer rebuilt\n",
previous, seconds, static_cast<unsigned long long>(part_.time_stride),
static_cast<unsigned long long>(want.time_stride), part_.stored_rate(),
want.stored_rate(), want.antialias_hz());
std::fflush(stderr);
rebuild_locked();
return true;
}
bool ScopeModel::is_time_signal(const std::vector<SignalInfo>& signals,
std::size_t signal) {
if (signal >= signals.size()) return false;
@@ -331,15 +371,23 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
snap->values.resize(channels_.size());
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
snap->pyramids.resize(channels_.size());
for (std::size_t c = 0; c < channels_.size(); ++c) {
auto& vals = snap->values[c];
vals.resize(static_cast<std::size_t>(n));
if (n > 0) src.values(c).copy_from(first, n, vals.data());
f32 lo = vals.empty() ? 0.0f : vals.front();
f32 hi = lo;
// The LOD index is folded into the bounds pass that was already here, so the
// snapshot costs one O(n) walk per channel rather than two. Without a pyramid
// the renderer's `level_for()` descends to level 0 and the whole capture is
// drawn raw on every frame the event is held: see `EventSnapshot::pyramids`.
auto& pyr = snap->pyramids[c];
pyr.reserve(n);
for (f32 v : vals) {
lo = std::min(lo, v);
hi = std::max(hi, v);
pyr.append(v);
}
snap->y[c] = plot::Range{lo, hi};
}