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:
1 parent
714d042809
commit
a92a6b5bf0
20 files changed
+1945
-243
No files matched your search
+113
-13
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user