From eb4372242c1a9e7d7c21144ec4587679f7df7457 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 5 Oct 2026 10:24:05 +0200 Subject: [PATCH] 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 --- src/acquisition.cpp | 11 +++++++++-- tools/mock_udpstreamer.py | 40 ++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/acquisition.cpp b/src/acquisition.cpp index e087323..d4451fc 100644 --- a/src/acquisition.cpp +++ b/src/acquisition.cpp @@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign used_stream_time = true; break; 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 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) * step; + for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence; used_stream_time = true; break; } diff --git a/tools/mock_udpstreamer.py b/tools/mock_udpstreamer.py index 685384e..10f7bd8 100644 --- a/tools/mock_udpstreamer.py +++ b/tools/mock_udpstreamer.py @@ -25,7 +25,7 @@ MAGIC = 0x53504455 PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4 T_FLOAT64 = 9 QUANT_NONE = 0 -TIME_PACKET, TIME_FULL_ARRAY = 0, 1 +TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2 PUBLISH_ACCUMULATE = 1 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) -def build_config(rate): +def build_config(rate, time_mode=TIME_FULL_ARRAY): sigs = [ # The stream's clock: referenced by every data signal, so udpscope treats - # it as the time base rather than a plottable channel. - signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY, + # it as the time base rather than a plottable channel. Its *own* time_mode + # 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"), - signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), - signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"), - signal_desc("ramp", 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_mode, time_idx=0, unit="V"), + signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit="V"), ] payload = struct.pack("= 0.0 else -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(" 0.0: 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 t_stream += args.batch * pdt next_send += period