Python API¶
The engine is also a Python class — ideal for notebooks and pipelines. Every CLI flag is a keyword argument, and the same defaults apply. For the full generated reference (every class and method), see the Python: Waveform Generator API.
Synth here is \"source\" everywhere else
One waveform's recipe. Python calls it a Synth; the CLI, the scene JSON
and the C API all call the same thing a source. A Segment is the
time span one or more of them are summed over — see
source and segment.
import numpy as np
from doppler.wfm import Synth
synth = Synth(type="qpsk", fs=1e6, snr=12.0, snr_mode="esno", sps=8, seed=1)
block = np.asarray(synth.steps(4096)) # → complex64 ndarray
one = synth.step() # → a single complex64 sample
synth.reset() # restart the sequence (keeps config)
The builders and the composer¶
The composition layer mirrors the concepts ladder. Builders
tone() / bpsk() / qpsk() / pn() / noise() / chirp(f_start=…, f_end=…)
/ bits(pattern=…, modulation=…) each return a Synth; Segment.sum mixes
them, .add / Timeline sequences them, and Composer renders:
from doppler.wfm import Composer, Reader, Segment, Writer, qpsk, tone
# Mix a QPSK signal of interest under a CW interferer, one noise floor.
scene = Segment.sum(qpsk(snr=15, sps=8, level=-10), tone(freq=2e5, level=-3),
num_samples=65536)
iq = Composer(scene).compose() # one complex64 array
# Stream block-by-block into a file type (empty block marks the end):
c = Composer(scene)
with Writer("frame.cf32", fs=1e6, sample_type="cf32") as w:
while len(blk := c.execute(4096)):
w.write(blk)
with Reader("frame.cf32", sample_type="cf32") as r: # C reader → complex64
back = r.read(r.num_samples)
Remember the timing gotcha from concepts:
num_samples / off_samples go on Segment.sum(...), not on Composer(...).
The symbols input — bring your own constellation¶
type="symbols" feeds an arbitrary complex constellation directly — each element
is an output point, oversampled by sps, cycled, and RRC-shaped like any
modulation:
import numpy as np
from doppler.wfm import Synth
# pi/4-QPSK: rotate every other QPSK symbol by pi/4, then pass the stream
qpsk = np.array([1 + 1j, -1 + 1j, -1 - 1j, 1 - 1j], np.complex64) / np.sqrt(2)
stream = np.array(
[qpsk[i % 4] * (np.exp(1j * np.pi / 4) if i % 2 else 1) for i in range(64)],
np.complex64,
)
iq = Synth(type="symbols", symbols=stream, sps=8, pulse="rrc").steps(64 * 8)
Reading a capture back¶
The raw file type is interleaved I/Q in the chosen sample type, so a naive
np.fromfile gets the layout (and, for integers, the scale) wrong. Reader
does the right thing entirely in C — deinterleaving and rescaling any wire type
to unit-scale complex64 — and auto-detects BLUE/SigMF/CSV/raw, recovering
fs/fc/sample-type from a self-describing header:
from doppler.wfm import Reader
with Reader("capture.iq", sample_type="ci16") as r: # hint for headerless raw
iq = r.read(r.num_samples) # → complex64, ±1.0
with Reader("capture.blue") as r: # file type auto-detected
print(r.file_type, r.fs, r.num_samples)
x = r.read(r.num_samples)
generate → Reader.read is bit-faithful. See
Type System → Reading interleaved I/Q.
Module-level helpers¶
doppler.wfm also exports the primitives the engine composes:
| Symbol | Use |
|---|---|
PN(poly, seed, length) |
a raw LFSR / PN sequence object |
bpsk_map(bits) / qpsk_map(syms) |
map bits/symbol-indices → cf32 constellation points |
wfm_awgn_amplitude(snr_db, signal_power) |
AWGN amplitude for a target SNR over fs |
wfm_ebno_to_snr_db(ebno_db, bits_per_symbol, samples_per_symbol) |
Eb/No → over-fs SNR |
rrc_taps(beta, sps, span) / dsss_spread(syms, code, sf) |
pulse-shaping and spreading primitives |
mls_poly(n) |
the auto-selected MLS polynomial for register length n |