Files
udpscope/src/measure.hpp
T
Martino FerrariandClaude Opus 4.6 a92a6b5bf0 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>
2026-10-03 23:36:07 +02:00

222 lines
12 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
namespace udpscope {
/// What a measurement computes over the visible window of one signal.
enum class MeasureType {
Average, ///< the mean of every visible sample
Min, ///< the smallest visible value
Max, ///< the largest visible value
/// The largest swing in the window: `max - min`, the two extremes the eye would measure
/// with a ruler across the waveform.
PeakToPeak,
/// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform
/// about its own mean, which is the figure a datasheet quotes and a signal generator is set
/// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal
/// riding on an offset is a different and less useful number.
Amplitude,
Top, ///< the signal's high level (the mean of the samples above the threshold)
Bottom, ///< the signal's low level (the mean of the samples below it)
PulseDuration, ///< how long the signal spends on one side of the threshold
Period, ///< the time between two successive crossings of the threshold
Frequency, ///< 1 / period
};
/// Type-specific behaviour. Every field is meaningful only for the types named below; the
/// others ignore it (an option that does nothing for a type is left out of the dialog
/// rather than stored and ignored).
struct MeasureOptions {
/// `Top`/`Bottom`/`PulseDuration`/`Period`/`Frequency`: where the split is, as a share of
/// the window's own min..max. The middle by default, which is what a symmetric waveform
/// wants; a 3.3 V logic signal with ringing wants it nearer the top.
double threshold_pct = 50.0;
/// `PulseDuration`: which side of the threshold is "the pulse". `Period`/`Frequency`
/// use it as the edge direction, so one cycle is counted once.
bool above = true;
/// Draw the value on the plot: a ruler at a level (top/bottom), both rulers at the
/// extremes (min/max), the pulse's two edges as vertical markers (pulse duration). The
/// markers are readouts -- they follow the window, and the user cannot drag them.
bool show_on_plot = false;
};
/// One entry in the measurements pane, and everything needed to recompute it: the signal by
/// *name* (a CONFIG renumbers channels, so an index would start measuring another signal)
/// and the type with its options.
struct Measure {
std::string signal;
MeasureType type = MeasureType::Average;
MeasureOptions options;
};
/// One thing to draw on the plot for a measurement: a *level* (a horizontal ruler) or a
/// *time* (a vertical marker). A result carries up to two, and they need not be the same kind
/// -- `min` is a ruler at the value and a marker where the value occurred.
struct MeasureMarkSpec {
bool level = false;
double value = 0.0;
};
/// What a measurement computed. `valid` is what the pane shows as `--` when a type has
/// nothing to measure in the window (a period on a flat signal, a pulse that never crosses).
struct MeasureResult {
double primary = 0.0; ///< the value the pane prints
double secondary = 0.0; ///< pulse duration also reports the duty; unused otherwise
bool has_secondary = false;
std::vector<MeasureMarkSpec> marks; ///< what to draw, empty when the type draws nothing
bool valid = false;
};
/// The window's own extremes, which every threshold-relative type scales from and which
/// several types report directly. Public so a caller holding *several* measurements on one
/// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes
/// that the difference between a frame budget and a stall.
struct MeasureStats {
double lo = 0.0;
double hi = 0.0;
/// The sum and count of the finite samples: the mean, and the mean over a level, are both
/// 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 *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 chunk of a window's values, in time order, exactly as the rings hold them.
///
/// 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);
/// The number of entries the dialog's dropdown holds; the index is `MeasureType`.
int measure_type_count();
MeasureType measure_type_at(int index);
/// Whether a type uses the threshold (see `MeasureOptions`), so the dialog can show the
/// field only then. Deliberately not the case for `Period`/`Frequency`: a period is the
/// spacing of the crossings of the *mid* level, one rising edge per cycle, and offering a
/// threshold there only invites a setting that makes the count wrong.
bool measure_uses_threshold(MeasureType type);
/// Whether a type uses the `above` side (only `PulseDuration`, where it says which half of
/// the waveform is the pulse).
bool measure_uses_side(MeasureType type);
/// Whether a type draws its result on the plot when asked.
bool measure_can_show_on_plot(MeasureType type);
} // namespace udpscope