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>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-05 10:24:05 +02:00
1 parent 1fc2a3afe2
commit eb4372242c
2 files changed
+36 -15

No files matched your search

+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
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("<I", len(sigs)) + b"".join(sigs)
payload += bytes([PUBLISH_ACCUMULATE])
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)]
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]
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)
for arr in (stamps, sine, square, ramp):
for arr in (wire_time, sine, square, ramp):
payload += struct.pack("<%dd" % batch, *arr)
return header(PKT_DATA, counter, len(payload)) + payload
@@ -88,6 +95,12 @@ def main():
"clock advances ten times slower than the wire delivers samples")
ap.add_argument("--declared-rate", type=float, default=-1.0,
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,
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 "
@@ -98,14 +111,15 @@ def main():
dt = args.stream_scale / args.rate # stream seconds per sample
period = args.batch / args.rate # wall seconds per packet
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.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", args.port))
sock.settimeout(0.001)
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
"stream clock x%.3g, declared %.0f S/s)"
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
% (args.port, args.rate, args.batch, args.stream_scale, declared, args.time_mode), flush=True)
peer = None
counter = 1
@@ -124,7 +138,7 @@ def main():
t_stream = 0.0
started = time.monotonic()
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)
elif magic == MAGIC and ptype == PKT_DISCONNECT:
print("mock udpstreamer: DISCONNECT", flush=True)
@@ -148,7 +162,7 @@ def main():
pdt = dt
if args.clock_jitter > 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