fixed plot flicker, measurement cost and toolbar icons

The flicker ("no data" at no constant frequency, worse with the measurements
and cursors panes up) was a torn read of the window's first stamp: acquire()
pins the window to the ring's oldest slot, and reading it at paint time races
the producer lapping it, returning a stamp one ring span too new. x0() is the
origin every trace point is reconstructed from, so one torn read produced a
refused neighbours(), a filter that dropped three quarters of the points, and
a polyline shifted off its axis. It is cached by acquire() under the lock now.

EventSeries::neighbours() answered from the (x - x0)/dx grid, whose drift from
the real arrival stamps arrives as a constant offset in seconds -- a doubling
share of the screen per zoom step, which is the "zooming into a held event
clips it more from the left" report. Both series binary-search the stamps now.

The measurements pane went through the LOD pyramid rather than reading its
window raw (19.7 ms per recompute to 1.2 ms, 0.1% of a 2 M-sample window) and
recomputes on a cadence; the math engine lost a per-slot heap allocation, a
per-sample source lookup by name and a fetch_add that cost more than the
arithmetic it counted.

The cursors pane's mix mode drew a line per *named* slot, so a slot holding a
signal that can never produce a channel -- the stream's timestamp -- got a row
that could only read "--", and such names were re-saved every session. It
counts bound traces now and apply_layout() refuses them.

Four toolbar icons are drawn for this app and compiled in as a GResource: the
stock ones were a record dot for the trigger and three magnifiers for cursors,
measurements and math.

New probes and dump lines for all of it (trace ink, measure cost, math cost,
event zoom, toolbar icons); README and CLAUDE.md record what they cure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-03 23:36:07 +02:00
1 parent 714d042809
commit a92a6b5bf0
20 files changed
+1945 -243

No files matched your search

+202 -105
View File
@@ -8,30 +8,185 @@
namespace udpscope {
MeasureStats measure_stats(const std::vector<double>& time, const std::vector<double>& value) {
MeasureStats s;
const std::size_t n = std::min(time.size(), value.size());
for (std::size_t i = 0; i < n; ++i) {
const double v = value[i];
namespace {
/// One crossing *into* side `e` at time `t`, which is also what closes the interval the walk was
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
/// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
void enter_side(MeasureLevel& acc, std::size_t e, double t) {
const std::size_t other = 1 - e;
if (acc.enters[e] == 0) acc.first_enter[e] = t;
acc.last_enter[e] = t;
++acc.enters[e];
if (acc.open[other]) {
acc.width_sum[other] += t - acc.open_at[other];
++acc.widths[other];
acc.open[other] = false;
}
acc.open_at[e] = t;
acc.open[e] = true;
}
} // namespace
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
// than one running total over a few million terms.
double sum = 0.0;
std::size_t count = 0;
double lo = 0.0;
double hi = 0.0;
std::size_t at_lo = 0;
std::size_t at_hi = 0;
bool any = false;
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
if (!s.valid) {
s.lo = s.hi = v;
s.at_lo = s.at_hi = time[i];
s.valid = true;
if (!any) {
lo = hi = v;
at_lo = at_hi = i;
any = true;
} else {
if (v < s.lo) {
s.lo = v;
s.at_lo = time[i];
if (v < lo) {
lo = v;
at_lo = i;
}
if (v > s.hi) {
s.hi = v;
s.at_hi = time[i];
if (v > hi) {
hi = v;
at_hi = i;
}
}
s.sum += v;
++s.count;
sum += v;
++count;
}
if (!any) return;
s.sum += sum;
s.count += count;
if (!s.valid) {
s.lo = lo;
s.hi = hi;
s.lo_i = chunk.offset + at_lo;
s.hi_i = chunk.offset + at_hi;
s.lo_search = 0;
s.hi_search = 0;
s.valid = true;
return;
}
// Strictly better only: the *first* sample to reach an extreme is the one a reader's eye lands
// on, so a later chunk that merely ties it does not move the mark.
if (lo < s.lo) {
s.lo = lo;
s.lo_i = chunk.offset + at_lo;
s.lo_search = 0;
}
if (hi > s.hi) {
s.hi = hi;
s.hi_i = chunk.offset + at_hi;
s.hi_search = 0;
}
}
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) {
double sum = 0.0;
std::size_t finite = 0;
for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b];
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
// walk skips exactly those samples -- so it contributes nothing here either.
if (k.count == 0) continue;
sum += k.sum;
finite += k.count;
if (!s.valid) {
s.lo = k.lo;
s.hi = k.hi;
s.lo_i = s.hi_i = k.offset;
s.lo_search = s.hi_search = k.span;
s.valid = true;
continue;
}
if (k.lo < s.lo) {
s.lo = k.lo;
s.lo_i = k.offset;
s.lo_search = k.span;
}
if (k.hi > s.hi) {
s.hi = k.hi;
s.hi_i = k.offset;
s.hi_search = k.span;
}
}
s.sum += sum;
s.count += finite;
}
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count) {
for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k];
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = static_cast<double>(chunk.value[i]);
if (!std::isfinite(v)) continue;
const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)];
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
// the side it last decided. That is what stops a signal which merely touches the level from
// counting every sample as an edge.
if (v == acc.level) continue;
if (acc.have_side && s != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
}
acc.side = s != 0;
acc.have_side = true;
}
}
}
bool measure_accumulate_bucket(const MeasureBucket& k,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& acc) {
if (k.count == 0) return true;
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
if (k.count != k.span) return false;
// Strictly on one side, so every sample decides the same side and none is *at* the level --
// which is what makes this exact rather than an approximation of the walk.
const bool above = k.lo > acc.level;
const bool below = k.hi < acc.level;
if (!above && !below) return false;
const std::size_t e = above ? 1u : 0u;
acc.sum[e] += k.sum;
acc.count[e] += k.count;
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
// The edge is the bucket's *first* sample: everything before it was on the other side and
// everything in the bucket is on this one, so that is where the raw walk would have found it.
enter_side(acc, e, time_of(k.offset));
}
acc.side = e != 0;
acc.have_side = true;
return true;
}
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
return true;
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
pct = 0.5;
return true;
default:
return false;
}
return s;
}
@@ -77,27 +232,11 @@ bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDura
bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const std::vector<double>& time, const std::vector<double>& value,
MeasureType type, const MeasureOptions& options,
const MeasureStats* stats) {
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level, MeasureType type,
const MeasureOptions& options) {
MeasureResult out;
const std::size_t n = std::min(time.size(), value.size());
if (n == 0) return out;
// A threshold as a share of the window's own range, which is what makes one percentage
// travel across signals: a threshold in volts would have to be typed for each. The bounds
// come from the caller when it has them (several measurements on one signal), because the
// scan is O(visible samples) and this runs per frame while a live window scrolls.
const MeasureStats b = stats != nullptr ? *stats : measure_stats(time, value);
if (!b.valid) return out;
// A threshold as a share of the window's own range. A flat window has no range at all, so
// the level is the signal's one value -- every sample is on neither side of it (nothing is
// strictly above or below), and the crossing types then find no edge, which is the honest
// answer for a signal that never moves.
const double pct = std::clamp(options.threshold_pct, 0.0, 100.0) / 100.0;
const double level = b.lo + pct * (b.hi - b.lo);
switch (type) {
case MeasureType::Average: {
if (b.count == 0) return out;
@@ -133,17 +272,13 @@ MeasureResult compute_measure(const std::vector<double>& time, const std::vector
}
case MeasureType::Top:
case MeasureType::Bottom: {
const bool want_high = type == MeasureType::Top;
double sum = 0.0;
std::size_t used = 0;
for (std::size_t i = 0; i < n; ++i) {
if (!std::isfinite(value[i])) continue;
if ((value[i] > level) != want_high) continue;
sum += value[i];
++used;
}
if (used == 0) return out;
out.primary = sum / static_cast<double>(used);
// The mean of one side of the level, straight out of the second pass' running sums. A flat
// window has no range at all, so every sample lands on the "below" side -- which is why
// `top` on a signal that never moves reads `--` rather than that one value.
if (level == nullptr) return out;
const std::size_t s = type == MeasureType::Top ? 1 : 0;
if (level->count[s] == 0) return out;
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
@@ -151,72 +286,34 @@ MeasureResult compute_measure(const std::vector<double>& time, const std::vector
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency: {
// No options here on purpose: a period is the spacing of *rising* crossings of the
// *mid* level -- one edge per cycle -- and a threshold or a side to choose from would
// only let the user make the count wrong (a threshold near either extreme counts noise
// as crossings; counting both edges halves the period).
const double period_level = b.lo + 0.5 * (b.hi - b.lo);
// Every strict side change is an edge. "Strict" is what keeps a signal that merely
// touches the level from counting each sample as a crossing: a sample equal to the
// level is on neither side, so the walk holds the last decided side until one is.
struct Edge {
double t;
bool rising;
};
std::vector<Edge> edges;
bool have_side = false;
bool side = false;
for (std::size_t i = 0; i < n; ++i) {
const double v = value[i];
if (!std::isfinite(v) || v == period_level) continue;
const bool cur = v > period_level;
if (have_side && cur != side) edges.push_back({time[i], cur});
side = cur;
have_side = true;
}
if (edges.size() < 2) return out;
// The pulse is the `above` side: its widths run from the edge that enters it to the
// edge that leaves it, and a period is the spacing of the edges that enter it -- one
if (level == nullptr) return out;
// The pulse is one side of the mid level: its widths run from the edge that enters it to
// the edge that leaves it, and a period is the spacing of the edges that *enter* it -- one
// per cycle, whatever else the waveform does in between.
std::vector<double> starts;
std::vector<double> widths;
for (std::size_t i = 0; i + 1 < edges.size(); ++i) {
if (edges[i].rising != options.above) continue;
starts.push_back(edges[i].t);
if (edges[i + 1].rising == options.above) continue;
widths.push_back(edges[i + 1].t - edges[i].t);
}
const std::size_t s = options.above ? 1 : 0;
const double enter_span = level->last_enter[s] - level->first_enter[s];
const double enter_period = level->enters[s] >= 2
? enter_span / static_cast<double>(level->enters[s] - 1)
: 0.0;
if (type == MeasureType::PulseDuration) {
if (widths.empty()) return out;
double sum = 0.0;
for (const double w : widths) sum += w;
out.primary = sum / static_cast<double>(widths.size());
if (level->widths[s] == 0) return out;
out.primary = level->width_sum[s] / static_cast<double>(level->widths[s]);
// Duty as width/period, not as pulse-time over the window: a window that starts or
// ends mid-pulse holds partial cycles, and counting only the complete ones against
// the whole span would report a duty that depends on where the window happened to
// begin. The period is the spacing of the pulse's own enter-edges.
double period = 0.0;
if (starts.size() >= 2) {
period = (starts.back() - starts.front()) / static_cast<double>(starts.size() - 1);
}
// The duty needs a period, which needs two pulses; a window holding one reports the
// width alone rather than a duty computed against a span that is not a cycle.
out.secondary = period > 0.0 ? out.primary / period : 0.0;
out.has_secondary = period > 0.0;
if (!starts.empty()) {
out.marks.push_back({false, starts.front()});
out.marks.push_back({false, starts.front() + out.primary});
}
// begin. The period is the spacing of the pulse's own enter-edges, and it needs two of
// them -- a window holding one pulse reports the width alone.
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
out.has_secondary = enter_period > 0.0;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + out.primary});
out.valid = true;
return out;
}
if (starts.size() < 2) return out;
const double span = starts.back() - starts.front();
const double period = span / static_cast<double>(starts.size() - 1);
if (!(period > 0.0)) return out;
out.primary = type == MeasureType::Period ? period : 1.0 / period;
out.marks.push_back({false, starts.front()});
out.marks.push_back({false, starts.front() + period});
if (!(enter_period > 0.0)) return out;
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
out.marks.push_back({false, level->first_enter[s]});
out.marks.push_back({false, level->first_enter[s] + enter_period});
out.valid = true;
return out;
}