// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #pragma once #include #include #include #include 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 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` 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& 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& 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