diff --git a/src/math_channel.cpp b/src/math_channel.cpp new file mode 100644 index 0000000..11e3c13 --- /dev/null +++ b/src/math_channel.cpp @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "math_channel.hpp" + +#include +#include + +namespace udpscope { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +/// 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 +/// built here as the constant-0-dB-peak form, because a derived trace wants the band itself and +/// not whatever gain its width happened to produce. +void biquad(MathOp op, double fc, double q, double dt, double& b0, double& b1, double& b2, + double& a1, double& a2) { + // Two Nyquist guards and a floor: a cutoff at or above Nyquist has no meaning to give, and one + // at zero would give coefficients of zero -- a trace that is flat because the filter is broken + // rather than because the signal is. + const double nyquist = 0.5 / std::max(dt, 1e-12); + fc = std::clamp(fc, nyquist * 1e-6, nyquist * 0.99); + q = std::clamp(q, 1e-3, 100.0); + const double w0 = 2.0 * kPi * fc * dt; + const double cos_w0 = std::cos(w0); + const double alpha = std::sin(w0) / (2.0 * q); + const double a0 = 1.0 + alpha; + switch (op) { + case MathOp::LowPass: + b0 = (1.0 - cos_w0) * 0.5; + b1 = 1.0 - cos_w0; + b2 = b0; + break; + case MathOp::HighPass: + b0 = (1.0 + cos_w0) * 0.5; + b1 = -(1.0 + cos_w0); + b2 = b0; + break; + default: // BandPass + b0 = alpha; + b1 = 0.0; + b2 = -alpha; + break; + } + a1 = -2.0 * cos_w0; + a2 = 1.0 - alpha; + b0 /= a0; + b1 /= a0; + b2 /= a0; + a1 /= a0; + a2 /= a0; +} + +/// One biquad step, transposed direct form II -- the form whose state words are the two things +/// that actually need carrying between samples. +double biquad_step(double x, double b0, double b1, double b2, double a1, double a2, double& z1, + double& z2) { + const double y = b0 * x + z1; + z1 = b1 * x - a1 * y + z2; + z2 = b2 * x - a2 * y; + return y; +} + +} // namespace + +const char* math_op_name(MathOp op) { + switch (op) { + case MathOp::Source: return "channel"; + case MathOp::Gain: return "gain + offset"; + case MathOp::Abs: return "abs"; + case MathOp::Sqrt: return "sqrt"; + case MathOp::Clamp: return "clamp"; + case MathOp::LowPass: return "low pass"; + case MathOp::HighPass: return "high pass"; + case MathOp::BandPass: return "band pass"; + case MathOp::Sum: return "sum"; + case MathOp::Sub: return "difference"; + case MathOp::Mul: return "product"; + case MathOp::Div: return "ratio"; + } + return "?"; +} + +int math_op_count() { return 12; } + +MathOp math_op_at(int index) { + switch (index) { + case 0: return MathOp::Source; + case 1: return MathOp::Gain; + case 2: return MathOp::Abs; + case 3: return MathOp::Sqrt; + case 4: return MathOp::Clamp; + case 5: return MathOp::LowPass; + case 6: return MathOp::HighPass; + case 7: return MathOp::BandPass; + case 8: return MathOp::Sum; + case 9: return MathOp::Sub; + case 10: return MathOp::Mul; + default: return MathOp::Div; + } +} + +int math_op_arity(MathOp op) { + switch (op) { + case MathOp::Source: return 0; + case MathOp::Sum: + case MathOp::Sub: + case MathOp::Mul: + case MathOp::Div: return 2; + default: return 1; + } +} + +bool math_op_uses_gain(MathOp op) { return op == MathOp::Gain; } +bool math_op_uses_filter(MathOp op) { + return op == MathOp::LowPass || op == MathOp::HighPass || op == MathOp::BandPass; +} +bool math_op_uses_clamp(MathOp op) { return op == MathOp::Clamp; } + +void math_prepare(const MathChannel& channel, MathRuntime& rt, double dt) { + 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); + rt.dt = dt; + rt.ready = true; + for (std::size_t i = 0; i < n; ++i) { + const MathNode& node = channel.nodes[i]; + 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]); + } +} + +double math_step(const MathChannel& channel, MathRuntime& rt, + const std::function& source) { + const std::size_t n = channel.nodes.size(); + if (n == 0 || rt.value.size() != n) return std::nan(""); + const auto at = [&](int index) -> double { + if (index < 0 || static_cast(index) >= n) return std::nan(""); + return rt.value[static_cast(index)]; + }; + for (std::size_t i = 0; i < n; ++i) { + const MathNode& node = channel.nodes[i]; + double x = 0.0; + switch (node.op) { + case MathOp::Source: + x = source(node.source); + break; + case MathOp::Gain: + x = at(node.in_a) * node.gain + node.offset; + break; + case MathOp::Abs: + x = std::fabs(at(node.in_a)); + break; + case MathOp::Sqrt: + // Of the magnitude: the square root of a negative sample has no real answer, and a derived + // trace that turns into NaN for half a cycle is not a useful one. + x = std::sqrt(std::fabs(at(node.in_a))); + break; + case MathOp::Clamp: + x = std::clamp(at(node.in_a), std::min(node.lo, node.hi), std::max(node.lo, node.hi)); + break; + 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]); + break; + case MathOp::Sum: + x = at(node.in_a) + at(node.in_b); + break; + case MathOp::Sub: + x = at(node.in_a) - at(node.in_b); + break; + case MathOp::Mul: + x = at(node.in_a) * at(node.in_b); + break; + case MathOp::Div: { + const double d = at(node.in_b); + // A scope trace must not go to infinity or NaN: a division by zero reads as zero, which is + // visibly wrong on the plot instead of invisibly wrong in the number. + x = std::fabs(d) > 0.0 ? at(node.in_a) / d : 0.0; + break; + } + } + rt.value[i] = x; + } + const int out = channel.output; + if (out < 0 || static_cast(out) >= n) return std::nan(""); + return rt.value[static_cast(out)]; +} + +bool math_channel_valid(const MathChannel& channel, std::string* why) { + const std::size_t n = channel.nodes.size(); + const auto fail = [&](const std::string& text) { + if (why != nullptr) *why = text; + return false; + }; + if (n == 0) return fail("empty graph"); + for (std::size_t i = 0; i < n; ++i) { + const MathNode& node = channel.nodes[i]; + if (node.op == MathOp::Source && node.source.empty()) { + return fail("node " + std::to_string(i + 1) + ": no channel chosen"); + } + const int arity = math_op_arity(node.op); + const int ins[2] = {node.in_a, node.in_b}; + for (int k = 0; k < arity; ++k) { + // Only nodes *before* this one: the evaluator walks the list in order, so a forward or + // self reference is not a cycle to detect, it is a node reading a value that is not there. + if (ins[k] < 0 || ins[k] >= static_cast(i)) { + return fail("node " + std::to_string(i + 1) + ": input " + std::to_string(k + 1) + + " is not connected"); + } + } + } + if (channel.output < 0 || static_cast(channel.output) >= n) { + return fail("no output chosen"); + } + return true; +} + +} // namespace udpscope diff --git a/src/math_channel.hpp b/src/math_channel.hpp new file mode 100644 index 0000000..f5d2e0d --- /dev/null +++ b/src/math_channel.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari +#pragma once + +#include +#include +#include + +namespace udpscope { + +/// A math channel's operations. `Source` reads another channel by name; `Sum`/`Sub`/`Mul`/`Div` +/// combine two node values; the rest take one. `Sqrt` and `Clamp` are here because a scope's +/// derived traces want them constantly (an envelope, a rectified-and-squared RMS input, a +/// bounded control signal) and they cost the same to carry as the ones that were asked for. +enum class MathOp { + Source, + Gain, ///< y = x * gain + offset + Abs, + Sqrt, + Clamp, ///< y = min(max(x, lo), hi) + LowPass, ///< biquad, corner `fc` Hz + HighPass, + BandPass, ///< `fc` Hz centre, `q` wide + Sum, ///< a + b + Sub, ///< a - b + Mul, ///< a * b + Div, ///< a / b, zero when b is 0 (a scope trace must not go to inf/NAN) +}; + +const char* math_op_name(MathOp op); +int math_op_count(); +MathOp math_op_at(int index); +/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic. +int math_op_arity(MathOp op); +/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows -- +/// an option that does nothing for an op is left out rather than stored and ignored. +bool math_op_uses_gain(MathOp op); +bool math_op_uses_filter(MathOp op); +bool math_op_uses_clamp(MathOp op); + +/// One node of a math channel's graph. Node indices are positions in `MathChannel::nodes`, and a +/// node may only read ones before it: the evaluator walks the list in order, which is what makes a +/// cycle impossible by construction rather than by a check. +struct MathNode { + MathOp op = MathOp::Source; + std::string source; ///< `Source`: the channel to read, by name. + int in_a = -1; ///< other ops: node indices, -1 while unconnected. + int in_b = -1; + double gain = 1.0; + double offset = 0.0; + double fc = 100.0; ///< LP/HP corner, BP centre, Hz. + double q = 0.707; ///< BP quality factor. + double lo = -1.0; ///< `Clamp` bounds. + double hi = 1.0; + double x = 0.0; ///< the editor's box position, logical pixels. + double y = 0.0; +}; + +/// A derived channel: a graph of nodes and which node is the answer. +struct MathChannel { + std::string name; ///< user-facing; also how another graph names it as a `Source`. + std::string unit; ///< optional. When empty, the first `Source` node's unit is used. + bool enabled = false; + std::vector nodes; + int output = -1; ///< the node whose value is the channel's. +}; + +/// 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. +struct MathRuntime { + std::vector value; ///< one per node, this sample. + /// Biquad coefficients per node (transposed direct form II), and its two state words. + std::vector b0, b1, b2, a1, a2, z1, z2; + /// 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); + +/// 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& source); + +/// 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). +bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr); + +} // namespace udpscope diff --git a/src/measure.cpp b/src/measure.cpp new file mode 100644 index 0000000..ee78119 --- /dev/null +++ b/src/measure.cpp @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +#include "measure.hpp" + +#include +#include + +namespace udpscope { + +MeasureStats measure_stats(const std::vector& time, const std::vector& 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]; + if (!std::isfinite(v)) continue; + if (!s.valid) { + s.lo = s.hi = v; + s.at_lo = s.at_hi = time[i]; + s.valid = true; + } else { + if (v < s.lo) { + s.lo = v; + s.at_lo = time[i]; + } + if (v > s.hi) { + s.hi = v; + s.at_hi = time[i]; + } + } + s.sum += v; + ++s.count; + } + return s; +} + + +const char* measure_type_name(MeasureType type) { + switch (type) { + case MeasureType::Average: return "average"; + case MeasureType::Min: return "min"; + case MeasureType::Max: return "max"; + case MeasureType::PeakToPeak: return "peak to peak"; + case MeasureType::Amplitude: return "amplitude"; + case MeasureType::Top: return "top"; + case MeasureType::Bottom: return "bottom"; + case MeasureType::PulseDuration: return "pulse duration"; + case MeasureType::Period: return "period"; + case MeasureType::Frequency: return "frequency"; + } + return "?"; +} + +int measure_type_count() { return 10; } + +MeasureType measure_type_at(int index) { + switch (index) { + case 0: return MeasureType::Average; + case 1: return MeasureType::Top; + case 2: return MeasureType::Bottom; + case 3: return MeasureType::Max; + case 4: return MeasureType::Min; + case 5: return MeasureType::PeakToPeak; + case 6: return MeasureType::Amplitude; + case 7: return MeasureType::PulseDuration; + case 8: return MeasureType::Frequency; + default: return MeasureType::Period; + } +} + +bool measure_uses_threshold(MeasureType type) { + return type == MeasureType::Top || type == MeasureType::Bottom || + type == MeasureType::PulseDuration; +} + +bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; } + +bool measure_can_show_on_plot(MeasureType) { return true; } + +MeasureResult compute_measure(const std::vector& time, const std::vector& value, + MeasureType type, const MeasureOptions& options, + const MeasureStats* stats) { + 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; + out.primary = b.sum / static_cast(b.count); + // A mean is a level like any other, so it can be shown as one ruler. + out.marks.push_back({true, out.primary}); + out.valid = true; + return out; + } + case MeasureType::Min: + case MeasureType::Max: { + // A ruler at the extreme, and a marker at the time it happened: "the minimum" is a level + // and a place. The place comes from the shared scan, which takes the *first* sample to + // reach the extreme -- the one a reader's eye lands on when the extreme is a plateau. + const bool want_max = type == MeasureType::Max; + out.primary = want_max ? b.hi : b.lo; + out.marks.push_back({true, out.primary}); + out.marks.push_back({false, want_max ? b.at_hi : b.at_lo}); + out.valid = true; + return out; + } + case MeasureType::PeakToPeak: + case MeasureType::Amplitude: { + // Both come straight out of the window's own extremes, which every type already needs: + // the two rulers are the same pair, and the numbers differ by a factor of two. + out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo); + // A ruler at each extreme: the *span* is the measurement, and two levels are how a + // plot states a span without a band between them. + out.marks.push_back({true, b.lo}); + out.marks.push_back({true, b.hi}); + out.valid = true; + return out; + } + 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(used); + out.marks.push_back({true, out.primary}); + out.valid = true; + return out; + } + 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 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 + // per cycle, whatever else the waveform does in between. + std::vector starts; + std::vector 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); + } + 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(widths.size()); + // 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(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}); + } + out.valid = true; + return out; + } + if (starts.size() < 2) return out; + const double span = starts.back() - starts.front(); + const double period = span / static_cast(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}); + out.valid = true; + return out; + } + } + return out; +} + + +} // namespace udpscope diff --git a/src/measure.hpp b/src/measure.hpp new file mode 100644 index 0000000..fd5ba7c --- /dev/null +++ b/src/measure.hpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari +#pragma once + +#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 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. + 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& time, const std::vector& 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. +/// +/// `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& time, const std::vector& value, + MeasureType type, const MeasureOptions& options, + const MeasureStats* stats = nullptr); + +/// 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