2 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 ec93a9831d fix: store the stream clock as f64, so a stamp's precision does not decay with uptime
The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.

A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.

Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 10:41:57 +02:00
Martino FerrariandClaude Opus 4.6 eb4372242c fix: time a FIRST_SAMPLE stream by its cadence, not by a second stamp
A UDPS_TIME_FIRST_SAMPLE time signal carries one meaningful value -- the
batch's first sample -- but a scalar signal in Accumulate mode still occupies
num_samples slots on the wire, which a producer fills by repeating that
anchor. Deriving the step from value_of(n - 1) therefore came out as zero, so
every sample of a frame landed on one x, the degeneracy guard tripped, and the
whole stream was timed by packet arrival instead. On a 1 MS/s source that
arrival clock is not monotonic within a frame: the export's time column held
90559 order breaks.

Space the batch by the cadence, exactly as the LAST_SAMPLE branch beside it
already dates its own batch backwards. The export's time column is now
strictly increasing (0 duplicates, 0 backwards steps) and reads the declared
1 MS/s.

mock_udpstreamer gains --time-mode {full,first}; it could only emit
FULL_ARRAY, so nothing exercised this branch. In 'first' it repeats the anchor
across the batch's wire slots -- the shape that produced the defect -- while
still generating the waveforms from the real per-sample stamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-05 10:24:05 +02:00
11 changed files with 195 additions and 96 deletions

No files matched your search

+28
View File
@@ -553,6 +553,11 @@ Two lines of the self-check output are assertions in disguise:
README) and a per-sample step derived from the client's `lost` counter (now the README) and a per-sample step derived from the client's `lost` counter (now the
stream's measured arrival cadence). A `rows=` count far below the span times the rate stream's measured arrival cadence). A `rows=` count far below the span times the rate
is the other signature, that of a table cut from the LOD pyramid. is the other signature, that of a table cut from the LOD pyramid.
**`set(np.diff(t))` must hold exactly one value**, and this is the check that needs a long
run at a high rate: a *plateau* of equal stamps, or a diff taking two or three values a few
hundred nanoseconds apart, is the time ring losing precision with uptime (see the `f64`
time-ring invariant below — the figure to compute is the ULP of `t[0]`, since the defect
grows with how long the app has run, not with the export's own length).
- `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run - `badge frames: lit=L dim=D idle=I` — the activity badge's phase per frame. Over a run
that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
blinks" means, and a badge stuck lit is indistinguishable from a working one in a blinks" means, and a badge stuck lit is indistinguishable from a working one in a
@@ -898,6 +903,29 @@ far the largest file.
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
0 and a window index *is* the absolute one. 0 and a window index *is* the absolute one.
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
A value's precision is a property of the signal (24 bits is plenty for a sample); a
timestamp's is a property of *how long the scope has been running*, because the stamps are
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
type with **no rounding step**, the old `f32` round-trip having existed only so that a
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
`EventSnapshot::stamps` is `f64` because a **pause** view is deliberately *not* rebased and
therefore carries full stream time (an event's own stamps are edge-relative and small — the
symmetry is not the reason). `RingBuffer<T>` stores slots as `std::atomic<T>` behind a
`static_assert(is_always_lock_free)`, which `double` satisfies on every supported target.
The guard is the **export**: `np.diff(t)` over a multi-million-row `.npy` must hold exactly
one distinct step. Three steps at 2, 2.5 and 3 ULP of the stamps' magnitude is this defect.
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins - **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
the published window to the value ring's base and both rings keep sliding afterwards — the published window to the value ring's base and both rings keep sliding afterwards —
their capacities are equal, so there is no slack — which means the oldest few samples their capacities are equal, so there is no slack — which means the oldest few samples
+43 -6
View File
@@ -343,7 +343,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
"A window longer than the buffer is resampled"). Because the split is by "A window longer than the buffer is resampled"). Because the split is by
*time span* rather than by bytes, every channel and the time base retain the same *time span* rather than by bytes, every channel and the time base retain the same
history -- which is what makes one shared time slice meaningful. With no history -- which is what makes one shared time slice meaningful. With no
decimation the two rules coincide and every slice is the same size. decimation the two rules coincide and every slice holds the same number of samples
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
The ring is always filling, whatever the trigger is doing. The pre-trigger history The ring is always filling, whatever the trigger is doing. The pre-trigger history
has to already be there when an edge arrives; while acquisition followed the has to already be there when an edge arrives; while acquisition followed the
@@ -456,11 +458,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
The axis is still pinned to what the snapshot actually captured, read off its own The axis is still pinned to what the snapshot actually captured, read off its own
stamps rather than off the request: the two can differ by a sample at each end, and stamps rather than off the request: the two can differ by a sample at each end, and
pinning the request would claim coverage the data does not have. The window's bounds pinning the request would claim coverage the data does not have. The cut's bounds are
are rounded to the time ring's own `f32` before being compared against it, so a sample compared in the ring's own type, which is now the `f64` they arrive in — see "The time
sitting exactly on a bound is kept whatever the stream's absolute time happens to be — ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and before comparing, or a sample sitting exactly on a bound fell to how its stamp
a third of the capture at 1 S/s. rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
rule is gone with its cause.
**Where the capture is drawn was a separate bug from what it contained**, and it **Where the capture is drawn was a separate bug from what it contained**, and it
outlived the fixes above. The plotting library's `gather()` emits x *relative to* outlived the fixes above. The plotting library's `gather()` emits x *relative to*
@@ -791,6 +794,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
speculatively. speculatively.
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
number being stored grew for as long as the app ran while the interval it had to resolve
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
without bound. That is the "zoom in to about 10 µs and there are three to five points with
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
plateau's edge rather than as the waveform.
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
fixed it; the type was. **A value's precision is a property of the signal and a
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
the rings are the memory budget, and the values are the overwhelming majority of them. The
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
slots, because a time slot and a value sample are now different sizes — it weighs the
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
stream time, which quantised at exactly the rate the live ring did.
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
assertion is a compile-time guard rather than a silent fallback.
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets **A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
laggy with large data" was. The renderer picks a level per trace per frame with laggy with large data" was. The renderer picks a level per trace per frame with
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid `MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
+10 -3
View File
@@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
used_stream_time = true; used_stream_time = true;
break; break;
case UDPS_TIME_FIRST_SAMPLE: { case UDPS_TIME_FIRST_SAMPLE: {
// Only element 0 is meaningful here: the producer stamps the batch's first
// sample and says nothing about the rest, so the cadence has to space them --
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
// producer fills them by repeating the anchor, so the step came out as zero
// and every sample of the frame landed on one x. The degeneracy guard below
// then threw the whole stream onto the arrival clock.
const double anchor = value_of(0); const double anchor = value_of(0);
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period; for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
used_stream_time = true; used_stream_time = true;
break; break;
} }
@@ -511,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
time_acc_ += rel; time_acc_ += rel;
++time_acc_n_; ++time_acc_n_;
if (tick % g == g - 1) { if (tick % g == g - 1) {
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_))); store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
time_acc_ = 0.0; time_acc_ = 0.0;
time_acc_n_ = 0; time_acc_n_ = 0;
} }
+8 -8
View File
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
// --- ChannelBuffer ---------------------------------------------------------- // --- ChannelBuffer ----------------------------------------------------------
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values, ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
f64 epoch, f64 nominal_dt) f64 epoch, f64 nominal_dt)
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride), : values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
epoch_(epoch), dt_(nominal_dt) {} epoch_(epoch), dt_(nominal_dt) {}
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
void ChannelBuffer::invalidate() noexcept { void ChannelBuffer::invalidate() noexcept {
published_ = 0; published_ = 0;
window_x0_ = 0.0f; window_x0_ = 0.0;
pyramid_.clear(); pyramid_.clear();
} }
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept { f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// Channel sample `j` is produced on the tick that also pushes time slot // Channel sample `j` is produced on the tick that also pushes time slot
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the // `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
// time ring every `time_stride` ticks and the channel every `stride` of those, // time ring every `time_stride` ticks and the channel every `stride` of those,
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
// whole trace's geometry collapsing. // whole trace's geometry collapsing.
const u64 base = times_->base(); const u64 base = times_->base();
const u64 produced = times_->produced(); const u64 produced = times_->produced();
if (produced == 0) return 0.0f; if (produced == 0) return 0.0;
u64 slot = index < base ? base : index; u64 slot = index < base ? base : index;
if (slot >= produced) slot = produced - 1; if (slot >= produced) slot = produced - 1;
f32 out = 0.0f; f64 out = 0.0;
times_->copy_from(slot, 1, &out); times_->copy_from(slot, 1, &out);
return out; return out;
} }
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
f64 ChannelBuffer::x0() const { f64 ChannelBuffer::x0() const {
if (pyramid_.size() == 0) return epoch_; if (pyramid_.size() == 0) return epoch_;
return epoch_ + f64(window_x0_); return epoch_ + window_x0_;
} }
f64 ChannelBuffer::x_at(u64 i) const { f64 ChannelBuffer::x_at(u64 i) const {
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
// ring-span too new made it refuse the whole visible range -- which left the renderer // ring-span too new made it refuse the whole visible range -- which left the renderer
// gathering the entire ring at a coarse level and drawing it off the axis. // gathering the entire ring at a coarse level and drawing it off the axis.
if (i == 0) return x0(); if (i == 0) return x0();
return epoch_ + f64(time_of(absolute(i))); return epoch_ + time_of(absolute(i));
} }
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const { bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
plot::Range ChannelBuffer::x_bounds() const { plot::Range ChannelBuffer::x_bounds() const {
const u64 n = pyramid_.size(); const u64 n = pyramid_.size();
if (n == 0) return plot::Range{epoch_, epoch_}; if (n == 0) return plot::Range{epoch_, epoch_};
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))}; return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
} }
plot::Range ChannelBuffer::y_bounds() const { plot::Range ChannelBuffer::y_bounds() const {
+6 -4
View File
@@ -34,8 +34,10 @@ using plot::u64;
/// undecimated case and the common one. /// undecimated case and the common one.
class ChannelBuffer final : public plot::data::Series { class ChannelBuffer final : public plot::data::Series {
public: public:
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
/// alone needs the wider type.
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values, ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch, std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
f64 nominal_dt); f64 nominal_dt);
ChannelBuffer(const ChannelBuffer&) = delete; ChannelBuffer(const ChannelBuffer&) = delete;
@@ -94,10 +96,10 @@ class ChannelBuffer final : public plot::data::Series {
/// Absolute (channel) index of retained-window index `i`. /// Absolute (channel) index of retained-window index `i`.
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; } u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`. /// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
f32 time_of(u64 absolute_index) const noexcept; f64 time_of(u64 absolute_index) const noexcept;
std::shared_ptr<const plot::data::RingBuffer<f32>> values_; std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
std::shared_ptr<const plot::data::RingBuffer<f32>> times_; std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
u64 stride_ = 1; u64 stride_ = 1;
f64 epoch_ = 0.0; f64 epoch_ = 0.0;
f64 dt_ = 1.0; f64 dt_ = 1.0;
@@ -114,7 +116,7 @@ class ChannelBuffer final : public plot::data::Series {
/// the "live plot flickers as if there were no data" defect. The cached value is also the /// the "live plot flickers as if there were no data" defect. The cached value is also the
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base` /// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
/// actually carries, where the clamp answered with whatever slot happened to be oldest now. /// actually carries, where the clamp answered with whatever slot happened to be oldest now.
f32 window_x0_ = 0.0f; f64 window_x0_ = 0.0;
plot::data::MinMaxPyramid pyramid_; plot::data::MinMaxPyramid pyramid_;
}; };
+7 -1
View File
@@ -26,7 +26,13 @@ using plot::u64;
/// live ring buffers switched by a pending flag, plus a display-epoch shift) /// live ring buffers switched by a pending flag, plus a display-epoch shift)
/// could not stop. /// could not stop.
struct EventSnapshot { struct EventSnapshot {
std::vector<f32> stamps; ///< edge-relative seconds, monotonic /// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
std::vector<f64> stamps;
std::vector<std::vector<f32>> values; ///< one array per channel, same length std::vector<std::vector<f32>> values; ///< one array per channel, same length
double pre_s = 0.0; ///< seconds of pre-trigger data kept double pre_s = 0.0; ///< seconds of pre-trigger data kept
double post_s = 0.0; ///< seconds of post-trigger data kept double post_s = 0.0; ///< seconds of post-trigger data kept
+6 -6
View File
@@ -109,9 +109,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (snap != nullptr) { if (snap != nullptr) {
// The snapshot is immutable and already index-aligned, so the visible window is two // The snapshot is immutable and already index-aligned, so the visible window is two
// binary searches over its stamps and the same index range for every channel. // binary searches over its stamps and the same index range for every channel.
const std::vector<f32>& st = snap->stamps; const std::vector<f64>& st = snap->stamps;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min)); const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max)); const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
if (hi <= lo) return table; if (hi <= lo) return table;
const std::size_t first = static_cast<std::size_t>(lo - st.begin()); const std::size_t first = static_cast<std::size_t>(lo - st.begin());
const std::size_t n = static_cast<std::size_t>(hi - lo); const std::size_t n = static_cast<std::size_t>(hi - lo);
@@ -122,7 +122,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
table.time.resize(n); table.time.resize(n);
table.stream_time.resize(n); table.stream_time.resize(n);
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
table.time[i] = static_cast<f64>(st[first + i]); table.time[i] = st[first + i];
table.stream_time[i] = edge + table.time[i]; table.stream_time[i] = edge + table.time[i];
} }
for (const int c : chans) { for (const int c : chans) {
@@ -151,12 +151,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
if (n == 0) return table; if (n == 0) return table;
BufferStore& store = model.live(); BufferStore& store = model.live();
std::vector<f32> stamps(static_cast<std::size_t>(n)); std::vector<f64> stamps(static_cast<std::size_t>(n));
store.time().copy_from(first, n, stamps.data()); store.time().copy_from(first, n, stamps.data());
table.time.resize(static_cast<std::size_t>(n)); table.time.resize(static_cast<std::size_t>(n));
table.stream_time.resize(static_cast<std::size_t>(n)); table.stream_time.resize(static_cast<std::size_t>(n));
for (std::size_t i = 0; i < stamps.size(); ++i) { for (std::size_t i = 0; i < stamps.size(); ++i) {
table.stream_time[i] = static_cast<f64>(stamps[i]); table.stream_time[i] = stamps[i];
table.time[i] = epoch + table.stream_time[i]; table.time[i] = epoch + table.stream_time[i];
} }
for (const int c : chans) { for (const int c : chans) {
+27 -29
View File
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
} // namespace } // namespace
u64 Partition::total_samples() const noexcept {
u64 total = time_capacity;
for (u64 c : value_capacity) total += c;
return total;
}
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes, Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
double target_span_s) { double target_span_s) {
Partition p; Partition p;
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1; for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes)); const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
const u64 budget = budget_bytes / sizeof(f32); // Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
double weight = 1.0; // the time slice itself // (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s); // slices are not interchangeable. Counting both as one f32 is what would make
// `--buffer-mb` quietly understate the time ring by its own size.
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
const u64 slots = budget / static_cast<u64>(std::llround(weight)); const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128)); u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
if (time_capacity == 0) { if (time_capacity == 0) {
throw std::runtime_error("buffer too small for one time-base sample per channel"); throw std::runtime_error("buffer too small for one time-base sample per channel");
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
return std::max<u64>(pow2_floor(ticks / stride), 1); return std::max<u64>(pow2_floor(ticks / stride), 1);
}; };
const auto bytes_for = [&](u64 ticks) { const auto bytes_for = [&](u64 ticks) {
u64 total = ticks; u64 total = ticks * sizeof(f64);
for (u64 s : p.stride) total += capacity_for(ticks, s); for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
return total * sizeof(f32); return total;
}; };
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) { // `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
while (bytes_for(time_capacity * 2) <= budget_bytes) {
time_capacity *= 2; time_capacity *= 2;
} }
p.time_capacity = time_capacity; p.time_capacity = time_capacity;
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
BufferStore::BufferStore(const Partition& part, f64 span_s) BufferStore::BufferStore(const Partition& part, f64 span_s)
: part_(part), : part_(part),
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) { time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
(void)span_s; (void)span_s;
const std::size_t n = part.value_capacity.size(); const std::size_t n = part.value_capacity.size();
values_.reserve(n); values_.reserve(n);
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) { void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
BufferStore& src = live(); BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time(); plot::data::RingBuffer<f64>& tsrc = src.time();
// The slice is one absolute index range, found in the time ring and then shared by // The slice is one absolute index range, found in the time ring and then shared by
// every value ring. Sharing it is what makes a cut's stamps and each of its channels // every value ring. Sharing it is what makes a cut's stamps and each of its channels
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
// truncations that could disagree. // truncations that could disagree.
const u64 tbase = tsrc.produced() - tsrc.size(); const u64 tbase = tsrc.produced() - tsrc.size();
const u64 tend = tsrc.produced(); const u64 tend = tsrc.produced();
// The bounds are rounded to the ring's own `f32` before they are compared against // The bounds are compared in the ring's own type, which is the `f64` they arrive in,
// it. The window arrives in f64 and the stamps are stored as f32, so an f64 // so a bound that lands exactly on a sample includes it. This used to need rounding
// comparison decides a sample that sits exactly on a bound by which way its stamp // *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
// happened to round -- the same nominal window then keeps or drops its first sample // a sample by which way its stamp happened to round, and the same nominal window kept
// depending on how large the stream time is (`f32(38.001) < 38.001` but // or dropped its first sample depending on the magnitude of the stream time
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the // (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a // question does not arise.
// sample include it, always. const f64 t_lo = lo_s;
const f32 t_lo = static_cast<f32>(lo_s); const f64 t_hi = hi_s;
const f32 t_hi = static_cast<f32>(hi_s);
// Stream time is non-decreasing, so the bounds are a binary search. Searching the // Stream time is non-decreasing, so the bounds are a binary search. Searching the
// stamps is the whole point: the window is in seconds and this ring is the only // stamps is the whole point: the window is in seconds and this ring is the only
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
last = a; last = a;
for (std::size_t c = 0; c < channels_.size(); ++c) { for (std::size_t c = 0; c < channels_.size(); ++c) {
plot::data::RingBuffer<f32>& from = src.values(c); plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
first = std::max(first, from.produced() - from.size()); first = std::max(first, from.produced() - from.size());
last = std::min(last, from.produced()); last = std::min(last, from.produced());
} }
@@ -554,7 +552,7 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
void ScopeModel::publish_event(double pre_s, double post_s, double edge) { void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
auto snap = std::make_shared<EventSnapshot>(); auto snap = std::make_shared<EventSnapshot>();
BufferStore& src = live(); BufferStore& src = live();
plot::data::RingBuffer<f32>& tsrc = src.time(); plot::data::RingBuffer<f64>& tsrc = src.time();
u64 first = 0, last = 0; u64 first = 0, last = 0;
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last); live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
@@ -566,7 +564,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
// Edge-relative from here on: the frozen event reads [-pre, +post] with the // Edge-relative from here on: the frozen event reads [-pre, +post] with the
// edge at exactly 0, with no axis-level shift any other code path could // edge at exactly 0, with no axis-level shift any other code path could
// disagree with. // disagree with.
for (auto& t : snap->stamps) t -= static_cast<f32>(edge); for (auto& t : snap->stamps) t -= edge;
} }
snap->values.resize(channels_.size()); snap->values.resize(channels_.size());
+12 -7
View File
@@ -93,8 +93,6 @@ struct Partition {
double base_dt = 1.0; ///< seconds per base tick double base_dt = 1.0; ///< seconds per base tick
double span_s = 0.0; ///< stream seconds one buffer covers double span_s = 0.0; ///< stream seconds one buffer covers
u64 total_samples() const noexcept;
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
/// Seconds between two time-base slots (the sample interval of every channel). /// Seconds between two time-base slots (the sample interval of every channel).
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); } double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
/// The rate samples are actually stored at, in samples per second. /// The rate samples are actually stored at, in samples per second.
@@ -125,8 +123,15 @@ class BufferStore {
/// axis read seconds from the trigger event. /// axis read seconds from the trigger event.
void set_epoch(f64 epoch) noexcept; void set_epoch(f64 epoch) noexcept;
plot::data::RingBuffer<f32>& time() noexcept { return *time_; } /// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; } /// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
/// magnitude grows without bound over a run's life while the interval that has to be
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; } plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; } ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
/// Shared handle to a channel view: the caller keeps the storage alive even if /// Shared handle to a channel view: the caller keeps the storage alive even if
@@ -141,7 +146,7 @@ class BufferStore {
private: private:
Partition part_; Partition part_;
std::shared_ptr<plot::data::RingBuffer<f32>> time_; std::shared_ptr<plot::data::RingBuffer<f64>> time_;
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_; std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
std::vector<std::shared_ptr<ChannelBuffer>> channels_; std::vector<std::shared_ptr<ChannelBuffer>> channels_;
}; };
@@ -278,8 +283,8 @@ class ScopeModel {
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds, /// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
/// clamped to what the time ring and *every* value ring still hold. One range for the /// clamped to what the time ring and *every* value ring still hold. One range for the
/// time ring and all the channels, which is what keeps a cut index-aligned by /// time ring and all the channels, which is what keeps a cut index-aligned by
/// construction; the bounds are compared in the ring's own `f32`, so a bound that /// construction; the bounds are compared in the ring's own type, which is now the
/// lands on a sample includes it whatever the magnitude of the stream time. /// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
/// ///
/// Caller holds `mutex()` (the acquisition thread already does when it latches). /// Caller holds `mutex()` (the acquisition thread already does when it latches).
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last); void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
+21 -19
View File
@@ -2030,18 +2030,18 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) { if (std::shared_ptr<const EventSnapshot> snap = app.model->display_snapshot()) {
if (snap == nullptr || channel >= snap->values.size()) return false; if (snap == nullptr || channel >= snap->values.size()) return false;
const std::vector<f32>& st = snap->stamps; const std::vector<f64>& st = snap->stamps;
const std::vector<f32>& vals = snap->values[channel]; const std::vector<f32>& vals = snap->values[channel];
if (vals.size() != st.size()) return false; if (vals.size() != st.size()) return false;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min)); const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max)); const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
const std::size_t count = static_cast<std::size_t>(hi - lo); const std::size_t count = static_cast<std::size_t>(hi - lo);
if (count == 0) return false; if (count == 0) return false;
const std::size_t off = static_cast<std::size_t>(lo - st.begin()); const std::size_t off = static_cast<std::size_t>(lo - st.begin());
time.resize(count); time.resize(count);
value.resize(count); value.resize(count);
for (std::size_t i = 0; i < count; ++i) { for (std::size_t i = 0; i < count; ++i) {
time[i] = static_cast<double>(st[off + i]); time[i] = st[off + i];
value[i] = static_cast<double>(vals[off + i]); value[i] = static_cast<double>(vals[off + i]);
} }
return true; return true;
@@ -2058,21 +2058,23 @@ bool visible_samples(App& app, std::size_t channel, std::vector<double>& time,
if (n == 0) return false; if (n == 0) return false;
time.resize(static_cast<std::size_t>(n)); time.resize(static_cast<std::size_t>(n));
value.resize(static_cast<std::size_t>(n)); value.resize(static_cast<std::size_t>(n));
// In bounded chunks rather than through two whole-window temporaries: the rings hold f32 and // In bounded chunks rather than through two whole-window temporaries: the value ring holds
// the engine reads f64, so the widening is unavoidable, but the *copies* are not -- a full // f32 and the engine reads f64, so that widening is unavoidable, but the *copies* are not --
// window is millions of samples, and the temporaries cost a full write and read of it on top // a full window is millions of samples, and the temporaries cost a full write and read of it
// of the result. `kChunk` keeps the scratch in cache. // on top of the result. `kChunk` keeps both scratches in cache. Two of them, because the
// clock ring is f64 and the values f32 (see `BufferStore::time()`).
constexpr u64 kChunk = 8192; constexpr u64 kChunk = 8192;
std::vector<f32> scratch(kChunk); std::vector<f64> t_scratch(kChunk);
std::vector<f32> v_scratch(kChunk);
for (u64 at = 0; at < n; at += kChunk) { for (u64 at = 0; at < n; at += kChunk) {
const u64 take = std::min<u64>(kChunk, n - at); const u64 take = std::min<u64>(kChunk, n - at);
store.time().copy_from(first + at, take, scratch.data()); store.time().copy_from(first + at, take, t_scratch.data());
for (u64 i = 0; i < take; ++i) { for (u64 i = 0; i < take; ++i) {
time[static_cast<std::size_t>(at + i)] = epoch + static_cast<double>(scratch[i]); time[static_cast<std::size_t>(at + i)] = epoch + t_scratch[i];
} }
store.values(channel).copy_from(first + at, take, scratch.data()); store.values(channel).copy_from(first + at, take, v_scratch.data());
for (u64 i = 0; i < take; ++i) { for (u64 i = 0; i < take; ++i) {
value[static_cast<std::size_t>(at + i)] = static_cast<double>(scratch[i]); value[static_cast<std::size_t>(at + i)] = static_cast<double>(v_scratch[i]);
} }
} }
return true; return true;
@@ -2098,10 +2100,10 @@ class MeasureWindowReader {
const plot::Range view = r0.plot->x_range(r0.axis_x); const plot::Range view = r0.plot->x_range(r0.axis_x);
if ((snap_ = app.model->display_snapshot()) != nullptr) { if ((snap_ = app.model->display_snapshot()) != nullptr) {
if (channel >= snap_->values.size()) return; if (channel >= snap_->values.size()) return;
const std::vector<f32>& st = snap_->stamps; const std::vector<f64>& st = snap_->stamps;
if (snap_->values[channel].size() != st.size()) return; if (snap_->values[channel].size() != st.size()) return;
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min)); const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max)); const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
if (hi <= lo) return; if (hi <= lo) return;
off_ = static_cast<std::size_t>(lo - st.begin()); off_ = static_cast<std::size_t>(lo - st.begin());
count_ = static_cast<std::size_t>(hi - lo); count_ = static_cast<std::size_t>(hi - lo);
@@ -2174,10 +2176,10 @@ class MeasureWindowReader {
/// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment. /// Window sample `i`'s stream time. One sample at a time on purpose: see the class comment.
double time_of(std::size_t i) const { double time_of(std::size_t i) const {
if (snap_vals_ != nullptr) return static_cast<double>(snap_->stamps[off_ + i]); if (snap_vals_ != nullptr) return snap_->stamps[off_ + i];
f32 stamp = 0.0f; f64 stamp = 0.0;
store_->time().copy_from(first_ + i, 1, &stamp); store_->time().copy_from(first_ + i, 1, &stamp);
return epoch_ + static_cast<double>(stamp); return epoch_ + stamp;
} }
private: private:
+27 -13
View File
@@ -25,7 +25,7 @@ MAGIC = 0x53504455
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4 PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
T_FLOAT64 = 9 T_FLOAT64 = 9
QUANT_NONE = 0 QUANT_NONE = 0
TIME_PACKET, TIME_FULL_ARRAY = 0, 1 TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2
PUBLISH_ACCUMULATE = 1 PUBLISH_ACCUMULATE = 1
NO_TIME_SIGNAL = 0xFFFFFFFF NO_TIME_SIGNAL = 0xFFFFFFFF
@@ -50,28 +50,35 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
return bytes(d) return bytes(d)
def build_config(rate): def build_config(rate, time_mode=TIME_FULL_ARRAY):
sigs = [ sigs = [
# The stream's clock: referenced by every data signal, so udpscope treats # The stream's clock: referenced by every data signal, so udpscope treats
# it as the time base rather than a plottable channel. # it as the time base rather than a plottable channel. Its *own* time_mode
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY, # is what udpscope switches on, so `--time-mode first` has to land here.
signal_desc("time", rate=0.0, time_mode=time_mode,
time_idx=NO_TIME_SIGNAL, unit="s"), time_idx=NO_TIME_SIGNAL, unit="s"),
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit="V"),
] ]
payload = struct.pack("<I", len(sigs)) + b"".join(sigs) payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
payload += bytes([PUBLISH_ACCUMULATE]) payload += bytes([PUBLISH_ACCUMULATE])
return header(PKT_CONFIG, 0, len(payload)) + payload return header(PKT_CONFIG, 0, len(payload)) + payload
def build_data(counter, t0, dt, batch, freq): def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY):
stamps = [t0 + i * dt for i in range(batch)] stamps = [t0 + i * dt for i in range(batch)]
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps] sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps] square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps] ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
# stamps the first sample of the batch and says nothing about the rest. A scalar
# signal in Accumulate mode still occupies `batch` slots on the wire (the client
# reads `num_samples` values for any 1-element signal), so the anchor is repeated --
# which is exactly the shape that made udpscope derive a step of zero.
wire_time = [t0] * batch if time_mode == TIME_FIRST_SAMPLE else stamps
payload = struct.pack("<QI", int(t0 * 1e9), batch) payload = struct.pack("<QI", int(t0 * 1e9), batch)
for arr in (stamps, sine, square, ramp): for arr in (wire_time, sine, square, ramp):
payload += struct.pack("<%dd" % batch, *arr) payload += struct.pack("<%dd" % batch, *arr)
return header(PKT_DATA, counter, len(payload)) + payload return header(PKT_DATA, counter, len(payload)) + payload
@@ -88,6 +95,12 @@ def main():
"clock advances ten times slower than the wire delivers samples") "clock advances ten times slower than the wire delivers samples")
ap.add_argument("--declared-rate", type=float, default=-1.0, ap.add_argument("--declared-rate", type=float, default=-1.0,
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'") help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
ap.add_argument("--time-mode", choices=("full", "first"), default="full",
help="how the stream carries its clock: 'full' stamps every sample "
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
"rest by the declared cadence -- the shape a real MARTe2-style "
"producer with a single u64 clock has")
ap.add_argument("--clock-jitter", type=float, default=0.0, ap.add_argument("--clock-jitter", type=float, default=0.0,
help="per-packet stamp jitter, as a fraction of one packet's span: a device " help="per-packet stamp jitter, as a fraction of one packet's span: a device "
"that stamps each batch from its own clock does not land on a perfect " "that stamps each batch from its own clock does not land on a perfect "
@@ -98,14 +111,15 @@ def main():
dt = args.stream_scale / args.rate # stream seconds per sample dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet period = args.batch / args.rate # wall seconds per packet
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port)) sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001) sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, " print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
"stream clock x%.3g, declared %.0f S/s)" "stream clock x%.3g, declared %.0f S/s, time-mode %s)"
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True) % (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True)
peer = None peer = None
counter = 1 counter = 1
@@ -124,7 +138,7 @@ def main():
t_stream = 0.0 t_stream = 0.0
started = time.monotonic() started = time.monotonic()
next_send = started next_send = started
sock.sendto(build_config(declared), peer) sock.sendto(build_config(declared, time_mode), peer)
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True) print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
elif magic == MAGIC and ptype == PKT_DISCONNECT: elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True) print("mock udpstreamer: DISCONNECT", flush=True)
@@ -148,7 +162,7 @@ def main():
pdt = dt pdt = dt
if args.clock_jitter > 0.0: if args.clock_jitter > 0.0:
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer) sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq, time_mode), peer)
counter += 1 counter += 1
t_stream += args.batch * pdt t_stream += args.batch * pdt
next_send += period next_send += period