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

+12 -3
View File
@@ -56,10 +56,15 @@ void ChannelBuffer::acquire() noexcept {
// The pyramid's window follows the ring's: sample index `base` is the oldest
// one still retained. drop_before() is monotonic and cheap.
pyramid_.drop_before(values_->base());
// And the window's first stamp with it, here and nowhere else (see the member's note): the
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
// place in the object's life where that sample's slot is safe to read.
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
}
void ChannelBuffer::invalidate() noexcept {
published_ = 0;
window_x0_ = 0.0f;
pyramid_.clear();
}
@@ -116,11 +121,16 @@ f64 ChannelBuffer::dx() const {
f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(time_of(absolute(0)));
return epoch_ + f64(window_x0_);
}
f64 ChannelBuffer::x_at(u64 i) const {
if (i >= pyramid_.size()) return x0();
// Sample 0 is served from the cache, never from the ring: it is the slot the producer is
// overwriting, and `neighbours()` tests the asked-for x against this value, so a read one
// 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)));
}
@@ -161,8 +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{epoch_ + f64(time_of(absolute(0))),
epoch_ + f64(time_of(absolute(n - 1)))};
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
}
plot::Range ChannelBuffer::y_bounds() const {
+13
View File
@@ -102,6 +102,19 @@ class ChannelBuffer final : public plot::data::Series {
f64 epoch_ = 0.0;
f64 dt_ = 1.0;
u64 published_ = 0;
/// The stamp of the window's **first** sample, read once by `acquire()` -- which runs on the
/// consumer under `ScopeModel::mutex()`, the same lock the producer's pushes hold, so the
/// slot cannot be overwritten under the read. Everything else on this object is read at
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
/// 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;
plot::data::MinMaxPyramid pyramid_;
};
+19 -6
View File
@@ -180,15 +180,28 @@ class EventSeries final : public plot::data::Series {
if (last * span < e) emit_raw(last * span, e);
}
/// Bracketing samples by **binary search over the stamps**, which are monotonic -- never
/// through the `(x - x0()) / dx()` grid, for the same reason `ChannelBuffer` does not: `dx()`
/// is the window's *mean* spacing and the stamps are an arrival clock, so the grid and the
/// real stamps drift apart by however much the clock wandered. The renderer picks the visible
/// index window from this call, so that drift came out as a **constant offset in data
/// seconds** between the axis and the gathered geometry: a blank left margin whose *pixel*
/// width doubles with every zoom step (374 px of a 716 px axis after seven), with the trace's
/// ink pushed off the right-hand edge by the same amount and the crosshair -- which reads
/// `x_at()`, the real stamp -- still correct everywhere. That is the "zooming into a held
/// event clips it more and more from the left" report. A perfectly uniform source hides it
/// completely, which is why the mock can jitter its clock (`--clock-jitter`).
bool neighbours(f64 x, u64& i0, u64& i1) const override {
const u64 n = size();
if (n < 2) return false;
const f64 step = dx();
if (!(step > 0.0)) return false;
f64 idx = (x - x0()) / step;
if (idx < 0.0) idx = 0.0;
if (idx > static_cast<f64>(n - 1)) idx = static_cast<f64>(n - 1);
i0 = static_cast<u64>(idx);
const auto& s = snap_->stamps;
const auto begin = s.begin();
const auto end = begin + static_cast<std::ptrdiff_t>(n);
// The first stamp *greater* than x, so x itself brackets as [that - 1, that]: a pointer
// exactly on a sample must not put the sample behind it.
const auto it = std::upper_bound(begin, end, static_cast<f32>(x));
const u64 above = static_cast<u64>(it - begin);
i0 = above > 0 ? above - 1 : 0;
i1 = i0 + 1 < n ? i0 + 1 : i0;
return true;
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The two vertical markers: a solid cap you grab, over a dashed shaft. Both parts
earn their place. A bare pair of lines does not say the markers are draggable,
a cap over a *solid* shaft is an audio fader to anyone who has seen a mixer, and
a tapered cap turns into a funnel at this size. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 1 H8 V4 H2 Z
M4 5 H6 V7 H4 Z
M4 8 H6 V10 H4 Z
M4 11 H6 V13 H4 Z
M4 14 H6 V16 H4 Z
M9 1 H15 V4 H9 Z
M11 5 H13 V7 H11 Z
M11 8 H13 V10 H11 Z
M11 11 H13 V13 H11 Z
M11 14 H13 V16 H11 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A sigma: the one pane that computes rather than reads. Four filled subpaths, all
wound clockwise so the default nonzero rule unions them where they overlap; the
diagonals run from the bars' left ends to a vertex right of centre, which is the
way the glyph actually points. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M3 2 H13 V4 H3 Z
M3 12 H13 V14 H3 Z
M4.64 2.23 L10.64 7.23 L9.36 8.77 L3.36 3.77 Z
M10.64 8.77 L4.64 13.77 L3.36 12.23 L9.36 7.23 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 762 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A ruler: the pane measures the waveform. The body is a hole punched out of the
frame, so under `evenodd` the ticks inside it are an *odd* number of crossings
deep (frame, hole, tick) and fill rather than cutting a second hole. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" fill-rule="evenodd"
d="M0 4 H16 V12 H0 Z
M2 6 H14 V10 H2 Z
M4 6 H5 V9 H4 Z
M6 6 H7 V8 H6 Z
M8 6 H9 V9 H8 Z
M10 6 H11 V8 H10 Z
M12 6 H13 V9 H12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A rising edge crossing a dashed trigger level: what the trigger *is*. The dash
rhythm is 2 on, 1 off (tight enough to read as one line rather than as a row of
dots), with the one wide gap at x 5..9 that the edge rises through. Every
coordinate is a whole pixel of the 16px cell so nothing blurs at 1x. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M0 11 H8 V13 H0 Z
M6 3 H8 V13 H6 Z
M6 3 H16 V5 H6 Z
M0 7 H2 V9 H0 Z
M3 7 H5 V9 H3 Z
M9 7 H11 V9 H9 Z
M12 7 H14 V9 H12 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The four icons this app had to draw for itself, compiled into the binary so there is
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
that means "an SVG, use it at any size".
-->
<gresources>
<gresource prefix="/org/udpscope/icons">
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
<file>scalable/actions/udpscope-math-symbolic.svg</file>
</gresource>
</gresources>
+36 -22
View File
@@ -5,11 +5,15 @@
#include <algorithm>
#include <cmath>
#include <limits>
namespace udpscope {
namespace {
constexpr double kPi = 3.14159265358979323846;
/// A value the step can return without a library call: `std::nan("")` parses its argument, and this
/// runs once per stored slot per node.
constexpr double kNaN = std::numeric_limits<double>::quiet_NaN();
/// A biquad's coefficients for one node, from the RBJ audio-EQ cookbook. `q` is 1/sqrt(2)
/// (Butterworth) for the plain low/high pass, and the node's own for the band pass -- which is
@@ -119,44 +123,54 @@ bool math_op_uses_filter(MathOp op) {
}
bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; }
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt) {
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of) {
const std::size_t n = channel.nodes.size();
rt.value.assign(n, 0.0);
rt.b0.assign(n, 1.0);
rt.b1.assign(n, 0.0);
rt.b2.assign(n, 0.0);
rt.a1.assign(n, 0.0);
rt.a2.assign(n, 0.0);
// A rate change *keeps* the filter state: the state words are in the signal's units, not in
// time, so a decimation change moves the cutoffs (which is why the coefficients are rebuilt)
// without making the trace jump. A node that is new gets zeros, which is a filter starting from
// rest -- the only honest starting point there is.
rt.z1.resize(n, 0.0);
rt.z2.resize(n, 0.0);
const std::vector<MathNodeRt> was = std::move(rt.node);
rt.node.assign(n, MathNodeRt{});
for (std::size_t i = 0; i < n && i < was.size(); ++i) {
rt.node[i].z1 = was[i].z1;
rt.node[i].z2 = was[i].z2;
}
rt.dt = dt;
rt.ready = true;
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i];
MathNodeRt& out = rt.node[i];
if (node.op == MathOp::Source) {
out.src = index_of ? index_of(node.source) : -1;
continue;
}
if (!math_op_uses_filter(node.op)) continue;
biquad(node.op, node.fc, node.q, dt, rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i]);
biquad(node.op, node.fc, node.q, dt, out.b0, out.b1, out.b2, out.a1, out.a2);
}
}
double math_step(const MathChannel& channel, MathRuntime& rt,
const std::function<double(const std::string&)>& source) {
double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
std::size_t count) {
const std::size_t n = channel.nodes.size();
if (n == 0 || rt.value.size() != n) return std::nan("");
if (n == 0 || rt.node.size() != n) return kNaN;
MathNodeRt* rts = rt.node.data();
const auto at = [&](int index) -> double {
if (index < 0 || static_cast<std::size_t>(index) >= n) return std::nan("");
return rt.value[static_cast<std::size_t>(index)];
if (index < 0 || static_cast<std::size_t>(index) >= n) return kNaN;
return rts[static_cast<std::size_t>(index)].value;
};
for (std::size_t i = 0; i < n; ++i) {
const MathNode& node = channel.nodes[i];
MathNodeRt& self = rts[i];
double x = 0.0;
switch (node.op) {
case MathOp::Source:
x = source(node.source);
case MathOp::Source: {
const int c = self.src;
x = c >= 0 && static_cast<std::size_t>(c) < count
? static_cast<double>(values[static_cast<std::size_t>(c)])
: kNaN;
break;
}
case MathOp::Gain:
x = at(node.in_a) * node.gain + node.offset;
break;
@@ -174,8 +188,8 @@ double math_step(const MathChannel& channel, MathRuntime& rt,
case MathOp::LowPass:
case MathOp::HighPass:
case MathOp::BandPass:
x = biquad_step(at(node.in_a), rt.b0[i], rt.b1[i], rt.b2[i], rt.a1[i], rt.a2[i], rt.z1[i],
rt.z2[i]);
x = biquad_step(at(node.in_a), self.b0, self.b1, self.b2, self.a1, self.a2, self.z1,
self.z2);
break;
case MathOp::Sum:
x = at(node.in_a) + at(node.in_b);
@@ -194,11 +208,11 @@ double math_step(const MathChannel& channel, MathRuntime& rt,
break;
}
}
rt.value[i] = x;
self.value = x;
}
const int out = channel.output;
if (out < 0 || static_cast<std::size_t>(out) >= n) return std::nan("");
return rt.value[static_cast<std::size_t>(out)];
if (out < 0 || static_cast<std::size_t>(out) >= n) return kNaN;
return rts[static_cast<std::size_t>(out)].value;
}
bool math_channel_valid(const MathChannel& channel, std::string* why) {
+37 -12
View File
@@ -68,25 +68,50 @@ struct MathChannel {
/// The per-channel runtime: the filter memory and the per-node scratch. Kept apart from the
/// `MathChannel` because it is *state* (one sample of history per biquad) while the channel is
/// *configuration* -- so the pane can edit one while the producer streams through the other.
/// One node's runtime: its value this sample, its biquad coefficients and state, and its resolved
/// source. **One struct per node, not one vector per field**: the step touches all of them for the
/// node it is on and none of them for any other, so a node's state belongs on one cache line.
/// Eight parallel `std::vector<double>` put a filter node's eight words on eight separate lines,
/// which at the stored rate is eight misses per node per sample.
struct MathNodeRt {
double value = 0.0; ///< this node's output for the sample being evaluated
/// Biquad coefficients (transposed direct form II) and the two state words.
double b0 = 1.0;
double b1 = 0.0;
double b2 = 0.0;
double a1 = 0.0;
double a2 = 0.0;
double z1 = 0.0;
double z2 = 0.0;
/// The channel index a `Source` node reads, -1 when its name names nothing acquired (and for
/// every other op). Resolved **once**, by `math_prepare()`, rather than per sample: the step runs
/// at the stored rate -- a million times a second on a fast stream -- and reading a source by
/// name there was a linear scan of the channel list with a `std::string` compare per candidate,
/// per source node, per sample, behind a type-erased call. Names cannot change without a
/// rebuild, and every rebuild re-prepares.
int src = -1;
};
struct MathRuntime {
std::vector<double> value; ///< one per node, this sample.
/// Biquad coefficients per node (transposed direct form II), and its two state words.
std::vector<double> b0, b1, b2, a1, a2, z1, z2;
std::vector<MathNodeRt> node;
/// The rate the coefficients were built for; a change rebuilds them.
double dt = 0.0;
bool ready = false;
};
/// Fills `rt`'s size and the biquad coefficients for `dt` seconds per sample. Call it whenever the
/// graph or the stored rate changes -- cheap, and it is what keeps the filters' cutoffs honest
/// across a decimation change, where `dt` is not what it was when the user typed a frequency.
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt);
/// Fills `rt`'s size, the biquad coefficients for `dt` seconds per sample, and the resolved source
/// indices -- `index_of(name)` is the channel a `Source` node names, or -1. Call it whenever the
/// graph, the channel list or the stored rate changes -- cheap, and it is what keeps the filters'
/// cutoffs honest across a decimation change, where `dt` is not what it was when the user typed a
/// frequency.
void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt,
const std::function<int(const std::string&)>& index_of);
/// One sample through the graph. `source(name)` returns that channel's current value, and NAN when
/// it is not acquired -- which propagates to the output, so a math channel whose source is disabled
/// reads as "no data" rather than as whatever the last good sample was.
double math_step(const MathChannel& channel, MathRuntime& rt,
const std::function<double(const std::string&)>& source);
/// One sample through the graph. `values[c]` is channel `c`'s value for this slot; a `Source` node
/// whose channel is not among them reads NAN -- which propagates to the output, so a math channel
/// whose source is disabled reads as "no data" rather than as whatever the last good sample was.
double math_step(const MathChannel& channel, MathRuntime& rt, const float* values,
std::size_t count);
/// Whether the graph is complete enough to produce a value: an output node that exists, and every
/// input it needs connected to a node that exists (with `Source` nodes naming something).
+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;
}
+113 -13
View File
@@ -2,6 +2,8 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
@@ -82,25 +84,123 @@ struct MeasureStats {
/// derived from them, so the window is read once for every type that is not an edge count.
double sum = 0.0;
std::size_t count = 0;
/// The time of the *first* sample to reach each extreme -- "min" is a level and a place, and
/// finding the place is why this is not just the two numbers.
/// The *window index* of the first sample to reach each extreme -- "min" is a level and a
/// place, and finding the place is why this is not just the two numbers. An index rather than
/// a stamp because a stamp costs a ring read and the walk moves an extreme many times, while
/// the caller needs the time exactly twice.
std::size_t lo_i = 0;
std::size_t hi_i = 0;
/// How many samples from `lo_i`/`hi_i` still have to be searched to turn it into a *sample*,
/// or 0 when it already is one. A bucket pass knows only which bucket holds the extreme, so
/// the caller finishes with one raw `measure_scan()` over that bucket alone -- two buckets at
/// most, whatever the window's size.
std::size_t lo_search = 0;
std::size_t hi_search = 0;
/// The time of each extreme. Filled by the caller from `lo_i`/`hi_i` once the walk is done;
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
double at_lo = 0.0;
double at_hi = 0.0;
bool valid = false;
};
/// One pass over a window: both extremes with their first positions, and the sum and count.
MeasureStats measure_stats(const std::vector<double>& time, const std::vector<double>& value);
/// The engine: the visible samples of one signal, in stream seconds and in the signal's own
/// units. Both arrays are the same length and in time order; the function reads nothing else,
/// which is what makes it testable and what keeps the answer independent of the plot.
/// One chunk of a window's values, in time order, exactly as the rings hold them.
///
/// `stats` is `measure_stats(time, value)` when the caller already has it -- several measurements
/// on one signal share one scan -- and null to compute it here. It must describe these arrays.
MeasureResult compute_measure(const std::vector<double>& time, const std::vector<double>& value,
MeasureType type, const MeasureOptions& options,
const MeasureStats* stats = nullptr);
/// The engine is *fed* the window this way rather than handed a materialized copy. A visible
/// window is millions of samples and the rings hold `f32`, so widening the whole thing into two
/// `std::vector<double>` cost a 16-byte-per-sample write and then a read of it for every pass --
/// 60 % of the pane's time before a single statistic had been computed, and 12 ns a sample over a
/// 1.7 M-sample window. Only the *values* are streamed: a stamp is needed at the extremes and at
/// the level crossings, which are a handful in a window of millions, so it is fetched by index.
struct MeasureChunk {
const float* value = nullptr;
std::size_t count = 0;
/// The window offset of `value[0]`, so `time_of()` below can be asked for one absolute sample.
std::size_t offset = 0;
};
/// One whole LOD bucket of a window, exactly as `plot::data::MinMaxPyramid` already holds it.
///
/// The pyramid *is* "evaluate only on the new samples": the producer folds every sample into it
/// as it arrives, at amortised O(1), so a bucket is 64 samples the measurement walks never have
/// to read. A 1.7 M-sample window is 26 k buckets, and only the handful that *straddle* a level
/// are opened.
struct MeasureBucket {
double lo = 0.0;
double hi = 0.0;
double sum = 0.0;
std::size_t count = 0; ///< finite samples folded in; 0 = a bucket with nothing usable
std::size_t offset = 0; ///< window offset of the bucket's first sample
std::size_t span = 0; ///< samples the bucket covers, finite or not
};
/// Pass one, chunk by chunk: both extremes with the *index* of the first sample to reach each,
/// and the sum and count of the finite samples. Feed the chunks in window order into a zeroed
/// `stats` -- the first sample to reach an extreme is the one the mark names, so a later chunk
/// that merely ties it must not move it.
void measure_scan(const MeasureChunk& chunk, MeasureStats& stats);
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats);
/// Everything the threshold-relative types need from a second walk, at one level: the sum and
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
/// first and last *enter* edge of each side, how many there were, and the summed width of the
/// intervals that closed. All of it is O(1) state, so `Top`, `Bottom`, `PulseDuration`, `Period`
/// and `Frequency` are all answered from one pass: adding a *type* must not add a pass, and nor
/// must adding a second measurement of the same type at the same level.
struct MeasureLevel {
double level = 0.0;
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
/// `bottom` has always reported), index 1 the side above it.
double sum[2] = {0.0, 0.0};
std::size_t count[2] = {0, 0};
double first_enter[2] = {0.0, 0.0};
double last_enter[2] = {0.0, 0.0};
std::size_t enters[2] = {0, 0};
double width_sum[2] = {0.0, 0.0};
std::size_t widths[2] = {0, 0};
/// Walk state carried between chunks: the side the signal was last *decided* to be on (a sample
/// equal to the level decides nothing, so the walk holds the previous side), and the interval
/// per side that has been entered but not yet left.
bool have_side = false;
bool side = false;
double open_at[2] = {0.0, 0.0};
bool open[2] = {false, false};
};
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
/// measurements on one signal costs two walks, not two per measurement.
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count);
/// Pass two from one whole bucket, into one level. True when the bucket answered it: a bucket
/// every one of whose samples is on the same side of the level holds no crossing, so its sums
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
///
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
/// the edge's position a guess -- and the caller must feed its raw samples to
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
bool measure_accumulate_bucket(const MeasureBucket& bucket,
const std::function<double(std::size_t)>& time_of,
MeasureLevel& level);
/// The level a type reads, as a share of the window's own min..max, or false when it reads none
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
/// `measure_uses_threshold()`.
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
MeasureResult compute_measure(const MeasureStats& stats, const MeasureLevel* level,
MeasureType type, const MeasureOptions& options);
/// A measurement type's name, as the pane's row and the dialog's dropdown spell it.
const char* measure_type_name(MeasureType type);
+54 -13
View File
@@ -6,6 +6,7 @@
#include <udps_client.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <stdexcept>
@@ -22,6 +23,23 @@ u64 pow2_floor(u64 v) {
return p;
}
std::uint64_t now_ns() {
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count());
}
/// One stored slot in this many is timed: see `ScopeModel::push_math_sample()`.
///
/// A *single* slot, deliberately, even though one slot's work is the same order as one
/// `clock_gettime` -- so the figure carries a clock read's own cost and is an upper bound. Timing a
/// *run* of consecutive slots to amortise that away was tried and is wrong: the producer gets its
/// slots a packet at a time, so a run long enough to be worth timing straddles a packet boundary
/// now and then and charges the measured slots the **wait for the next packet**. At 2000 samples a
/// packet a 64-slot run crosses one about 3% of the time, and a ~1.5 ms gap spread over 64 slots
/// reported 799 ns/slot against the true 78. One slot is always inside one packet.
constexpr std::uint64_t kMathCostSample = 1024;
} // namespace
u64 Partition::total_samples() const noexcept {
@@ -221,29 +239,52 @@ void ScopeModel::prepare_math_locked() {
// starting point for the new one. Both are reset here, together, so the two cannot drift apart.
math_rt_.assign(math_.size(), MathRuntime{});
const double dt = part_.slot_dt();
for (std::size_t i = 0; i < math_.size(); ++i) math_prepare(math_[i], math_rt_[i], dt);
// A `Source` node's channel index, resolved here so the per-sample step never looks a name up.
const auto index_of = [this](const std::string& name) -> int {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name == name) return static_cast<int>(c);
}
return -1;
};
for (std::size_t i = 0; i < math_.size(); ++i) {
math_prepare(math_[i], math_rt_[i], dt, index_of);
}
}
void ScopeModel::push_math_sample(const std::vector<f32>& sample) {
// The common case is no derived channels at all, and this runs once per *stored slot* -- so the
// early-out is what keeps a stream with no math from paying for the feature.
if (math_.empty()) return;
// The cost is *sampled*: a run of `kMathCostRun` consecutive slots every `kMathCostPeriod`. This
// runs up to the wire rate, where a slot's own work is tens of nanoseconds -- so timing every
// slot is itself able to drop packets, and timing one slot at a time charges it a whole clock
// read. A run amortises both: one pair of reads per 64 measured slots, one pair per 65536 slots
// overall, and the figure converges over the millions this counter sees.
const std::uint64_t slot = math_slot_count_++;
math_slots_.store(math_slot_count_, std::memory_order_relaxed);
const bool timed = slot % kMathCostSample == 0;
if (timed) math_cost_t0_ = now_ns();
// Values for this slot, extended as the derived ones are computed: a graph may name another math
// channel, and nodes are evaluated in list order, so by the time one is asked for it is there.
std::vector<f32> values = sample;
const auto value_of = [&](const std::string& name) -> double {
for (std::size_t c = 0; c < channels_.size(); ++c) {
if (channels_[c].name != name) continue;
return c < values.size() ? static_cast<double>(values[c]) : std::nan("");
}
return std::nan("");
};
// `assign()` into a member reuses the capacity -- a local vector here was a heap allocation and
// free per stored slot, i.e. up to a million pairs a second on a fast stream.
math_values_.assign(sample.begin(), sample.end());
if (math_values_.size() < channels_.size()) math_values_.resize(channels_.size(), 0.0f);
for (std::size_t m = 0; m < math_.size(); ++m) {
const int channel = m < math_channel_.size() ? math_channel_[m] : -1;
if (channel < 0 || m >= math_rt_.size()) continue;
const double out = math_step(math_[m], math_rt_[m], value_of);
const double out =
math_step(math_[m], math_rt_[m], math_values_.data(), math_values_.size());
live().values(static_cast<std::size_t>(channel)).push(static_cast<f32>(out));
if (values.size() <= static_cast<std::size_t>(channel)) {
values.resize(static_cast<std::size_t>(channel) + 1, 0.0f);
if (static_cast<std::size_t>(channel) < math_values_.size()) {
math_values_[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
}
values[static_cast<std::size_t>(channel)] = static_cast<f32>(out);
}
if (timed) {
math_cost_count_ += now_ns() - math_cost_t0_;
++math_timed_count_;
math_cost_ns_.store(math_cost_count_, std::memory_order_relaxed);
math_timed_.store(math_timed_count_, std::memory_order_relaxed);
}
}
+33
View File
@@ -204,6 +204,21 @@ class ScopeModel {
/// the streamed channels. `sample[c]` is channel `c`'s value for this slot; the math channels are
/// evaluated from those, in order, so a math channel may use another one.
void push_math_sample(const std::vector<f32>& sample);
/// What the derived channels cost the *producer*: every stored slot that went through
/// `push_math_sample()`, and the nanoseconds taken by the `math_timed()` of them that were
/// measured. The producer owns the budget between two DATA packets, so this cost is invisible in
/// the frame mean and surfaces only as dropped packets -- which is why it is counted rather than
/// inferred. It is *sampled*, so that the instrumentation cannot itself cause what it is looking
/// for: read `math_cost_ns() / math_timed()` as the per-slot figure, scaled by `math_slots()`.
std::uint64_t math_slots() const noexcept {
return math_slots_.load(std::memory_order_relaxed);
}
std::uint64_t math_timed() const noexcept {
return math_timed_.load(std::memory_order_relaxed);
}
std::uint64_t math_cost_ns() const noexcept {
return math_cost_ns_.load(std::memory_order_relaxed);
}
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
@@ -434,6 +449,24 @@ class ScopeModel {
/// before the stream's own are re-appended -- a CONFIG must not leave a stale derived channel
/// behind, and must not drop a live one either.
std::vector<std::string> math_signal_names_;
/// The producer's scratch for one slot's channel values, derived ones appended as they are
/// computed. A member rather than a local because `push_math_sample()` runs once per *stored
/// slot* -- up to the wire rate -- and a local `std::vector` is a heap allocation and free per
/// slot. `assign()` reuses the capacity, so after the first slot it is a memcpy.
std::vector<f32> math_values_;
/// The counters' own copies, written and read by the producer and nobody else, so each publish
/// below is a relaxed **store** of a value already in hand rather than a `fetch_add`. The
/// read-modify-write is a locked instruction and this is the per-slot path: one `lock xadd` costs
/// about what a two-node graph's whole arithmetic does, so counting the slots cost more than the
/// work it was counting.
std::uint64_t math_slot_count_ = 0;
std::uint64_t math_timed_count_ = 0;
std::uint64_t math_cost_count_ = 0;
/// When the slot being timed began.
std::uint64_t math_cost_t0_ = 0;
std::atomic<std::uint64_t> math_slots_{0};
std::atomic<std::uint64_t> math_timed_{0};
std::atomic<std::uint64_t> math_cost_ns_{0};
std::vector<bool> enabled_;
Partition part_;
+865 -49
View File
File diff suppressed because it is too large. Load diff