Skip to content

Waveforms — what you can generate

Every --type the engine offers, the knobs that shape it, how loud it comes out, and the three things you can wrap around it: a frame, a channel code, and a moving clock.

For putting waveforms in time — scenes, sweeps, streaming — see Scenes. The object model is on the guide index.


--type selects the waveform; every type shares the same parameter set. The engine produces nine types from one declarative core.

--type What it is Key parameters
tone a complex sinusoid at --freq --freq
noise complex AWGN (unit power) --snr (ignored — it is noise)
pn a maximum-length sequence (±1 chips), --sps samples/chip --pn-length, --pn-poly, --sps
bpsk BPSK symbols (PN-sourced data), --sps samples/symbol --sps, --snr
qpsk Gray-coded QPSK symbols (PN-sourced data) --sps, --snr
chirp linear-FM sweep --freq → --f-end over --count --freq, --f-end
bits your bits (a data source), oversampled --sps, sent once --data, --modulation, --sps
symbols your complex constellation, oversampled --sps and cycled --symbols-file, --sps

The data bits for bpsk/qpsk come from a deterministic PN sequence (seeded by --seed), so output is reproducible and receiver-correlatable.

wfmgen --type tone --freq 1e5 --fs 1e6 --count 4096 -o tone.cf32
wfmgen --type qpsk --sps 8 --snr 12 --count 100000 -o qpsk.cf32
wfmgen --type chirp --freq 100e3 --f-end 300e3 --fs 1e6 --count 10000 -o chirp.cf32

Which waveforms have a receiver

wfmgen is a transmitter. Some of what it sends has a receiver in doppler and some is stimulus only, and it is better to know which before building a test around one.

You send Received by
bpsk / qpsk / bits (a BPSK or QPSK mapping) as a continuous stream, framed (preamble, sync word, data, CRC) or not MpskReceiver, with FrameDesc.check scoring each frame
a dsss burst (--acq-code, --data-code, a frame) DsssBurstReceiver (or BurstDemod once a burst is located)
continuous dsss (--symbol-rate) DsssReceiver
an unspread burst: bits / bpsk / qpsk frames with silence or noise between them (--off, --repeats, --delay), or one frame alone stimulus only: it must be located before it can be demodulated, and nothing here does both
chirp stimulus only: no demodulator
symbols (QAM, APSK, pi/4-QPSK, anything you supply) stimulus only: no demodulator for the constellation
tone, noise, pn measurement and correlation inputs, not links

What makes a burst stimulus-only is not its preamble. A preamble is part of a continuously framed stream, and MpskReceiver receives that stream and scores its frames; it is the schedule (a frame, then silence, then another) that leaves no receiver, because a tracking loop has nothing to track in the gaps.

"Stimulus only" is a statement about demodulating, not about finding the signal. A burst that starts with a complex preamble can still be detected and located with BurstAcquisition, and BurstCapture cuts the located bursts out of a stream; what you do with their samples next is yours. doppler does not ship a receiver for these: they exist to drive your own receiver, an acquisition test, or a plot.


Chirp — linear-FM sweep

A chirp sweeps its instantaneous frequency linearly from --freq (the start) to --f-end over its span, then holds at --f-end; --f-end < --freq is a down-chirp. Its phase is one running accumulator, so a chirp read in pieces -- one block or many, step() or steps(n) -- joins seamlessly: the pieces are the same samples, byte for byte, as one read. Radar pulse compression, SAR, sonar and frequency-response test signals all fall out of this one type. A scene's segments are different: each segment starts its sources afresh, so a sweep meant to run unbroken is one segment.

On the CLI and in a Segment the span is the on-time (--count, num_samples); a span on the source overrides it. Sample n steps by --freq + n·k, with k = (--f-end − --freq) / span, so the span's last sample steps at --f-end − k, and --f-end is held from the sample after.

A standalone Python Synth/chirp() has no segment to lend a span, so a sweeping one must declare span=. It raises on first generation otherwise, rather than guessing one from the size of the first read, which would make step(), steps(n) and block reads three different waveforms (#1115).


Bits — your bit pattern, mapped

A bits waveform sends your bits — a preamble, sync word, or test vector — from a data source: a Field — a 0/1 string (--data 10110101), hex (--data 0xAA55, MSB first) or a generated sequence (--data pn:1024:15) — or a binary file whose bytes are the bits, MSB first per byte (--data-from-file frame.bin), which is how a real transfer frame reaches the tool. --modulation (none / bpsk / qpsk) maps the bits to symbols (none → 0/1 amplitude, bpsk → ±1, qpsk → two bits per symbol, Gray-coded). Each bit is held --sps samples, and the bits are sent once: the run is the data. A source is a frame's payload, so the default --crc crc16 appends a CRC-16; --crc none sends the bits exactly as given.

wfmgen --type bits --data 10110101 --crc none --modulation bpsk --sps 8 -o sync.cf32
wfmgen --type bits --data 0xAA55 --crc none --modulation none --sps 4 -o preamble.cf32

# a frame on disk: 0xB2 0x5A is the same 16 bits as --data 0xb25a
printf '\262\132' > frame.bin
wfmgen --type bits --data-from-file frame.bin --crc none --modulation bpsk \
       --sps 1 -o from-file.cf32

Data — a payload drawn frame by frame

A data source fills a frame's payload a chunk at a time: --data-len bits per frame, each frame carrying the next chunk with its own CRC, until the source ends. This is how a message, a file or a pipe is sent as a sequence of frames rather than as one pattern cycled. It works on the framed types (bits, bpsk, qpsk, pn) and on a DSSS burst, where each burst is the preamble then the next chunk's frame, spread. Continuous DSSS has no frame: each data symbol carries the next bit, so --data-len and --fill do not apply and are refused, a finite source ends the run with its last data symbol, and --realtime over stdin is refused (there is no idle frame to send while the pipe pauses). With no data source it sends its seeded PRBS, as it always has.

A paced producer must stay ahead. Under --realtime, a frame's data has to be in the pipe when its block is computed, up to one 4096-sample block (4096 / fs seconds) early. A producer that is ahead of the run, as cat always is, never trips this. A producer that paces itself to the frame rate gets idle frames at the start until it is one block ahead, and that holds at any frame period.

flag (scene key) what it is
--data FIELD ("data") the source's bits, as a Field: 0x…, 0101, or a finite pn:/gold:
--data-from-file PATH ("data_from_file") a file of packed octets, MSB first; - is stdin (the command line, and its record)
--data-len BITS ("data_len") bits per frame; 0 takes a finite source whole, as one frame
--fill FIELD ("fill") pads the last frame when the data does not divide into frames, and an idle frame

--data and --data-from-file are one exclusive pair. What each kind of source is -- finite or a stream, and how many frames it makes -- is the table in wfm/wfm_data.h, the one home of it.

  • A finite source sets the run's length: ceil(bits / data_len) frames, so --count beside it is refused, and a --record replays it without one.
  • A remainder needs a fill, refused before the first sample otherwise, naming the fix.
  • stdin is a stream: it always needs --fill, ends the run on the frame boundary where the input ends, and cannot be repeated (--repeat, --repeats, --continuous) or written into a scene by hand. Under --realtime, a pause in the input sends idle frames, so the carrier and the frame timing never break.

An idle frame is defined by the fill. It is a data frame whose data field is the declared --fill Field, tiled from its first bit across --data-len bits, and it is framed exactly like any other frame: the same preamble, sync word or ASM, the CRC computed over it, and every coding stage of the frame description applied. Only the data field differs. A receiver recognises an idle frame by comparing its decoded data field with the fill. Bits that arrived before the pause wait for the next data frame, and are never mixed into an idle frame. Only a paced (--realtime) stream has idle frames: unpaced, the writer waits for the input, as cat does.

Recording and replaying a data source

--record stores the data source as it was given: a --data Field inline, and a --data-from-file by its path. Once the run ends it adds "data_sent", what the source sent, which is the truth for scoring:

key what it counts
frames data frames sent, the padded last included
pad_bits fill bits padding the last frame (0 when the data divides)
idle_frames idle frames sent; only a paced stream has any
bits source bits read, fill excluded
hash a file or stdin only: dp_hash64 (FNV-1a 64) of octets read

The counts are per instance of the segment: a finite source sends the same frames in every instance, and a stream has only one. A SigMF capture carries the same truth on each annotation, as wfmgen:frames, wfmgen:pad_bits, wfmgen:idle_frames, wfmgen:data_bits and wfmgen:data_hash. Both are written when the run ends, so the idle count is final.

A replay identifies a file by its content, not by its name. A path alone would replay whatever the file holds on the day, and a length alone misses an edit of the same size. So, before the first sample:

  • a recorded file whose length in bits or hash differs is refused, and the error names the file, the record's hash and the file's;
  • a run read from stdin is recorded as "data_from_file": "-" with the hash of what was read. Its octets are gone, so it replays only when --data-from-file FILE is given again, naming a file that holds them, which is checked the same way. - again is refused, because a pipe could only be checked after it had been sent;
  • a --data Field replays from the record itself, and pn:0 from its seed.
# Record a run from a file: the record names the file, its length in bits
# and its hash, and what was sent. The replay sends the same samples.
printf 'F7F7' > msg.bin
wfmgen --type bpsk --data-from-file msg.bin --data-len 16 --sps 4 \
       --record msg.json -o a.cf32
wfmgen --from-file msg.json -o b.cf32

# The same length, edited in place: the replay is refused before it writes
# anything, naming the record's hash and the file's.
printf 'F7F8' > msg.bin
! wfmgen --from-file msg.json -o c.cf32   # refused: exits non-zero

# From stdin: recorded as "-", replayed from the file given again.
printf 'F7F7' > msg.bin
wfmgen --type bpsk --data-from-file - --data-len 16 --fill 0 --sps 4 \
       --record piped.json -o d.cf32 < msg.bin
wfmgen --from-file piped.json --data-from-file msg.bin -o e.cf32

python3 -c "import filecmp as f; assert f.cmp('a.cf32', 'b.cf32', 0)"
python3 -c "import filecmp as f; assert f.cmp('d.cf32', 'e.cf32', 0)"
python3 -c "import os; assert not os.path.exists('c.cf32')"
# A message, three 16-bit frames, each with a CRC-16 over its own chunk.
wfmgen --type bpsk --data 0x0123456789AB --data-len 16 --sps 4 -o frames.cf32

# The same bytes from a file, then from stdin: the same waveform.
printf '\001\043\105\147\211\253' > six.bin
wfmgen --type bpsk --data-from-file six.bin --data-len 16 --sps 4 -o file.cf32
printf '\001\043\105\147\211\253' \
  | wfmgen --type bpsk --data-from-file - --data-len 16 --fill 0 --sps 4 \
           -o stdin.cf32
cmp frames.cf32 file.cf32
cmp frames.cf32 stdin.cf32

# A DSSS burst per frame: the same three chunks, three bursts.
wfmgen --type dsss --acq-code 0x9 --data-code 0xd --data 0x0123456789AB \
       --data-len 16 --sps 1 -o bursts.cf32

# Continuous DSSS: the same 48 bits, one per data symbol, then the run ends.
wfmgen --type dsss --data-code 0x1d --symbol-rate 2700 --sps 2 \
       --fs 6138000 --data 0x0123456789AB -o cont.cf32

From Python, data= takes the bits -- a file's bytes become bits through cvt.bytes_to_bin -- and there is no file face of its own. The message comes back frame by frame, each with its own CRC (wfmgen_data_source_demo.py, whose C twin is native/examples/wfmgen_data_source_demo.c):

import numpy as np

from doppler.cvt import bytes_to_bin
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment

message = b"a payload drawn from a data source, frame by frame"
octets = np.frombuffer(message, np.uint8)
bits = np.zeros(8 * octets.size, np.uint8)
bytes_to_bin(octets, bits, 0)  # MSB first, as a file is read

SPS = 4
d = FrameDesc()
PAYLOAD = 96  # bits of the source per frame
d.add_data("payload", PAYLOAD)
d.add_derived("crc", 16)
d.add_stage_over(STAGE_CRC16, "payload", "crc")  # a CRC-16 over its chunk
d.build()  # lay it out: the receive side (check, deframe) reads the layout
seg = Segment(
    type="bpsk",
    fs=1e6,
    sps=SPS,
    snr=100.0,
    snr_mode="fs",
    data=bits,  # the source: its bits
    frame=d,  # each frame: its chunk, then a CRC-16 over it
    fill=np.array([0, 1], np.uint8),  # pads the short last frame
)
x = np.asarray(Composer([seg]).compose())

Symbols — bring your own constellation

Where bits maps data through a fixed modulation, symbols skips the map entirely: --symbols-file iq.cf32 supplies a raw interleaved-I/Q complex64 constellation stream and each element becomes an output point directly, oversampled by --sps and cycled. That expresses any modulation an enum doesn't — pi/4-QPSK, QAM, APSK — since you compute the constellation yourself and pass it. symbols honours --pulse rrc like the built-in modulations.

wfmgen --type symbols --symbols-file qam16.cf32 --sps 8 --pulse rrc -o qam.cf32

In Python the constellation is the symbols= keyword on the composer Synth (Synth(type="symbols", symbols=iq, sps=8)); on the low-level _SynthEngine it is attached with set_symbols() after construction. See the Python API for a worked pi/4-QPSK example, and the Symbols gallery walkthrough for pi/4-QPSK and 16-QAM constellations with rect vs RRC pulses.


Framing — preamble, sync word, CRC

--acq-code / --sync / --crc describe a frame's bit layout:

[ preamble x acq-reps | sync | payload | CRC-16 ]

Setting --acq-code or --sync is what makes a source framed. --crc on its own does not — it defaults to crc16, so treating it as intent would put a trailer on every plain bit pattern ever generated.

One Field per sequence

--acq-code, --data-code, --sync, --data and --fill each take one Field — literal bits, hex, or a generated sequence — in the one grammar every face reads. Its forms, numbers, limits and refusals are Fields: bits as text.

A *REPS suffix repeats the preamble — --acq-code 'pn:127:7*4' — and only the preamble. Quote it, or the shell globs the *.

wfmgen --type bits --data 10110010 --sync pn:1023:10 \
       --sps 4 --record run.json -o framed.cf32

# A DSSS burst with BOTH of its codes generated: a 127-chip preamble a
# receiver correlates against, and a 31-chip code spreading the payload.
wfmgen --type dsss --acq-code 'pn:127:7*4' \
       --data-code pn:31:5 --sync 1111100110101 --data 0xa5c3 \
       --sps 2 --snr 8 --snr-mode esno -o burst.cf32

Every number takes hex or decimal, so a tap mask reads as 0x409 the way it does in the literature.

Why generated Fields exist. --sync 1111100110101 is fine for a Barker-13. A 1023-chip sync word is not, and a --record of one is a 1023-character string that says nothing about what produced it. --record stores the Field itself, so a capture is reproducible from its metadata — which is the point of the kinds, not a shorthand for them:

{"name": "sync", "spec": "pn:1023:10"}

The payload is a data source

The payload is --data — a Field like the other three — or --data-from-file, sent frame by frame as Data describes: each frame carries the next --data-len bits, so a long source is a multi-frame record whose length is the data's, and nothing is cycled.

wfmgen --type bits --modulation bpsk --data pn:65535:16 --data-len 4096 \
       --fill 0 --sync 1111100110101 --sps 4 -o framed.cf32

--type bpsk/qpsk/pn frame the same data and map its bits as the type names, so a framed --type qpsk is Gray-coded QPSK over the frame and there is no --modulation to disagree with it; a coded frame reaches them too. For --type bits, --modulation maps the frame to BPSK or QPSK, so a framed unspread waveform is one command:

wfmgen --type bits --modulation bpsk --data 10110010 \
       --acq-code '10101010*4' --sync 1111100110101 --crc crc16 \
       --sps 4 -o framed.cf32

A frame needs a data source: the framing flags without one are refused, naming --data, rather than writing an unframed waveform at exit 0. A type that carries no bit stream at all — tone, noise, chirp, symbols — cannot be framed, with or without one.

The Python Synth and Segment take the frame as a description, frame= (a FrameDesc), with acq_code= and acq_reps= for a DSSS preamble; --sync and --crc build that same description for you, and --record stores it, so --from-file rebuilds the framed waveform byte for byte. A scene's old "sync" and "crc" keys are refused, naming "frame".

The layout is one C descriptor (native/inc/doppler/wfm/wfm_frame.h) read by the generator that builds it and the measurer that scores it — see Receiver Test Harness §7.


PN sequences & MLS

--type pn emits a maximum-length sequence; --pn-length n sets the LFSR register length (2 to 64, period 2ⁿ−1). The register, polynomial, and --pn-poly are full 64-bit. Leave --pn-poly 0 and the engine selects a primitive polynomial that yields a true MLS for that length (a built-in table of verified primitive polynomials for every length 2..64) — verified by period, balance, and the thumbtack autocorrelation. Supply --pn-poly only to force a specific tap set.

wfmgen --type pn --pn-length 7   --sps 1 --count 127   # one full period (2⁷−1)
wfmgen --type pn --pn-length 11  --sps 4               # length-11 MLS, 4× oversampled
wfmgen --type pn --pn-length 7   --lfsr fibonacci      # Fibonacci realization
# Force a specific tap set instead of the auto-selected one. 0x41 is the
# table's own entry for length 7 (x⁷+x⁶+1), so this is byte-identical to
# --pn-poly 0 above -- which is how you check a polynomial you were handed.
wfmgen --type pn --pn-length 7 --pn-poly 0x41 --sps 1 --count 127 -o pn7.cf32

--lfsr selects the LFSR realization: galois (default, internal XOR feedback) or fibonacci (external XOR of the tapped bits). Both use the same primitive polynomial and have the same period 2ⁿ−1; they differ only in the chip sequence/phase. The Fibonacci taps are derived from the same polynomial, so --pn-poly 0 still auto-selects the MLS for either mode.


RRC pulse shaping — band-limited carriers

The modulated types (pn / bpsk / qpsk / bits / symbols) default to rectangular sample-and-hold chips — a wide sinc² spectrum. Set --pulse rrc for root-raised-cosine shaping: the symbol stream is filtered to a band-limited channel, so a realistic carrier (e.g. a WCDMA QPSK downlink at roll-off 0.22) comes straight from the generator. --rrc-beta is the roll-off and --rrc-span the filter support in symbols. The taps are unit-transmit-power scaled, so the output stays at unit average power.

wfmgen --type qpsk --sps 8 --pulse rrc --rrc-beta 0.22 --count 100000 -o wcdma.cf32
# A longer span truncates the RRC tails less, so the stopband is deeper --
# at the cost of `--rrc-span * --sps` more samples of filter delay.
wfmgen --type qpsk --sps 8 --pulse rrc --rrc-beta 0.22 --rrc-span 16 \
       --count 100000 -o wcdma-span16.cf32

Engine parameter reference

Flag Type Default Meaning
--type tone noise pn bpsk qpsk chirp bits symbols dsss tone waveform
--fs float (Hz) 1.0 sample rate (default 1.0 ⇒ --freq/--f-end are normalised, cycles/sample)
--freq float (Hz) 0 frequency offset from baseband (mixed by the LO); chirp start
--f-end float (Hz) 0 chirp end frequency (--type chirp only)
--snr float (dB) 100 SNR; metric chosen by --snr-mode (≈clean at 100) — see Levels & SNR
--snr-mode auto fs ebno esno auto how --snr is interpreted
--seed uint32 0 PRNG / LFSR seed (deterministic)
--sps int 1 samples per symbol (*psk/bits/symbols) / per chip (pn)
--pn-length int (2..64) 15 LFSR register length → period 2ⁿ−1
--pn-poly uint64 0 LFSR polynomial; 0 ⇒ auto-pick the MLS polynomial
--lfsr galois fibonacci galois LFSR realization (same polynomial/period, different sequence)
--data Field — the payload's data source, e.g. 10110101, 0xAA55, pn:1024:15 (or --data-from-file)
--modulation none bpsk qpsk bpsk bits: how the pattern maps to symbols
--symbols-file path (cf32) — symbols: raw interleaved-I/Q complex64 constellation stream
--acq-code Field — preamble code; *REPS repeats it, e.g. 'pn:127:7*4'
--data-code Field — dsss: payload spreading code
--sync Field — frame-sync word (optional)
--crc none crc16 crc16 dsss: CRC-16 trailer over the payload bits
--pulse rect rrc rect pulse shape; rrc = band-limited RRC shaping
--rrc-beta float 0.35 RRC roll-off (--pulse rrc)
--rrc-span int 8 RRC filter support in symbols (--pulse rrc)
--count int 0 on-time in samples; 0 derives it (the sources' length, else 1024)

Levels & SNR

Amplitude & full-scale

The amplitude invariant is unit average power: every waveform is normalised so its mean power is 1.0. That — not a constant envelope — is what the rest of the system is built on. It is the reference the SNR math uses (signal power ≡ 1, so the noise σ falls straight out of the target SNR), and the level you control is the SNR, not a signal gain. The I/Q full-scale is ±1.0 per axis (→ the largest integer code).

Today's built-in types all happen to be constant-envelope, so for them the peak equals the average and they sit exactly at ±1.0 — but that is a property of the current set, not a design assumption:

--type Sample values Envelope Avg. power
tone exp(j·2πft) constant, mag 1 1.0
bpsk / pn ±1 (real axis) constant, mag 1 1.0
qpsk (±1/√2, ±1/√2) constant, mag 1 1.0
chirp exp(j·φ(t)), φ′ ramps freq→f_end constant, mag 1 1.0
noise complex Gaussian, σ = 1/√2 per axis Gaussian, PAPR > 0 1.0
symbols whatever you supply (e.g. 16-QAM) user-defined user-defined

Don't rely on |z| = 1. A pulse-shaped (RRC), QAM, or OFDM waveform has a peak-to-average power ratio (PAPR) above 0 dB: at unit average power its peaks run well past ±1.0. noise, and any signal-plus-noise sum, already do.


Scaling to the wire, and headroom

cf32 / cf64 carry samples verbatim and never clip — peaks past ±1.0 are preserved. The integer types scale by 2^(N-1), round to nearest, and saturate to the type's range — the cvt converters do it, so a capture's codes are exactly what doppler.cvt.F32ToI16().steps() produces:

--sample-type Map -1.0 → +1.0 →
ci32 round(v·2³¹) -2147483648 2147483647
ci16 round(v·2¹⁵) -32768 32767
ci8 round(v·2⁷) -128 127

-1.0 lands exactly on the most negative code; +1.0 scales to 2^(N-1), one past the maximum by construction, and saturates. That asymmetry belongs to two's complement, not to the scaling — and saturating is what makes it safe.

So clipping is governed by PAPR, not by something being "signal" vs "noise":

  • A constant-envelope, clean signal (a tone/PSK/PN at --snr 100) fills the integer range, and the only samples that saturate are the ones that reach exactly +1.0 — which lose half an LSB, not a peak.
  • Any PAPR > 0 dB content clips at the rails — added noise (at --snr 0, noise power = signal power, ~⅓ of integer I/Q components already saturate) and any pulse-shaped / QAM / OFDM mode. Such a signal needs headroom: --headroom <dB> (and Writer(headroom=…) in Python) scales the whole output down to −H dBFS so the peaks fit. It is a single common gain, so it is SNR-invariant — it moves only the absolute level, not any power ratio — and 0 dB (the default) is a bit-exact no-op. An integer capture that clips reports the exact backoff to use (remedy: --headroom N). You can also just carry envelope-varying signals as a float type (cf32 / cf64), which never clips.

Reader (see Output & file types) inverts the same map, so a float round-trip is exact, and an integer round-trip is exact wherever the value lands on a code and did not clip — which, because full scale is a power of two, includes every dyadic value: 0.5 comes back as 0.5.

Clipping is observable, not silent. Two flags turn "the capture looks wrong" into a number, and they are what to reach for before anything else: --clip-report prints the clipped fraction and the peak to stderr, and --clip-error makes clipping an exit status — which is what you want in a script that generates a capture and must not hand on a broken one.

# What does this shaped, noisy QPSK actually do to a 16-bit integer capture?
wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
       --sample-type ci16 --clip-report -o shaped.ci16
#   wfmgen: warning: ci16 output clipped — peak is +13.4 dB over full scale.
#     remedy: --headroom 14, or --sample-type cf32.
#     clipped 42.41% of I/Q components

# Refuse to write a clipped capture at all: this one EXITS NON-ZERO.
! wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
       --sample-type ci16 --clip-error -o shaped.ci16   # clipped: exits non-zero

# Take the remedy the report named, and it passes.
wfmgen --type qpsk --sps 8 --pulse rrc --snr 6 --count 20000 \
       --sample-type ci16 --headroom 14 --clip-error -o clean.ci16

# --level places a source in dBFS. Backing a source off is how a scene puts
# one signal below another; --headroom scales the composite AFTER summing.
wfmgen --type tone --freq 1e5 --fs 1e6 --level -10 --count 4096 -o quiet.cf32

--level is per-source and --headroom is per-output: a level difference is part of the scene (and survives into every sweep point a Plan renders), while headroom is a single common gain applied on the way to the wire.

>>> import numpy as np
>>> from doppler.wfm import Synth
>>> # the invariant is unit *average* power (here a clean, constant-envelope QPSK)
>>> x = Synth(type="qpsk", sps=1, snr=100.0).steps(4096)
>>> bool(np.allclose(np.mean(np.abs(x) ** 2), 1.0))
True
>>> # add noise (or pulse-shaping / QAM) and peaks exceed full-scale:
>>> y = Synth(type="qpsk", sps=1, snr=0.0).steps(100000)
>>> float(np.mean(np.abs(y.real) > 1.0)) > 0.1   # many samples clip in ci*
True

SNR & noise

--snr is applied as AWGN; --snr-mode chooses the reference:

Mode --snr means Use for
fs SNR over the full sample rate (in-band power / noise power) tones, wideband
esno Es/No — energy per symbol over noise PSD modulated (*psk)
ebno Eb/No — energy per bit over noise PSD link-budget work
auto esno for bpsk/qpsk/dsss, fs for the other six the sensible default

auto splits the nine --types exactly once, and this is the one place that split is written out — the schema and the API page defer here rather than carry their own copy:

auto resolves to types
esno (Es/N0) bpsk · qpsk · dsss
fs (over full scale) tone · noise · pn · chirp · bits · symbols

bits is on the fs side, which surprises people: a bits waveform is modulated (--modulation bpsk|qpsk), but its symbol rate is whatever the frame and --sps make it, so there is no symbol energy the engine can refer to without being told. Ask for one explicitly with --snr-mode esno if that is what you want. Verified by construction: --type bits renders byte-identically under --snr-mode auto and --snr-mode fs, and differently under esno.

--snr 100 (the default) is clean — snr ≥ 100 dB generates no AWGN at all, so a clean waveform pays no noise cost. Lower --snr to add noise; the signal stays at unit average power, so the per-axis noise σ is σ = sqrt(1 / (2·10^(snr_fs/10))), where Es/No and Eb/No are first converted to an over-fs SNR using 10·log10(sps) (and, for Eb/No, the bits/symbol: 1 for BPSK/PN, 2 for QPSK). (--type noise always generates AWGN.) Likewise --freq 0 skips the LO — the carrier is a constant 1 — so a clean baseband waveform is pure signal generation.

dsss: the symbol is the outer data symbol

For type="dsss" the Es/N0 reference is the outer data symbol (len(data_code) chips × sps samples) — what a despreader's data-aided estimator measures — not the chip a hand-spread bits pattern would get. Full semantics: DSSS bursts.

Same QPSK at three references

wfmgen --type qpsk --snr 10 --snr-mode esno     # 10 dB Es/No (the auto default)
wfmgen --type qpsk --snr 7  --snr-mode ebno     # 7 dB Eb/No  (= 10 dB Es/No)
wfmgen --type qpsk --snr 1  --snr-mode fs        # 1 dB over fs (per-sample)

DSSS bursts

The canonical spread-spectrum test capture — N bursts at a specific data-symbol Es/N0, each a repeated PN preamble followed by a payload spread with a second code, randomly placed with a minimum gap, over a continuous noise floor — is one Segment:

import numpy as np
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment

rng = np.random.default_rng(0)
acq = rng.integers(0, 2, 128, dtype=np.uint8)   # preamble code A
dat = rng.integers(0, 2, 25, dtype=np.uint8)    # data-spreading code B
pay = rng.integers(0, 2, 200, dtype=np.uint8)   # payload bits
BARKER13 = np.array([1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], np.uint8)

# The frame is a description: Barker-13 | the payload | a CRC-16 over it
frame = FrameDesc()
frame.add_field("sync", BARKER13)
frame.add_data("payload", len(pay))
frame.add_derived("crc", 16)
frame.add_stage_over(STAGE_CRC16, "payload", "crc")

burst = Segment(
    type="dsss", fs=4e6, sps=4, seed=1,
    snr=10.0, snr_mode="esno",            # data-symbol Es/N0 (see below)
    acq_code=acq, acq_reps=4,             # preamble: code A x 4
    data_code=dat,                        # payload spread: code B
    frame=frame, data=pay,                # one burst of the data source
    delay_samples=(2_000, 10_000),        # arrival jitter before each burst
    off_samples=(4_000, 12_000),          # trailing gap, min 4k samples
    repeats=5,                            # -> a 5-burst train
)
comp = Composer([burst])
x = comp.compose()

This page is the one home for everything type="dsss" means; the other guide pages carry a summary and link here.

The frame — acq_code / acq_reps / sync / crc — is not DSSS-specific: the same four describe an unspread type="bits" waveform, and the layout comes from one descriptor either way. See Framing. What is DSSS's own is everything below: the second code, the chip clock, and the Es/N0 that refers to the outer data symbol.


The burst anatomy

A dsss segment is one complete burst honouring the BurstDemod/BurstDespreader frame contract:

[ acq_code x acq_reps | (sync | payload | crc16(payload)) ^ data_code ]
   unmodulated preamble          every frame bit spread by code B
  • The preamble is acq_code repeated acq_reps times, transmitted unmodulated — the coherent pull-in target the receiver's acquisition correlates against.
  • The frame is sync | payload | CRC-16 (CCITT, over the payload bits — the same doppler.wfm.crc16 kernel the demod validates), each bit XOR-spread by the full data_code. The sync word and the CRC are optional fields of the frame description; a frame with neither is the payload alone.
  • Codes are plain 0/1 arrays of any length — no 2^n - 1 restriction — so the geometry matches whatever the receiver expects. The payload is a data source (keyword data=; JSON key "data"): one burst per data_len chunk, the whole of it when data_len is 0.
  • sps is samples per chip; the burst's span is intrinsic (n_chips x sps samples a burst), so num_samples is derived, a count given beside it is refused, and a replay derives it again. (A dsss source inside a multi-source sum() keeps the segment's explicit num_samples.)

Either half may be absent, not both. A preamble alone (acq_code, no sync, no payload) is a valid burst, the stimulus for an acquisition test; a frame with no preamble is valid too. A frame with no data_code to spread it, a burst with neither half, and a continuous stream with no data_code are refused, each naming the code to give:

>>> from doppler.wfm import Composer, field_bits
>>> pre = Composer(type="dsss", sps=2, acq_code=field_bits("pn:31:5"),
...                acq_reps=3)
>>> len(pre.compose()) == 3 * 31 * 2           # the preamble, and only it
True
>>> try:
...     Composer(type="dsss", sps=2, num_samples=64)
... except ValueError as exc:
...     print(str(exc).split(":")[0])
a dsss burst has nothing to send

Es/N0 that means what the receiver measures

snr_mode="esno" (and auto) targets the Es/N0 of the outer data symbol — len(data_code) chips × sps samples — which is the number a despreader's data-aided estimator recovers. The 10*log10(sf*sps) spreading conversion happens internally. This matters because a hand-spread bits pattern gets the chip as its symbol: the same --snr 10 would land 10*log10(sf) dB apart between the two spellings. The payload is BPSK, so ebno and esno coincide. (General SNR model: Levels & SNR.)

Random placement, deterministically

repeats=5 plays the segment five times back-to-back; each instance is delay | burst | gap (see Scenes):

  • delay_samples=(lo, hi) re-draws per instance — per-burst arrival jitter. off_samples=(lo, hi) re-draws per instance — inter-burst spacing, with lo the guaranteed minimum gap. (Spacing between bursts composes as off(k) + delay(k+1).)
  • The AWGN is fresh per instance (bursts never share a noise realization) while the signal — codes, payload, code phase — is fixed.
  • Every draw is a deterministic hash, so to_json()/--record stores the ranges and a replay is byte-identical.

Gaps carry the noise floor

By default the delay and trailing gaps are not silent: every source's additive-AWGN term keeps running through them — the same stream, the same power — so the inter-burst region is the channel, exactly what an acquisition/CFAR front-end needs to see for honest threshold tuning (gh-409). A clean scene (no AWGN anywhere) still renders exact-zero gaps, and gap_noise="off" restores hard zeros per segment:

floor = 10 ** (-(10.0 - 10 * np.log10(25 * 4)) / 10)   # esno → over-fs power
gap = x[-4000:]                                        # inside the last gap
assert abs(float(np.mean(np.abs(gap) ** 2)) - floor) / floor < 0.2

On the CLI that switch is --gap-noise, and the difference is measurable in the gap itself — the noise floor at 10 dB SNR, or exact zeros:

wfmgen --type bpsk --snr 10 --count 2048 --off 1024 -o gap-auto.cf32
wfmgen --type bpsk --snr 10 --count 2048 --off 1024 --gap-noise off -o gap-off.cf32
python3 -c "
import numpy as np
for f in ('gap-auto.cf32', 'gap-off.cf32'):
    g = np.fromfile(f, dtype=np.complex64)[2048:]   # the trailing gap only
    print(f, 'gap power', round(float(np.mean(np.abs(g) ** 2)), 4))"
#   gap-auto.cf32 gap power 0.1036
#   gap-off.cf32 gap power 0.0

Use off when a downstream tool defines a burst by "where the samples are non-zero"; leave it on auto for anything that has to pick the burst out of a channel, which is the case the default is built for.

Ground truth for free

The engine knows every drawn instance timing, and the SigMF sidecar emits one annotation per burst instance at the exact rendered position — so a detector can be scored against the capture without ever walking it:

import json

meta = json.loads(comp.to_sigmf(sample_type="cf32", fs=4e6))
starts = [int(a["core:sample_start"]) for a in meta["annotations"]]
assert len(starts) == 5                     # one per instance, in order

Decoding the train back — acquisition over the whole capture, the burst held across block boundaries, every frame checked — is the DsssBurstReceiver walkthrough.

The same burst on the other two faces

The JSON scene carries the same keys (codes as Field strings, ranges as pairs, "repeats": 5; "gap_noise"/"delay_samples" only when non-default), and on the command line a train of the same shape is one command. Here every run of bits is a Field rather than the random arrays above:

wfmgen --type dsss --fs 4e6 --sps 4 --seed 1 --snr 10 --snr-mode esno \
       --acq-code 'pn:127:7*4' --data-code pn:31:5 \
       --sync 1111100110101 --data pn:200:9 \
       --delay 2000:10000 --off 4000:12000 --repeats 5 \
       --record train.json -o train.cf32

# the record is the resolved scene, and it replays byte for byte
wfmgen --from-file train.json -o replay.cf32
python3 - <<'EOF'
import pathlib
import sys

a = pathlib.Path("train.cf32").read_bytes()
b = pathlib.Path("replay.cf32").read_bytes()
sys.exit(a != b)
EOF

--file-type sigmf writes the annotated sidecar. The three faces render byte-identically, and the DsssBurstReceiver walkthrough asserts it on a train sent from a frame description: the command line, a scene and Python objects, then decoded and checked against that description.


Continuous asynchronous DSSS

A burst is a bounded frame with an integer number of chips per data symbol (one bit spread by exactly one code period). Some links instead run a continuous stream: the spreading code repeats forever and the data rides it at a symbol rate that is independent of the code epoch — so the chips per symbol are non-integer and symbol edges land mid-code:

chip[i] = code[i mod code_len] ^ data[floor(i / chips_per_symbol)]

Adding symbol_rate= (Hz) to a dsss source selects this mode. There is no preamble/sync/CRC frame — only the spreading code and the outer data clock:

import numpy as np
from doppler.wfm import Composer, Segment

fs, spc, chip_rate = 6.138e6, 2, 3.069e6
code = np.array([1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1], np.uint8)

stream = Segment(
    type="dsss", fs=fs, sps=spc, seed=1,
    symbol_rate=2700.0,          # data clock, independent of the chip clock
    data_code=code,              # spreading code; repeats forever
    snr=10.0, snr_mode="esno",   # Es/N0 of the 2700 bps data symbol
    num_samples=40_000,
)
x = Composer([stream]).compose()

cps = (fs / spc) / 2700.0        # chips per symbol
assert abs(cps - 1136.667) < 1e-3        # non-integer: the asynchronicity

Here sps is samples per chip (as for a burst), so the chip rate is fs / sps and chips_per_symbol = (fs / sps) / symbol_rate — 1136.667 at these numbers, spanning 1137, 1137, 1136 chips in a repeating cycle with boundaries falling inside a code period. Unlike a burst, the stream has no intrinsic length, so num_samples (--count) is honoured verbatim.

Three data sources

The data modulating the code has three legitimate origins, selected the same way on every face:

source selector what it emits
PRBS (default) — bits from the source's seeded PN — endless, and a receiver regenerates them to score BER
code-only dsss_code_only=True (--code-only) constant bit 0 → the pure spreading code, +code polarity, no data transitions
data data= (--data, --data-from-file) a data source, one bit per data symbol, sent once

The default PRBS is the useful one for a stream with no finite truth array: the data is a pure function of (pn_poly, seed, pn_length, lfsr), so a scorer regenerates exactly the transmitted bits — chip i carries data symbol floor(i / chips_per_symbol), and that bit is the floor(i/cps)-th PN output. On a clean capture the chip-centre sample signs match bit-for-bit:

from doppler.wfm import PN, mls_poly

clean = Segment(
    type="dsss", fs=fs, sps=spc, seed=1, symbol_rate=2700.0,
    data_code=code, snr=100.0, snr_mode="fs", num_samples=40_000,
)
xc = Composer([clean]).compose()

L = 15                                    # default --pn-length (register size)
bits = PN(poly=mls_poly(L), seed=1, length=L).generate(64)
i = np.arange(len(xc) // spc)             # one entry per chip
sym = np.floor(i / cps).astype(int)
chip = code.astype(int)[i % len(code)] ^ bits[sym]     # transmitted chip bit
centres = i * spc + spc // 2
assert np.array_equal(np.sign(xc[centres].real).astype(int), 1 - 2 * chip)

(--seed-advance all makes the per-epoch signal seed seed + epoch, so a long multi-epoch scorer must track the epoch; the safe scoring modes are the default none and noise. Code-only and payload are seed-independent.)

Es/N0 references the data symbol, correctly

snr_mode="esno"/auto targets the outer data symbol, which spans fs / symbol_rate samples — not sf * sps. The referencing SSOT takes fs so a non-integer symbol span is exact (a truncated "effective spreading factor" would silently misplace the noise floor). Nothing to set; it is picked up from symbol_rate.

The CLI face

wfmgen --type dsss --fs 6138000 --sps 2 --seed 1 \
       --symbol-rate 2700 --data-code 111001010110011 \
       --snr 10 --snr-mode esno --count 40000 \
       --record cont.json -o cont.cf32

# The same stream with the data switched OFF -- the pure repeating code,
# which is what you correlate against when bringing a receiver up.
wfmgen --type dsss --fs 6138000 --sps 2 --seed 1 \
       --symbol-rate 2700 --data-code 111001010110011 --code-only \
       --snr 10 --snr-mode esno --count 40000 -o code-only.cf32

--code-only selects code-only (a scene's "code_only": true); a supplied --data/--data-from-file selects a data source. Incompatible combinations are rejected (exit 2), not silently ignored: --symbol-rate with the burst-frame flags (--acq-code, --sync, --crc), --code-only together with a data source, --symbol-rate without --data-code, a non-positive --symbol-rate, and a chip rate fs / sps below --symbol-rate. A stream sends at least one chip per data symbol, so the default --fs 1.0 (a normalised rate) with a --symbol-rate in Hz is refused, naming the three values; give the real --fs. The old spelling --data none is refused naming --code-only: --data is now a frame's data source.

SigMF distinguishes the two modes

Both modes carry the "dsss" core:label, so the sidecar adds a wfmgen:symbol_rate key for a continuous source (a burst omits it), and wfmgen:data: "none" for a code-only stream — enough for a scorer to know the outer symbol clock and how the code is (un)modulated:

import json

meta = json.loads(Composer([stream]).to_sigmf(sample_type="cf32", fs=fs))
assert meta["annotations"][0]["wfmgen:symbol_rate"] == 2700

A continuous stream has no defined end, so --file-type sigmf (whose sidecar is written after the capture) requires a finite --count, not --continuous.


A burst train in a Plan

A burst train prepares: repeats, ranged gaps and delay, and a bundled noisy source all go into a Plan. A ranged per-source freq, snr, level or f_end, or a ranged num_samples, raises ValueError at prepare(), because each would invalidate the cached render. The DSSS BER Monte Carlo sweeps one.


Coded frames — --frame FILE

A coded frame is a description, and --frame FILE is how wfmgen takes one. --acq-code, --sync and --crc spell one frame, the common [preamble × reps | sync | payload | crc]; anything else — a marker, an outer code, a randomiser, an interleaver, an inner code, a field of your own — is a list of fields in wire order and a list of stages, each naming the span of fields it covers.

The spans are the point. They are not a chain of filters applied to "the frame": a marker, a preamble and a sync word are things a receiver finds by correlation, so they must look the same in every frame, and the stages that scramble or code the data have to reach past them. A chain gets three of those boundaries right and the fourth wrong, in the direction that still decodes against a receiver of your own construction and syncs to nothing.

The file is exactly what a scene's "frame" key holds, and --record writes it there, so --from-file rebuilds a coded capture byte for byte.

This is block interleaving, not I/Q interleaving

Everywhere else in these guides, interleaved describes the sample layout on disk — raw files store I, Q, I, Q, … and --symbols-file reads that layout back. That is a memory format and has nothing to do with this section.

An interleave stage is a block interleaver over bits: it permutes the frame's data group before transmission so that a burst of channel errors arrives spread across the outer code's codewords instead of destroying one of them. It changes where errors land and never touches the sample layout. The two meanings share a word and no code.


A CCSDS CADU

A 223-octet Transfer Frame behind the 0x1ACFFC1D marker, with the outer code, the randomiser and the inner code — no preamble and no sync word — is a CADU. It is a configuration, not a mode wfmgen switches into:

cadu.json
{
  "fields": [
    {"name": "asm", "spec": "0x1ACFFC1D"},
    {"name": "payload", "spec": "pn:1784:11"},
    {"name": "rs_parity", "bits": 256, "derived_by": 1}
  ],
  "stages": [
    {"kind": "rs", "first_field": 1, "n_fields": 2, "depth": 1},
    {"kind": "randomise", "first_field": 1, "n_fields": 2, "depth": 1},
    {"kind": "conv", "first_field": 0, "n_fields": 3,
     "emit_num": 2, "emit_den": 1}
  ]
}
wfmgen --type bits --modulation bpsk --frame cadu.json \
       --sps 1 --record cadu-run.json -o cadu.cf32

# the record carries the description, and replays it byte for byte
wfmgen --from-file cadu-run.json -o replay.cf32
python3 - <<'EOF'
import pathlib
import sys

a = pathlib.Path("cadu.cf32").read_bytes()
b = pathlib.Path("replay.cf32").read_bytes()
sys.exit(a != b)
EOF

The run is exactly one frame, 4144 samples: 32 marker bits plus a 2040-bit data group (223 + 32 octets under RS(255,223)) is 2072, doubled by the inner code, at one sample per symbol. A frame of fixed bits is a finite source of one frame, sent once: it sets the run's length, so --count beside it is refused. The same frame again is --repeats N, a gap after it --off, and different frames are more data: give the description a data:LEN field and a data source fills it, frame by frame.

The gallery page renders what this repairs and what it refuses: A CCSDS CADU, as a Frame Description.

The file, key by key

A field is its bits or a stage's output:

Key Meaning
name What a receiver asks for when it slices a capture; optional
spec Its bits, as a Field: 0x1ACFFC1D, 10110, pn:1784:11, *REPS
bits A DERIVED field's length — a CRC trailer, the outer code's parity
derived_by The stage that fills a derived field: that stage's index plus one

A stage is a kind and the fields it covers, listed in the order the stages are applied:

Key Meaning
kind crc16, rs, randomise, interleave, conv — or a number from 0x1000 up, your own kind
first_field, n_fields The span it covers, by index; a derived field it fills is the last field of that span
depth rs: codewords interleaved in the codeblock (1 2 3 4 5 8). randomise: which generator — 1 is 131.0-B-6 §10.4.1's 131071-bit sequence, 2 §10.4.2's legacy 255-bit one. interleave: rows
unit_bits interleave: bits per permuted unit — 8 behind an rs stage, because RS is a code over GF(256)
emit_num, emit_den A stage that emits a new stream (conv is 2, 1). It must cover the whole frame, and there is at most one

The two randomisers are not interchangeable on the air: only the matching receiver derandomises a given waveform, which is why the stage carries which one. An rs stage's depth is intrinsic to the codeblock and is not an interleave stage, which is a permutation applied over whatever span it is given — the two share a name and no implementation. See Interleaving for the transform and the frame-error rates that put a number on it, and The FEC Receive Half for the decoder.

What is refused

A description is the whole frame, so the flags that spell the common frame are refused beside it rather than silently dropped: --sync, --crc and — on an unspread type — --acq-code. A data source is not one of them: --data fills the description's own data:LEN field. A frame whose stages cannot run over the spans they are given is refused before anything is generated, with the reason, never padded: an rs stage needs exactly 223 × depth octets (virtual fill is not implemented), and an interleave stage a whole number of depth × unit_bits units.

# 5 rows over 1784 + 256 = 2040 bits, 8 bits a unit: 2040 / 40 divides
python3 - <<'PY'
import json
frame = json.load(open("cadu.json"))
frame["stages"].insert(2, {"kind": "interleave", "first_field": 1,
                           "n_fields": 2, "depth": 5, "unit_bits": 8})
json.dump(frame, open("cadu-ilv.json", "w"))
frame["stages"][2]["depth"] = 2   # 2040 / 16 does not divide
json.dump(frame, open("bad.json", "w"))
PY
wfmgen --type bits --modulation bpsk --frame cadu-ilv.json \
       --sps 1 -o cadu-ilv.cf32
! wfmgen --type bits --frame bad.json -o bad.cf32   # refused, as documented

In a scene, and from Python

A scene carries the same description under a source's frame key, and --frame FILE reads exactly that value. The description is the whole frame, so the common frame's keys do not belong beside it: a sync or an unspread preamble is refused on every face. A data source ("data", data=) is not a second spelling of anything in it — it fills the description's data:LEN field, when it has one.

frame.json
{
  "version": 1,
  "segments": [
    {
      "type": "bits", "fs": 1e6, "sps": 4, "modulation": "bpsk",
      "snr": 100.0, "snr_mode": "fs",
      "frame": {
        "fields": [
          { "name": "hdr", "spec": "0101110001011100" },
          { "name": "payload", "spec": "101010101010101010101010" },
          { "name": "crc", "bits": 16, "derived_by": 1 }
        ],
        "stages": [
          { "kind": "crc16", "first_field": 1, "n_fields": 2 }
        ]
      }
    }
  ]
}

hdr is a field of the caller's own bits at a position the caller chose, outside the CRC's cover. Spelled as the common frame instead — hdr as its sync word — it is the same frame, and the two are byte-identical, because --sync/--crc build their frame through dp_wfm_frame_fixed(), which is a description too:

wfmgen --from-file frame.json -o from_desc.cf32
wfmgen --type bits --data 101010101010101010101010 \
       --sync 0101110001011100 --crc crc16 \
       --fs 1e6 --sps 4 --snr 100 -o from_flags.cf32
python3 - <<'EOF'
import pathlib
import sys

a = pathlib.Path("from_desc.cf32").read_bytes()
b = pathlib.Path("from_flags.cf32").read_bytes()
print("byte-identical" if a == b else "DIFFER")
sys.exit(0 if a == b else 1)
EOF
byte-identical

A derived field must name its producer: derived_by

A field with a declared length and no bits — a CRC trailer, a block of check symbols — is derived, and in JSON it must say which stage produces it: derived_by is the stage's index plus one, so 1 means stage 0. The +1 is what makes a zero mean "the caller supplies this field" rather than "the output of stage 0".

Omit it and the scene is refused, naming what is missing — the geometry is decided in one place, so the CLI, the C API and Python all say the same thing:

error: this frame description does not lay out: a field that declares a
length but supplies no bits is DERIVED and must name the stage that
fills it (`derived_by` = the stage's index plus one), …

The C builder cannot make this mistake: dp_wfm_frame_add_stage() takes the cover by name and wires the producer itself.

From C, wfm_source_t.frame takes a wfm_frame_desc_t *, built by name with dp_wfm_frame_add_field() / dp_wfm_frame_add_derived() / dp_wfm_frame_add_stage(); the gallery page is a whole program. From Python, a source's frame= takes the description as a FrameDesc (or a Frame), built by name as in C. A str is refused: a description written as JSON is the scene's "frame" key, which Composer.from_json() reads with the same C code wfmgen --from-file runs. The source keeps its own copy, and frame= is an input: read it back from Composer.to_json().

import numpy as np

from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment, field_bits

d = FrameDesc()  # fields in wire order, by name
d.add_field("hdr", field_bits("0101110001011100"))  # a header of your own
d.add_field("payload", field_bits("101010101010101010101010"))
d.add_derived("crc", 16)  # 16 bits a stage will fill
d.add_stage_over(STAGE_CRC16, "payload", "crc")  # the span it covers

common = {
    "type": "bits",
    "fs": 1e6,
    "sps": 4,
    "modulation": "bpsk",
    "snr": 100.0,
    "snr_mode": "fs",
}
framed = Segment(**common, frame=d)
x = np.asarray(Composer([framed]).compose())

The whole script, src/doppler/examples/wfmgen_frame_demo.py, checks that the frame reaches the samples and that the record rebuilds them; its C twin is native/examples/wfmgen_frame_demo.c.

A data field from Python is add_data(name, LEN): the field the text data:LEN describes, whose bits the source's data= supplies one LEN-bit chunk per frame, padded from fill= at the end. add_field takes bits, and a data field has none until it is sent, so it has its own method. In C it is dp_frame_add_data().

import numpy as np
from doppler.wfm import STAGE_CRC16, Composer, FrameDesc, Segment

barker13 = np.array([1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], np.uint8)
desc = FrameDesc()
desc.add_field("sync", barker13)
desc.add_data("payload", 24)  # data:24, filled per frame
desc.add_derived("crc", 16)
desc.add_stage_over(STAGE_CRC16, "payload", "crc")

payload = np.random.default_rng(0).integers(0, 2, 60, dtype=np.uint8)
seg = Segment(type="bits", sps=1, modulation="bpsk", frame=desc,
              data=payload, fill=np.array([1, 0], np.uint8))
x = Composer([seg]).compose()
# 60 bits are two 24-bit frames and a third padded with fill
assert len(x) == 3 * (13 + 24 + 16)

A description goes back out through to_json() and --record, so a scene read and re-written keeps its frame. A stage whose kind has no kernel — a number from WFM_STAGE_USER (4096) up with no wfm_frame_ops_t entry — is refused, never skipped.

On --type dsss

The description is the spread frame. The acquisition preamble stays on the command line, unspread and outside every stage, because it is what a receiver correlates raw chips against:

spread.json
{
  "fields": [
    {"name": "sync", "spec": "10110"},
    {"name": "payload", "spec": "0xb25a"},
    {"name": "crc", "bits": 16, "derived_by": 1}
  ],
  "stages": [
    {"kind": "crc16", "first_field": 1, "n_fields": 2},
    {"kind": "randomise", "first_field": 1, "n_fields": 2, "depth": 1},
    {"kind": "interleave", "first_field": 1, "n_fields": 2, "depth": 4}
  ]
}
wfmgen --type dsss --acq-code '1101100*4' --data-code 10110 \
       --frame spread.json --sps 2 -o dsss.cf32

A train sent from a description on all three faces, decoded by DsssBurstReceiver and checked against the same description, is the DsssBurstReceiver walkthrough.

Everything else type="dsss" means — the second code, the chip clock, the Es/N0 that refers to the outer data symbol — is in DSSS bursts.


Clock Doppler — a source that is moving

A carrier offset moves the carrier. Doppler moves the clock.

That distinction is the whole page. --freq 12000 puts a signal 12 kHz up the band and leaves its symbol rate exactly where it was. A real emitter closing at 7.5 km/s does something else: it rescales the received time base, so the carrier, the symbol rate and the chip rate all move together, by the same fraction. A receiver's timing loop sees an error that a carrier-only offset never produces — which is precisely the error you wanted to test.

--doppler is that second thing. It is given in ppm — parts per million of time-base scale — because that one number is what moves every clock at once:

\[ f_\text{rx} = f_\text{tx}\,(1 + d\times10^{-6}), \qquad R_\text{sym,rx} = R_\text{sym,tx}\,(1 + d\times10^{-6}) \]

The four flags

Flag Unit What it sets
--doppler PPM[:PPM] ppm Time-base scale. 0 (default) builds no channel at all.
--doppler-rate PPM_S[:PPM_S] ppm/s Linear ramp on the above — an accelerating pass.
--carrier-hz HZ Hz The RF carrier the ppm is referred to, which is what turns a time-base scale into a coherent carrier rotation.
--doppler-lifetime L — per_instance (default) or persist. See below.

A LEO pass at S-band is a couple of tens of ppm:

# 25 ppm closing on a 2.4 GHz carrier ≈ 60 kHz of carrier shift, and a
# symbol rate 25 ppm fast along with it.
wfmgen --type qpsk --fs 1e6 --sps 4 --snr 12 --count 20000 \
       --doppler 25 --carrier-hz 2.4e9 -o pass.cf32
ls -l pass.cf32

--carrier-hz is independent of the two ppm figures, and 0 is a real answer rather than an unset field: it warps the clock and applies no carrier rotation, which is the right model for a baseband recording whose carrier was already stripped. Set it when you want the rotation that accompanies the warp.

--doppler-rate ramps the offset as the pass progresses:

# A pass that sweeps through zero: -20 ppm, closing at 4 ppm/s.
wfmgen --type bpsk --fs 1e6 --sps 8 --count 20000 \
       --doppler -20 --doppler-rate 4 --carrier-hz 2.2e9 -o accel.cf32
ls -l accel.cf32

The rate is per second of stream, not per second of on-time

The channel keeps running through a segment's gaps, on the noise floor the gap already carries. An emitter does not stop moving because its burst ended, so a two-burst scene with a 10 ms gap resumes 10 ms further along the pass — not where it left off. Skip the gaps and doppler_rate would quietly mean "ppm per second of transmission", which is not a geometry anything flies.


Ranged, like freq and snr

A pass is a distribution, not a number. Both ppm fields take the same LO:HI form as --freq/--snr/--level, drawn uniformly on each repeat instance:

# Eight bursts, eight geometries — a Monte Carlo over the pass.
wfmgen --type qpsk --fs 1e6 --sps 4 --snr 10 --count 4096 --off 2000 \
       --repeats 8 --doppler 2:9 --doppler-rate 0.1:0.5 \
       --carrier-hz 1.5e9 --file-type sigmf -o sweep
ls sweep.sigmf-meta

The draw is a hash of (seed, repeat index, segment, source, field), so --record stores the span and --from-file replays the identical sequence of draws. What each instance actually flew is recorded separately, per annotation, in the SigMF sidecar:

one annotation from sweep.sigmf-meta
{
  "core:sample_start": 6096,
  "core:sample_count": 4096,
  "wfmgen:doppler_ppm": 2.1624832257080335,
  "wfmgen:doppler_rate_ppm_s": 0.4429440692793868,
  "wfmgen:carrier_hz": 1500000000,
  "wfmgen:doppler_lifetime": "per_instance"
}

Those keys appear only when a channel was actually built, so a scene without Doppler writes exactly the sidecar it always did.


The lifetime is a choice

--repeats 8 plays one segment eight times. Does the emitter restart its pass on each burst, or fly straight through?

  • per_instance (default) — the channel dies with each instance and the geometry restarts. This is the repeated-trial shape: eight independent looks at a target, which is what composes with a ranged --doppler re-drawn per instance.
  • persist — one continuous pass carries across the segment's gaps and repeat instances. This is the only lifetime under which --doppler-rate accumulates over a multi-burst scene: burst 8 is further along than burst 1 by the full elapsed stream time.
# One pass, eight bursts of it — the offset has ramped by the last one.
wfmgen --type bpsk --fs 1e6 --sps 8 --count 4096 --off 4000 --repeats 8 \
       --doppler 5 --doppler-rate 200 --carrier-hz 2e9 \
       --doppler-lifetime persist -o onepass.cf32
ls -l onepass.cf32

A persisting channel is keyed by (segment, source) position, which is the only source identity a scene file carries. So it persists across a segment's repeat instances and their gaps, and two different segments each get their own pass — sharing one pass between segments would need a declared source id the format does not have.


In JSON and in Python

Both fields are ordinary source keys, scalar or [lo, hi]:

pass.json
{
  "version": 1,
  "segments": [
    {
      "type": "qpsk", "fs": 1e6, "sps": 4, "snr": 12,
      "num_samples": 20000,
      "doppler": [2, 9],
      "doppler_rate": 0.4,
      "carrier_hz": 2.4e9,
      "doppler_lifetime": "persist"
    }
  ]
}
wfmgen --from-file pass.json -o from_json.cf32
ls -l from_json.cf32

and the same names are Segment/Synth keyword arguments, with a ranged field spelled as a tuple:

from doppler.wfm.compose import Composer, Segment

scene = Composer(
    [
        Segment(
            "qpsk",
            fs=1e6,
            sps=4,
            snr=12.0,
            num_samples=20000,
            doppler=(2.0, 9.0),
            doppler_rate=0.4,
            carrier_hz=2.4e9,
            doppler_lifetime="persist",
        )
    ]
)
x = scene.compose()
print(x.shape, x.dtype)

Plan serves a Doppler source

Plan caches each source's clean on-time once and re-weights it. A Doppler channel cannot live in that cache, for two reasons that were measured against compose():

  • A burst depends on what came before it. The channel is a stateful resampler and it runs through the gaps, so a trailing gap carries the burst's ring-out and a leading delay_samples advances the geometry before the burst starts.
  • The noise sits inside the channel. compose() puts a bundled source's AWGN inside the synth, so the channel resamples it too.

So the cache holds the signal before the channel, and render() runs the channel over it at render time, through the composer's own renderer. The delay, the gaps, the noise and (for persist) the earlier segments are all in hand when it runs, and the result is compose() to the bit:

import numpy as np

from doppler.wfm import prepare
from doppler.wfm.compose import Composer, Segment

kw = dict(fs=1e6, sps=4, num_samples=4096, carrier_hz=2.2e9)
scene = Composer(
    [
        Segment(
            "bpsk",
            snr=12.0,
            doppler=(2.0, 9.0),  # a ranged Doppler, drawn per repeat
            off_samples=512,
            delay_samples=128,
            repeats=3,
            **kw,
        )
    ]
)
plan = prepare(scene.to_json())
assert np.array_equal(plan.render(), scene.compose())

# A Monte-Carlo trial is a seed: it redraws the Doppler as well as the noise,
# and each seed is exactly the scene composed with that seed.
print(len(plan.render(seed=101)), len(plan.render(seed=202)))

Two things are worth knowing. A render of a Doppler scene is no longer a pure re-weight of the cache: each Doppler source runs one resampler pass over its timeline, on top of the synthesis the cache still saves. That pass is what render() parallelises: a segment's repeats render concurrently, or its sources do when a persist channel chains the repeats, so it costs wall-clock mainly for a single burst from a single source. And one shape is still refused, with a ValueError: a background=True source with Doppler, because the background fold sums those sources into one composite before any channel could run, and a channel is per source.


See also

  • Scenes — putting these waveforms in time.
  • Gallery: wfmgen — the spectra and constellations behind each type.
  • Writing captures — sample types, file types, endianness, output targets.