// 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