fixed toolbar order and included math/measuraments files

This commit is contained in:
Martino Ferrari committed 2026-10-03 17:39:29 +02:00
1 parent aa2508e431
commit 714d042809
4 files changed
+677

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "math_channel.hpp"
#include <algorithm>
#include <cmath>
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<double(const std::string&)>& 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<std::size_t>(index) >= n) return std::nan("");
return rt.value[static_cast<std::size_t>(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<std::size_t>(out) >= n) return std::nan("");
return rt.value[static_cast<std::size_t>(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<int>(i)) {
return fail("node " + std::to_string(i + 1) + ": input " + std::to_string(k + 1) +
" is not connected");
}
}
}
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
return fail("no output chosen");
}
return true;
}
} // namespace udpscope
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#include <functional>
#include <string>
#include <vector>
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<MathNode> 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<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;
/// 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<double(const std::string&)>& 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
+228
View File
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "measure.hpp"
#include <algorithm>
#include <cmath>
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];
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<double>& time, const std::vector<double>& 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<double>(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<double>(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<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
// 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);
}
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());
// 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});
}
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});
out.valid = true;
return out;
}
}
return out;
}
} // namespace udpscope
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once
#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 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<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.
///
/// `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);
/// 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