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:
1 parent
1fc2a3afe2
commit
eb4372242c
2 files changed
+36
-15
No files matched your search
+9
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-13
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user