fixed toolbar order and included math/measuraments files
This commit is contained in:
1 parent
aa2508e431
commit
714d042809
4 files changed
+677
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user