Synthetic Signals¶
Every mock oscilloscope channel now produces a real, parameterized waveform
instead of a fixed byte pattern -- and the same signal engine is available
directly, for generating test data with no instrument (or mock) involved at
all. This guide covers SignalSpec, synthesize()/make_waveform(), and
how MockConnection uses them to synthesize state-coupled, trigger-aligned
captures.
Why¶
- Develop and test without hardware. Write and exercise capture, analysis, and reporting code against realistic waveforms -- sine, square, triangle, ramp, DC, noise, chirp, exponential, pulse, multitone -- before an instrument is available.
- Reproducible test data. A seeded
SignalSpecproduces the exact same samples every run, which makes it useful for regression tests and deterministic examples/demos. - A mock that behaves like a scope.
MockConnectionchannels synthesize from their current state by default, so SCPI commands that change the timebase, voltage scale, or trigger settings visibly change the next capture -- the mock reacts the way real hardware would, instead of always returning the same bytes.
SignalSpec¶
scpi_control.signal_synth.SignalSpec is a frozen dataclass describing one
signal:
| Field | Default | Meaning |
|---|---|---|
kind |
"sine" |
"sine", "square", "triangle", "ramp", "dc", "noise", "chirp", "exponential", "pulse", or "multitone" |
frequency |
1000.0 |
Repetition rate in Hz (periodic kinds only) |
amplitude |
1.0 |
Peak amplitude in volts (Vpp = 2 * amplitude); for "noise", the standard deviation; ignored for "dc" |
offset |
0.0 |
DC offset in volts, added to every kind ("dc" outputs exactly this level) |
phase |
0.0 |
Phase in radians (periodic kinds only) |
duty |
0.5 |
High fraction of a "square" period, 0 < duty < 1 (pulse/PWM) |
noise_rms |
0.0 |
Std-dev of additive Gaussian noise laid on top of any kind |
seed |
None |
None for fresh randomness on every call; an int for reproducible output |
drift_amplitude |
0.0 |
Volts of slow baseline wander; 0 (the default) turns drift off |
drift_frequency |
0.1 |
Hz of that wander; only used when drift_amplitude > 0 |
glitch_rate |
0.0 |
Mean glitches per second; 0 (the default) turns glitches off |
glitch_amplitude |
0.0 |
Volts, peak height of a glitch |
ringing_frequency |
0.0 |
Hz of post-edge oscillation; 0 (the default) turns ringing off |
ringing_damping |
5000.0 |
Decay rate per second of that oscillation; only used when ringing_frequency > 0 |
The last six fields are impairments: default-off knobs that make a signal
imperfect the way a real one is, so measurement and analysis code has
something realistic to face instead of a mathematically clean waveform. All
six are appended at the end of the dataclass and every one defaults to a
value that leaves the signal unaffected, so existing code that constructs a
SignalSpec positionally or without these arguments is unaffected.
drift_amplitude/drift_frequencyadd slow baseline wander -- a sinusoid atdrift_frequencyHz,drift_amplitudevolts of amplitude, derived from absolute time so it (like ringing, below) stays continuous acrossstream()chunks instead of jumping at chunk boundaries.glitch_rate/glitch_amplitudeadd sparse, isolated spikes -- a Poisson process atglitch_rateevents per second, each an instantaneous+/-glitch_amplitudevolt spike on one sample. Glitches draw from their own generator (seeded independently from the base signal/noise), so enabling them never perturbsnoise_rms's samples.ringing_frequency/ringing_dampingadd a damped sinusoid after every edge of a periodic signal -- what a real probe/scope front-end does after a fast transition, and what gives an overshoot/preshoot measurement something genuine to measure. It's an edge impairment, so it is physically meaningful on signals that actually have fast edges ("square"and"pulse", or a pulse-like"ramp"). On the continuous kinds ("sine","chirp","exponential","multitone") it is not a no-op, though: edges are found as any nonzero sample-to-sample change rather than as a discontinuity, so every sample of a continuous signal qualifies and the impairment becomes a derivative-weighted filter. Its magnitude scales with the signal's slew rate -- measurable, but usually far smaller than on a real edge (atsample_rate=1e6,amplitude=1.0,ringing_frequency=50_000: about 0.10 V on"chirp", 0.056 V on"exponential", 0.013 V on"multitone"and 0.010 V on"sine", against 0.90 V on"square")."dc", whose sample-to-sample differences are all zero, is the only kind ringing genuinely leaves untouched.
from scpi_control.signal_synth import SignalSpec, synthesize
# A 1kHz square wave with all three impairments: slow thermal-style drift on
# the baseline, occasional glitches, and probe-style ringing after every edge
# -- an intentionally imperfect signal for exercising measurement code against
# something closer to what a real instrument would actually capture.
messy = SignalSpec(
kind="square",
frequency=1_000.0,
amplitude=1.0,
noise_rms=0.02,
drift_amplitude=0.05,
drift_frequency=0.2,
glitch_rate=2.0,
glitch_amplitude=0.3,
ringing_frequency=20_000.0,
ringing_damping=5_000.0,
seed=42,
)
volts = synthesize(messy, sample_rate=1_000_000.0, n_points=10_000)
"square"'s duty is the fraction of each period spent high, so a
SignalSpec(kind="square", duty=0.2) is a 20%-duty pulse/PWM waveform, not
just a symmetric square wave. "dc" ignores amplitude entirely and always
outputs offset. "noise" uses amplitude as the Gaussian standard
deviation rather than a peak value. An invalid kind or an out-of-range
parameter (non-positive frequency on a periodic kind, duty outside
(0, 1), negative noise_rms, a negative drift_amplitude/glitch_rate/
glitch_amplitude/ringing_frequency/ringing_damping, or a non-positive
drift_frequency while drift_amplitude > 0) raises InvalidParameterError
-- no partial or silently-clamped signal is ever returned.
Kind-specific parameters¶
Seven more fields exist purely to parameterize the four newer kinds; each is
ignored by every other kind, and every default is chosen so
SignalSpec(kind=X) alone works at the default 1 kHz frequency:
| Field | Read by | Default | Meaning |
|---|---|---|---|
end_frequency |
"chirp" |
10_000.0 |
Sweep stop frequency in Hz |
sweep_time |
"chirp" |
0.01 |
Seconds per sweep, after which it retraces |
sweep_log |
"chirp" |
False |
Sweep logarithmically (equal time per octave) instead of linearly |
tau |
"exponential" |
1e-4 |
RC time constant in seconds |
pulse_width |
"pulse" |
2e-4 |
50%-to-50% width (FWHM) in seconds |
edge_time |
"pulse" |
1e-5 |
0-to-100% transition time in seconds; 0 gives an ideal instantaneous edge |
harmonics |
"multitone" |
(0.1, 0.05) |
Relative amplitudes of the 2nd, 3rd, ... harmonic |
Three things are easy to get wrong here:
pulse_widthis the 50%-to-50% width (FWHM), not the flat-top duration -- it matches the instrument convention (SOUR{ch}:FUNC:PULS:WIDT) and the threshold the repo's timing analyzer measures at. The flat top itself runs forpulse_width - edge_time."pulse"also ignoresdutyentirely; independence from the period is the whole reason it exists alongside"square", whose only shape control isduty.- For
"multitone",amplitudeis the fundamental's amplitude, not the peak of the sum. It is deliberately not normalized against the harmonics, because normalizing would make THD depend on which harmonics are present; as generated, THD is exactlysqrt(sum(h**2))forharmonics = (h2, h3, ...). "chirp"retraces everysweep_time, with phase carried across the retrace rather than reset, so there is no discontinuity at a sweep boundary. Because it has no stable period,"chirp"is deliberately not inPERIODIC_KINDS: it is the one kind the mock free-runs rather than trigger-aligns (see Trigger alignment below).
Generating Signals Directly¶
from scpi_control.signal_synth import SignalSpec, synthesize, make_waveform
spec = SignalSpec(kind="sine", frequency=1_000.0, amplitude=0.8, noise_rms=0.02, seed=42)
# Raw voltage samples, as a numpy array
volts = synthesize(spec, sample_rate=100_000.0, n_points=1_000)
# A full WaveformData, ready for analysis, saving, or the report generator
waveform = make_waveform(spec, sample_rate=100_000.0, n_points=1_000, channel=1)
waveform.voltage, waveform.time, waveform.sample_rate
synthesize() returns a float64 array of n_points voltage samples;
t0 shifts the start time, which matters for periodic kinds (it's how the
mock aligns a capture to a trigger crossing -- see below). make_waveform()
wraps the same samples in a WaveformData with a matching time axis, so it
composes directly with the rest of the library:
waveform = make_waveform(SignalSpec(kind="square", frequency=1_000.0, amplitude=1.0), sample_rate=1_000_000.0, n_points=10_000)
# waveform.voltage / waveform.time feed analysis, plotting, or a report exactly
# like a captured waveform would -- see docs/report-generator/ for the reporting
# side of that chain.
Two calls with seed=None (the default) produce different noise and, for
"noise" itself, different signal on every call. Pass an int seed to get
byte-identical output across runs.
Streaming¶
synthesize() and make_waveform() generate one fixed-length block. For
continuous or live simulation -- feeding a loop, a plot, or a network socket
indefinitely -- stream() does the same phase-continuous generation without
hand-rolling the t0 bookkeeping yourself:
def stream(
spec: SignalSpec,
sample_rate: float,
chunk_size: int,
*,
start_time: float = 0.0,
duration: Optional[float] = None,
realtime: bool = False,
) -> Iterator[np.ndarray]
It returns an iterator of float64 voltage chunks, each chunk_size
samples long, where every chunk picks up exactly where the previous one left
off -- no phase discontinuity at chunk boundaries. Validation errors (the
same ones SignalSpec and synthesize() raise) happen at call time, before
the first chunk is produced.
A minimal continuous loop:
from scpi_control.signal_synth import SignalSpec, stream
spec = SignalSpec(kind="sine", frequency=1_000.0, amplitude=1.0, noise_rms=0.02)
for chunk in stream(spec, sample_rate=1_000_000.0, chunk_size=10_000):
process(chunk) # phase-continuous float64 volts; break when done
By default stream() yields chunks as fast as the consumer pulls them. With
duration=None (the default) it streams forever -- break out of the loop
to stop. Passing a positive duration bounds the stream to
round(duration * sample_rate) total samples, truncating the final chunk to
fit; start_time shifts the time of the very first sample, the same way
t0 does for synthesize().
Set realtime=True to pace chunks at wall-clock rate: chunk k is withheld
until k * chunk_size / sample_rate seconds after the first chunk was
produced. Scheduling is absolute (measured from the start, not
chunk-to-chunk), so timing error never accumulates across a long stream, and
a consumer slower than real time simply never waits -- it just gets chunks
later than it asked for them.
Seeding follows the same rule the mock uses per acquisition: seed=None
re-rolls fresh noise on every chunk, while a seeded spec advances the seed
per chunk (seed + chunk_index) so the whole stream is reproducible
run-to-run without repeating the same noise block over and over.
Mock Oscilloscope Synthesis¶
MockConnection synthesizes each channel's waveform from its current state
at every acquisition, instead of returning fixed bytes -- unless that channel
has an explicit payload (see Precedence
below).
Per-channel defaults¶
If a channel has no signals= entry, it falls back to a built-in default:
| Channel | Kind | Frequency | Amplitude | Noise (RMS) |
|---|---|---|---|---|
| 1 | square | 1 kHz | 2 Vpp (amplitude=1.0) |
0.01 |
| 2 | sine | 2 kHz | 1 Vpp (amplitude=0.5) |
0.01 |
| 3 | sine | 5 kHz | 0.5 Vpp (amplitude=0.25) |
0.01 |
| 4 | sine | 10 kHz | 0.25 Vpp (amplitude=0.125) |
0.01 |
Any channel number outside 1-4 that still needs synthesis falls back to a
1 kHz, 1 V-amplitude sine with noise_rms=0.01.
Choosing signals with signals=¶
Pass a signals={channel: SignalSpec(...)} dict to MockConnection to
override the defaults for specific channels; channels you don't mention keep
their built-in default:
from scpi_control.connection import MockConnection
from scpi_control.oscilloscope import Oscilloscope
from scpi_control.signal_synth import SignalSpec
conn = MockConnection(
"mock",
idn="Siglent Technologies,SDS1104X-E,MOCK0001,1.0.0.0",
channel_states={1: True, 2: False, 3: False, 4: False},
trigger_status=["Stop"],
sample_rate=1_000_000.0,
timebase=1e-3,
signals={1: SignalSpec(kind="sine", frequency=2_000.0, amplitude=0.8, noise_rms=0.02, seed=42)},
)
scope = Oscilloscope("mock", connection=conn)
scope.connect()
waveform = scope.get_waveform(1)
scope.disconnect()
State coupling¶
The next capture reflects the mock's current SCPI state, so writes made before an acquisition change what comes back:
- Capture length. The window is 14 divisions times the timebase; the
point count is
round(sample_rate * window), clamped to[2, 14000].scope.write("TDIV 1e-4")shrinks the window (and therefore the point count) on the next capture. - Clipping. Samples are converted to int8 codes at 25 codes/division and
clipped at ±127 -- an 8-bit ADC over-ranging exactly like real hardware.
scope.write("C1:VDIV 0.1")narrows the voltage range a division represents, so a signal whose amplitude no longer fits clips at the new ceiling (127 * vdiv / 25volts). - Trigger alignment. For periodic kinds, the mock finds where the ideal
signal crosses the configured trigger level in the trigger's slope
direction and places that crossing at the center of the capture window --
the same edge a real scope would trigger on. A triggered, noise-free
capture is therefore bit-identical across repeated acquisitions at
unchanged settings. If the configured trigger level/slope combination is
unattainable for the signal (e.g. a level outside its range), the mock
free-runs instead: each acquisition's window drifts forward by a fixed
fraction of the window, so consecutive captures visibly shift rather than
repeating. Each channel aligns to its own trigger level and signal; the
mock does not model
trigger_sourcerouting (triggering one channel off another channel's crossing) between channels."chirp"is not inPERIODIC_KINDS(it has no stable period), so it always free-runs -- never trigger-aligned, even when the configured trigger level/slope is otherwise attainable.
Precedence vs. waveform_payloads¶
waveform_payloads={channel: bytes} bypasses synthesis entirely for that
channel -- if a channel has an explicit payload, those bytes are always
returned regardless of signals= or SCPI state, which keeps existing
byte-identical tests unaffected by this feature.
Seeding rules¶
seed=None(the default): every acquisition re-rolls fresh noise (and, for"noise", a fresh signal).seed=<int>: the first acquisition for that channel uses exactly that seed; each subsequent acquisition on the same channel advances the seed by one (seed + 1,seed + 2, ...), so repeated captures are each individually reproducible but not identical to one another -- matching how a real, noisy instrument produces a different (but statistically consistent) trace on every trigger.
Web gateway default¶
Mock sessions created through the web gateway now default to
sample_rate=1_000_000.0, so a default mock session's captures are 14,000
points long (14 divisions x 1 ms/div timebase) instead of the fixed 256-byte
explicit ramp payloads earlier sessions served.
AWG to Scope Loopback¶
Every example so far has one mock instrument synthesizing from its own state.
AwgLoopback (scpi_control.connection.mock.loopback) patches a second mock
instrument's state into that synthesis, so a mock scope can capture whatever a
mock AWG is currently outputting -- two separate MockConnection objects, one
virtual cable between them:
from scpi_control.connection import MockConnection
from scpi_control.connection.mock.loopback import AwgLoopback
from scpi_control.dut import RCLowPass
from scpi_control.oscilloscope import Oscilloscope
awg = MockConnection("mock", awg_mode=True)
scope_conn = MockConnection("mock", signals={1: AwgLoopback(awg, awg_channel=1, dut=RCLowPass(cutoff_hz=2_000))})
scope = Oscilloscope("mock", connection=scope_conn)
signals= as a callable¶
signals={channel: ...} now accepts a callable that returns a SignalSpec,
not just a static SignalSpec instance -- and unlike a static spec, which is
read once, a callable is invoked at every acquisition. That is what makes
the loopback live: AwgLoopback is such a callable, and each call re-reads
the AWG connection's current channel state, so a C1:BSWV write on the AWG
changes the very next scope capture. This is a general extension point, not
specific to AWGs -- anything with a () -> SignalSpec signature works.
Function mapping¶
AwgLoopback translates the AWG channel's live state into a SignalSpec on
every call:
AWG WVTP |
SignalSpec.kind |
|---|---|
SINE |
sine |
SQUARE |
square |
NOISE |
noise |
DC |
dc |
PULSE |
pulse |
RAMP |
triangle if symmetry is within 1 percentage point of 50 (i.e. 49 to 51), otherwise ramp |
ARB |
sine, with a logged warning (the mock stores no arbitrary sample data) |
Two unit conversions happen on the way in, because the AWG and SignalSpec
don't speak the same units for the same quantity:
- Amplitude. An AWG's
AMPis peak-to-peak;SignalSpec.amplitudeis peak.AwgLoopbackhalves it, so an AWG set to 2.0 Vpp arrives as a 2.0 V peak-to-peak capture, not 4.0.NOISEis the exception, becauseSignalSpec.amplitudeis a standard deviation for that kind rather than a peak: Gaussian noise has no true peak, soAwgLoopbackmaps the AWG'sAMPon the usual convention that a quoted peak-to-peak is the ±3 sigma span (99.7% of samples), i.e.sigma = Vpp / 6. An AWG set to 2.0 Vpp of noise therefore arrives as a sigma = 0.333 V trace, and only sigma is fixed by that mapping -- the measured peak-to-peak of an actual capture is not scattered around 2.0 V but systematically above it, because the extreme of N Gaussian samples grows with N rather than converging on the ±3 sigma convention used to set sigma. A 14,000-sample capture's expected extreme is about 7.9 sigma, i.e. roughly 2.3 to 2.8 V peak-to-peak for this 2.0 Vpp request, and larger still for a longer capture. - Phase. An AWG's
PHSEis in degrees;SignalSpec.phaseis in radians.AwgLoopbackconverts.
An output with enabled=False reads flat -- like a disconnected input, not a
zero-amplitude waveform at some frequency.
Duty is clamped, not validated¶
SignalSpec requires strictly 0 < duty < 1 and raises InvalidParameterError
outside that range, but a real AWG accepts DUTY,0 and DUTY,100 without
complaint. AwgLoopback clamps the converted duty into SignalSpec's legal
range instead of propagating the AWG's value verbatim, because a mock
instrument should not raise where a real one would simply output something
(a very narrow pulse, or one at the opposite rail) and keep going.
The DUT: RCLowPass¶
AwgLoopback accepts an optional dut= -- a device model sitting between
the AWG and the scope, exactly where a real device under test would
physically sit. That's why the DUT lives on the loopback rather than on the
scope's MockConnection: it belongs to the cable run connecting the two
instruments, not to either instrument itself.
scpi_control.dut.RCLowPass(cutoff_hz=...) is a first-order RC low-pass. It
is stateful -- unlike every generator in signal_synth, which is closed-form
specifically so consecutive captures and streamed chunks join seamlessly --
so filtering a bare capture would start from y = 0 and put a settling
transient at the head of every acquisition. To avoid that, the filter is
applied to a lead-in rendered before the capture window and then discarded,
the same fix signal_synth's ringing impairment uses for the same reason: a
capture is in steady state from its very first sample, with no transient at
the head.
Known simplification: trigger alignment (see State coupling above) searches for the trigger-level crossing on the unfiltered signal, so where a capture triggers does not depend on the DUT's filter state -- only the sample values within the window do.
Quantization caveat: a legacy-dialect mock capture is quantized to int8
codes at 25 codes/division (see State coupling above), so a
voltage difference smaller than one code -- 0.04 V at the default 1 V/div --
cannot be resolved in a capture, no matter how gently the DUT is filtering. The
modern dialect's :WAVeform:WIDTh WORD path is 256x finer (6400 codes/division,
0.15625 mV/LSB), and since both paths share the same synthesis it is the WORD
grid, not int8, that the filter's lead-in depth is sized against
(dut._WARMUP_TIME_CONSTANTS, 12 time constants: e^-12 leaves under 0.05 WORD
LSB un-settled at the head of the window). A
comparison that leans on fine amplitude detail (e.g. a raw sample-to-sample
step height at a gentle cutoff) will end up measuring the code grid rather
than the filter. Either widen the effect until it clears one code, or measure
a time-domain property such as rise time instead, which isn't limited by the
code grid -- see examples/awg_scope_loopback.py, which does exactly that.
Extensibility¶
Signal kinds live in a small dispatch table (kind -> generator function)
inside scpi_control/signal_synth.py. The mock's state-coupling and
volts-to-codes conversion are kind-agnostic, so adding a new kind is: one new
generator function, a table entry, and matching docs/tests -- no changes to
MockConnection or the waveform code path.
See Also¶
- Data Provenance for what gets attached to a saved waveform (including one synthesized by the mock) and how to read it back
examples/synthetic_signals.pyfor a runnable, hardware-free walkthrough ofmake_waveform(), a mock session reacting to SCPI writes, and the save/reload round tripexamples/awg_scope_loopback.pyfor a runnable walkthrough of the AWG to scope loopback, including theRCLowPassDUT