Files
udpscope/src/measure.cpp
T
Martino Ferrari fc4700d1c9 perf: a measurement pane is priced by its window, and its fold is inline
A pane is recomputed over the whole visible window on a cadence, and at a 15 s window
(11.8 M samples, 184 k level-0 level buckets, `--buffer-mb 4000` so the stride stays 1)
that is priced per *measured channel* and per *distinct share*. Measured with the new
`[levels-probe]` sweep, per recompute: one window with one level 6.58 ms, four shares
10.28 ms, sixteen shares 24.55 ms, and four measurements one share each over four
channels **35.69 ms** -- against a tick mean of 1.34 ms with an empty pane and 4.58 ms
with that one. That is the reported "very slow with multiple measurements": the pass
runs inside `tick()`, so it is a *frame* cost, and not an ingest one -- `gaps` and
`packets` stay clean even though each `MeasureWindowReader` holds the model mutex for
its whole life, because a socket buffer absorbs a few tens of milliseconds of producer.

The largest single term inside a pass was a call. `measure_accumulate_bucket()` folds
one bucket into one level, and it is the innermost loop of a walk whose iteration count
is the window's buckets *times* the pane's levels; it lived out of line in
`measure.cpp`, so every one of those iterations was a cross-translation-unit call.
Moving it, and the crossing state it updates, into `measure.hpp` as `inline` takes
sixteen shares on one channel from 24.55 to **17.25 ms** a recompute -- with every value
in the dump's `measurements:` unchanged. Two levers remain and both are measured: the
fetch's three out-of-line pyramid accessors plus a 48-byte copy per bucket (3.3 ms of an
11.8 M-sample window's 11.6 ms pass) needs a combined accessor upstream, and `raw` is
the *signal's* share (4.6 % on the mock's noisy channel against 0.1 % on its sine),
because a level inside a bucket's own `[lo, hi]` has to be walked sample by sample.

Bisected with the pane populated (the earlier bisect had it empty, which cannot see
this): the parent of the recent range and HEAD read **8.29** and **7.92 ms** a
recompute for the same four-type pane at the same window, and neither of the two
commits between them touches `ui_measure.cpp` or `measure.cpp` at all. So the cost is
the scale and not a regression, and the fix makes a pass cheaper rather than removing
one -- the plot's own per-frame cost was checked the same way (four commits, equal
within 3 %, and unchanged by the window, the ring size and the visible sample count).

`UDPSCOPE_PROBE_LEVELS_MS` with `UDPSCOPE_PROBE_LEVELS` and
`UDPSCOPE_PROBE_LEVELS_CHANNELS` is the sweep; `docs/claude/invariants-measurements.md`
records the curve and the inline rule, `verification.md` and `probe-readings.md` the
probe and what its numbers must read, and the README the pane's cost at this scale.
2026-10-16 08:00:54 +02:00

542 lines
24 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "measure.hpp"
#include <algorithm>
#include <cmath>
namespace udpscope {
namespace {
/// One crossing *into* side `e` at time `t`, which is also what closes the interval the walk was
/// in. Shared by the raw walk and the bucket walk so the two cannot disagree about what an edge
/// records -- an interval's width is the distance from the edge that entered it to the edge that
/// left it, which is the pairing the pulse duration averages.
// The hysteresis band and its rationale moved to `MeasureOptions::noise_pct`: it is a per-measurement
// setting now, and a second copy of the number here would be a second thing to keep in step.
constexpr double kPi = 3.14159265358979323846;
/// The flat level's band, as a half-width in shares of the window's peak-to-peak. 5 % is *under*
/// the overshoot a band-limited edge rings to -- Gibbs, about 9 % of the swing at the harmonic
/// counts the mock's `square` uses -- so the ringing's excursion falls outside the densest band
/// while a plateau's own ripple, a few percent, stays inside it. Measured on the mock's 30-harmonic
/// square at 1.3 MS/s: the flat top reads 1.000000 with this band, 1.003573 at 3 %, 1.003495 at
/// 10 % -- the wider bands reach the ringing again. A share and not an absolute width: the band is
/// the same quantity `threshold_pct` and the hysteresis are measured in.
constexpr double kFlatBandShare = 0.05;
/// Which bin of a side is the level: the *outermost* one holding at least this share of the
/// densest bin's buckets. One rule for both kinds of waveform. Where a plateau exists it is by
/// far the densest band and the rule cannot reach past it. Where none exists -- a sine, a ramp --
/// the signal dwells in every band it visits, the whole side qualifies, and the outermost is the
/// honest answer: the level is then the top of what the window reaches, near the peak, rather
/// than a bin of an arbitrary mid-range density that a shifted window would move.
constexpr double kFlatTieShare = 0.5;
} // namespace
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
const double span = s.hi - s.lo;
acc.level = s.lo + pct * span;
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
// fixed band, `enters=4 period=0.200000` with the clamp.
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
const double want = noise_frac > 0.0 ? noise_frac * span : 0.0;
acc.hysteresis = std::max(0.0, std::min(want, 0.5 * room));
}
void measure_filter_set(MeasureFilter& f, double fc, double dt) {
f.reset();
f.a = 1.0;
if (fc > 0.0 && dt > 0.0) {
const double nyquist = 0.5 / dt;
const double use = fc < nyquist * 0.99 ? fc : nyquist * 0.99;
f.a = 1.0 - std::exp(-2.0 * kPi * use * dt);
}
}
void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& filter) {
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
// end. The sum is accumulated per chunk and folded in once, which is also kinder numerically
// than one running total over a few million terms.
double sum = 0.0;
std::size_t count = 0;
double lo = 0.0;
double hi = 0.0;
std::size_t at_lo = 0;
std::size_t at_hi = 0;
bool any = false;
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = filter.step(static_cast<double>(chunk.value[i]));
if (!std::isfinite(v)) continue;
if (!any) {
lo = hi = v;
at_lo = at_hi = i;
any = true;
} else {
if (v < lo) {
lo = v;
at_lo = i;
}
if (v > hi) {
hi = v;
at_hi = i;
}
}
sum += v;
++count;
}
if (!any) return;
s.sum += sum;
s.count += count;
if (!s.valid) {
s.lo = lo;
s.hi = hi;
s.lo_i = chunk.offset + at_lo;
s.hi_i = chunk.offset + at_hi;
s.lo_search = 0;
s.hi_search = 0;
s.valid = true;
return;
}
// Strictly better only: the *first* sample to reach an extreme is the one a reader's eye lands
// on, so a later chunk that merely ties it does not move the mark.
if (lo < s.lo) {
s.lo = lo;
s.lo_i = chunk.offset + at_lo;
s.lo_search = 0;
}
if (hi > s.hi) {
s.hi = hi;
s.hi_i = chunk.offset + at_hi;
s.hi_search = 0;
}
}
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s,
bool flat_hist) {
double sum = 0.0;
std::size_t finite = 0;
// The *buckets'* own range, which is not the window's: the window's partial edge buckets are
// read raw and are not in this list. Only the non-empty buckets: an all-non-finite bucket is
// stored as an empty aggregate and its `lo`/`hi` say nothing.
double blo = 0.0;
double bhi = 0.0;
bool any = false;
for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b];
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
// walk skips exactly those samples -- so it contributes nothing here either.
if (k.count == 0) continue;
if (!any) {
blo = k.lo;
bhi = k.hi;
any = true;
} else {
if (k.lo < blo) blo = k.lo;
if (k.hi > bhi) bhi = k.hi;
}
sum += k.sum;
finite += k.count;
if (!s.valid) {
s.lo = k.lo;
s.hi = k.hi;
s.lo_i = s.hi_i = k.offset;
s.lo_search = s.hi_search = k.span;
s.valid = true;
continue;
}
if (k.lo < s.lo) {
s.lo = k.lo;
s.lo_i = k.offset;
s.lo_search = k.span;
}
if (k.hi > s.hi) {
s.hi = k.hi;
s.hi_i = k.offset;
s.hi_search = k.span;
}
}
s.sum += sum;
s.count += finite;
if (!flat_hist || !any || !(bhi > blo)) return;
// One observation per bucket, at its midpoint, carrying that bucket's *exact* sum and finite
// count -- so the level the caller finally reports is a mean of real samples and not a bin
// centre. Weighting by the bucket (and not by its 64 samples) is what keeps the flat regions
// and the ringing on the same footing: the question is how much of the *waveform* dwells in a
// band, and a bucket is a span of it. A range that is degenerate (every bucket the same value)
// has no band to pick and leaves `folded` at 0, which is the caller's "no histogram".
s.flat.lo = blo;
s.flat.hi = bhi;
const double scale = static_cast<double>(MeasureFlatHist::kBins) / (bhi - blo);
for (std::size_t b = 0; b < count; ++b) {
const MeasureBucket& k = buckets[b];
if (k.count == 0) continue;
const double mid = 0.5 * (k.lo + k.hi);
std::size_t bin = mid <= blo ? 0u : static_cast<std::size_t>((mid - blo) * scale);
if (bin >= MeasureFlatHist::kBins) bin = MeasureFlatHist::kBins - 1;
s.flat.sum[bin] += k.sum;
s.flat.count[bin] += k.count;
++s.flat.buckets[bin];
++s.flat.folded;
}
}
void measure_accumulate(const MeasureChunk& chunk,
const std::function<double(std::size_t)>& time_of, MeasureLevel* levels,
std::size_t level_count, MeasureFilter& filter) {
// Samples outer, levels inner -- the reverse of what reads more naturally, and deliberately. The filter
// holds one state for the whole walk, so a levels-outer loop would step it once per level per sample and
// every level after the first would read a signal filtered `k` times over. Each level's own state is
// independent of the others, so the swap costs nothing else.
for (std::size_t i = 0; i < chunk.count; ++i) {
const double v = filter.step(static_cast<double>(chunk.value[i]));
if (!std::isfinite(v)) continue;
for (std::size_t k = 0; k < level_count; ++k) {
MeasureLevel& acc = levels[k];
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
// below the threshold, and a band here would move a documented exact reading.
const int s = v > acc.level ? 1 : 0;
acc.sum[static_cast<std::size_t>(s)] += v;
++acc.count[static_cast<std::size_t>(s)];
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
// signal that merely wobbles about the level from counting every wobble as an edge. With
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
int d;
if (v > acc.level + acc.hysteresis) {
d = 1;
} else if (v < acc.level - acc.hysteresis) {
d = 0;
} else {
continue;
}
if (acc.have_side && d != (acc.side ? 1 : 0)) {
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
}
acc.side = d != 0;
acc.have_side = true;
}
}
}
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
MeasureFlatLevels out;
const MeasureFlatHist& h = b.flat;
const double span = b.hi - b.lo;
if (h.folded == 0 || !(h.hi > h.lo) || !(span > 0.0)) return out;
// The split, sized exactly as `measure_set_level()` sizes the level the crossing types read, so
// "the top" and "the level a period is counted about" are placed by one rule.
const double level = b.lo + std::clamp(pct, 0.0, 1.0) * span;
const double width = (h.hi - h.lo) / static_cast<double>(MeasureFlatHist::kBins);
const double band = kFlatBandShare * span;
for (int upper = 1; upper >= 0; --upper) {
const auto in_side = [&](std::size_t i) {
const double centre = h.lo + (static_cast<double>(i) + 0.5) * width;
return (centre > level) == (upper != 0);
};
// One scan for the densest bin of this side, one for the outermost of the equally dense
// ones, one to sum the band about it: 192 iterations of an array of 64, per flat level.
std::size_t densest = 0;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
if (in_side(i) && h.buckets[i] > densest) densest = h.buckets[i];
}
// A side of the split with no bucket in it has no level: `top` on a window that never reaches
// above the split reads `--`, which is exactly what it read before.
if (densest == 0) continue;
const double floor_buckets = kFlatTieShare * static_cast<double>(densest);
std::size_t anchor = 0;
bool have_anchor = false;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
if (!in_side(i) || static_cast<double>(h.buckets[i]) < floor_buckets) continue;
// The upper side wants the *last* qualifier and the lower one the first, so a bottom-up scan
// is "always overwrite" for one and "only ever write once" for the other.
if (upper != 0 || !have_anchor) anchor = i;
have_anchor = true;
}
if (!have_anchor) continue;
const double centre = h.lo + (static_cast<double>(anchor) + 0.5) * width;
double sum = 0.0;
std::size_t count = 0;
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
const double offset = h.lo + (static_cast<double>(i) + 0.5) * width - centre;
if (offset < -band || offset > band) continue;
sum += h.sum[i];
count += h.count[i];
}
if (count == 0) continue;
if (upper != 0) {
out.top = sum / static_cast<double>(count);
out.top_ok = true;
} else {
out.bottom = sum / static_cast<double>(count);
out.bottom_ok = true;
}
}
return out;
}
int measure_level_pcts(MeasureType type, const MeasureOptions& options, double pct[2]) {
const auto share = [](double v) { return std::clamp(v, 0.0, 100.0) / 100.0; };
switch (type) {
case MeasureType::Top:
case MeasureType::Bottom:
case MeasureType::Above:
case MeasureType::Below:
pct[0] = share(options.threshold_pct);
return 1;
case MeasureType::Between: {
// The band's two edges, clamped and *ordered*: the shares name a lower and an upper edge, so
// "between 80 % and 20 %" is the band between 20 % and 80 %. Normalizing rather than refusing
// is the honest reading of a band -- the set of samples does not depend on which edge the user
// typed first -- and the two accumulators the pair is answered from are keyed on these ordered
// values, so the same band is one walk however it was written.
const double a = share(options.threshold_pct);
const double b = share(options.threshold_pct2);
pct[0] = std::min(a, b);
pct[1] = std::max(a, b);
return 2;
}
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency:
// The *mid* level, for all three. No option here on purpose: a period is the spacing of
// one crossing per cycle, and a threshold near either extreme counts noise as crossings.
pct[0] = 0.5;
return 1;
default:
return 0;
}
}
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";
case MeasureType::Above: return "above";
case MeasureType::Below: return "below";
case MeasureType::Between: return "between";
}
return "?";
}
int measure_type_count() { return 13; }
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;
case 9: return MeasureType::Period;
// Appended rather than filed beside `Top`/`Bottom`, where they read best in the dropdown:
// `layout_store` writes *this* index into the session file, so renumbering would turn a saved
// `max` into an `above` on the next start -- silently, and only for users who had one.
case 10: return MeasureType::Above;
case 11: return MeasureType::Below;
default: return MeasureType::Between;
}
}
bool measure_uses_threshold(MeasureType type) {
return type == MeasureType::Top || type == MeasureType::Bottom ||
type == MeasureType::PulseDuration || type == MeasureType::Above ||
type == MeasureType::Below || type == MeasureType::Between;
}
bool measure_uses_band(MeasureType type) { return type == MeasureType::Between; }
bool measure_uses_side(MeasureType type) { return type == MeasureType::PulseDuration; }
bool measure_uses_noise(MeasureType type) {
return type == MeasureType::PulseDuration || type == MeasureType::Period ||
type == MeasureType::Frequency;
}
bool measure_can_show_on_plot(MeasureType) { return true; }
MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* const levels[2],
MeasureType type, const MeasureOptions& options) {
MeasureResult out;
if (!b.valid) return out;
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: {
// Straight out of the window's own extremes, which every type already needs: this one *is*
// the extremes, ripple and overshoot included -- that is the difference between it and
// `Amplitude`, which reads the two levels the signal dwells at instead.
out.primary = 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::Amplitude: {
// The delta between the two flat levels, so a ringing edge's overshoot is not part of it.
// The split is the mid level, fixed rather than an option -- the dialog offers no threshold
// for this type -- and the reading is insensitive to it anyway: the band a level is read from
// is a *value* band, so any split between the two plateaus picks the same two.
const MeasureFlatLevels flat = measure_flat_levels(b, 0.5);
if (flat.top_ok && flat.bottom_ok) {
out.primary = flat.top - flat.bottom;
out.marks.push_back({true, flat.top});
out.marks.push_back({true, flat.bottom});
out.valid = true;
return out;
}
// No density estimate to separate a plateau from its ringing: a window shorter than a
// bucket, or an HF-rejected walk, which cannot use the pyramid at all. The delta then
// degenerates to the window's own peak to peak -- the same number when there is nothing to
// exclude, and the only honest one when the ripple cannot be told from the level.
out.primary = b.hi - b.lo;
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: {
// The flat level of one side of the split: the densest band there, whose samples' exact mean
// is the value. The band is a *value* band, so the reading does not follow the window's
// alignment, and it excludes both a plateau's ripple and an edge's overshoot -- which is the
// whole point, because those are exactly what a mean over a side reports instead.
double pct[2] = {0.0, 0.0};
if (measure_level_pcts(type, options, pct) == 0) return out;
const bool want_top = type == MeasureType::Top;
const MeasureFlatLevels flat = measure_flat_levels(b, pct[0]);
if (want_top ? flat.top_ok : flat.bottom_ok) {
out.primary = want_top ? flat.top : flat.bottom;
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
// No histogram: the mean of one side of the level, straight out of the second pass' running
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
// an HF-rejected walk, and on those the split's own means are the honest reading -- and they
// are what `Above`/`Below` report on *every* window.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = want_top ? 1 : 0;
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Above:
case MeasureType::Below: {
// The plain mean of the samples on one side of the share -- what `Top`/`Bottom` reported
// before they became flat levels, and a different question from them: a mean over a side
// carries the ripple and the edge's overshoot (0.986496 on the rippled square where the
// plateau is 1), which is exactly why the flat-level types exist beside these.
const MeasureLevel* lv = levels != nullptr ? levels[0] : nullptr;
const std::size_t s = type == MeasureType::Above ? 1 : 0;
if (lv == nullptr || lv->count[s] == 0) return out;
out.primary = lv->sum[s] / static_cast<double>(lv->count[s]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::Between: {
// The band's interior: what lies above the lower edge and *not* above the upper one. Both
// accumulators split the same samples the same way -- pass two walks one bucket list and one
// filter for the whole window -- so the band is the *difference* of the two upper sides, and
// it is exact rather than approximate. It needs no third walk of its own, and the bucket
// fast path serves it unchanged: a bucket wholly inside the band is strictly above the lower
// level and strictly below the upper, so both accumulators fold it whole.
const MeasureLevel* lower = levels != nullptr ? levels[0] : nullptr;
const MeasureLevel* upper = levels != nullptr ? levels[1] : nullptr;
if (lower == nullptr || upper == nullptr) return out;
// Equal counts mean an empty interior: nothing lies in the band, so there is nothing to
// average -- the pane reads `--`, as it does for any other type with no samples on the side
// it was asked about. `lower` may not count *fewer*, which would say the two accumulators
// saw different samples.
if (lower->count[1] <= upper->count[1]) return out;
out.primary = (lower->sum[1] - upper->sum[1]) /
static_cast<double>(lower->count[1] - upper->count[1]);
out.marks.push_back({true, out.primary});
out.valid = true;
return out;
}
case MeasureType::PulseDuration:
case MeasureType::Period:
case MeasureType::Frequency: {
if (levels == nullptr || levels[0] == nullptr) return out;
const MeasureLevel& level = *levels[0];
// The pulse is one side of the mid level: 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.
const std::size_t s = options.above ? 1 : 0;
const double enter_span = level.last_enter[s] - level.first_enter[s];
const double enter_period = level.enters[s] >= 2
? enter_span / static_cast<double>(level.enters[s] - 1)
: 0.0;
if (type == MeasureType::PulseDuration) {
if (level.widths[s] == 0) return out;
out.primary = level.width_sum[s] / static_cast<double>(level.widths[s]);
// 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, and it needs two of
// them -- a window holding one pulse reports the width alone.
out.secondary = enter_period > 0.0 ? out.primary / enter_period : 0.0;
out.has_secondary = enter_period > 0.0;
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + out.primary});
out.valid = true;
return out;
}
if (!(enter_period > 0.0)) return out;
out.primary = type == MeasureType::Period ? enter_period : 1.0 / enter_period;
out.marks.push_back({false, level.first_enter[s]});
out.marks.push_back({false, level.first_enter[s] + enter_period});
out.valid = true;
return out;
}
}
return out;
}
} // namespace udpscope