#!/usr/bin/env python3 # SPDX-License-Identifier: MIT # SPDX-FileCopyrightText: 2026 Martino Ferrari """A minimal UDPStreamer (UDPS) producer, for exercising udpscope without MARTe2. Unicast only: binds `--port`, waits for a CONNECT datagram, then streams CONFIG once and DATA packets to whoever connected. Publish mode is Accumulate, so each packet carries `--batch` consecutive samples per (scalar) signal -- the shape the viewer's per-sample path expects. Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`. Everything is float64 and unquantised. ./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100 """ import argparse import math import random import socket import struct import time import numpy as np 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, TIME_FIRST_SAMPLE = 0, 1, 2 PUBLISH_ACCUMULATE = 1 NO_TIME_SIGNAL = 0xFFFFFFFF def header(pkt_type, counter, payload_bytes): return struct.pack(" 0.0 else stamps # The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which # is the shape that is hardest on anything counting crossings: near the sine's own mid level # the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal # recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and # it is the default because the documented probe assertions are *exact* (`peak to peak=2`, # `top=0.636620`) and any interferer moves the extremes. if noise > 0.0: sine = [ math.sin(2.0 * math.pi * freq * t) + random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t) for t in stamps ] else: 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] noisy = np.sin(100 * math.pi * np.array(stamps) + 0.5) + np.random.normal( 0, 0.5, batch ) # A glitch is an *inversion* of the square for `glitch_width` seconds, repeated once per # square period (2/freq -- the period of sin(2*pi*freq*0.5*t)). Inverting rather than # forcing a level makes it a narrow pulse whatever the square's polarity is, and repeating # it lets a probe assert the *spacing* of the hits a width condition finds instead of an # absolute count, which depends on when the probe happened to start looking. if glitch_at >= 0.0 and glitch_width > 0.0: sq_period = 2.0 / freq for i, t in enumerate(stamps): if t < glitch_at: continue k = math.floor((t - glitch_at) / sq_period) start = glitch_at + k * sq_period if t < start + glitch_width: square[i] = -square[i] # `--quiet-at` is for the dropout condition: the sine stops at a level on the *opposite* # side of zero from where it was, so `quiet_at` itself is its last zero crossing and a # dropout at level 0 fires at exactly `quiet_at + timeout`. Holding at the same sign would # leave the last crossing at whichever earlier zero happened to precede it, which is not a # figure a probe can assert. Choose a `quiet_at` away from the sine's own zeros. if quiet_at >= 0.0: held = -0.5 if math.sin(2.0 * math.pi * freq * quiet_at) >= 0.0 else 0.5 for i, t in enumerate(stamps): if t >= quiet_at: sine[i] = held # 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 = ( [wire_stamps[0]] * batch if time_mode == TIME_FIRST_SAMPLE else wire_stamps ) payload = struct.pack("= 17: magic, ptype = struct.unpack_from(" 0.0: pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter)) spike = 0.0 if ( args.time_glitch_at >= 0.0 and not glitched and t_stream >= args.time_glitch_at ): spike = args.time_glitch_by glitched = True print( "mock udpstreamer: time-signal glitch at t=%.3f, +%.3f s for one packet" % (t_stream, spike), flush=True, ) pkt = build_data( counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise, spike, args.glitch_at, args.glitch_width, args.quiet_at, ) burst = max(1, args.drop_burst) if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0: drop_left = burst if drop_left > 0: drop_left -= 1 dropped += 1 if not noted_drop: noted_drop = True print( "mock udpstreamer: dropping packet %d and the %d after it" % (counter, burst - 1), flush=True, ) elif ( args.reorder_every > 0 and held is None and counter % args.reorder_every == 0 ): held = (counter, pkt, now + args.reorder_delay_ms / 1000.0) else: sock.sendto(pkt, peer) if held is not None and now >= held[2]: # Within the receiver's reorder window, this packet is put back in order and # costs nothing. Past the window, the receiver has already committed a hole # and this packet is what fills it. sock.sendto(held[1], peer) inverted += 1 if not noted_reorder: noted_reorder = True print( "mock udpstreamer: sent packet %d before %d, releasing it %.1f ms late" % (counter, held[0], args.reorder_delay_ms), flush=True, ) held = None # The counter and the stream clock advance whatever became of the datagram. That # is what makes a withheld packet a *lost* one and not a slower producer: nothing # in a payload says which packet it is, only the header's counter does. counter += 1 t_stream += args.batch * pdt next_send += period if ( args.duration > 0.0 and started is not None and now - started > args.duration ): print( "mock udpstreamer: done (%d packets, %d dropped, %d inverted)" % (counter, dropped, inverted), flush=True, ) return if __name__ == "__main__": main()