Skip to content

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 map ±1.0 → ±max-code by saturating each axis to ±1.0, then truncating toward zero (a plain cast, not round-to-nearest):

--sample-type Map Full-scale code
ci32 clip(v, ±1)·(2³¹−1) ±2 147 483 647
ci16 clip(v, ±1)·32767 ±32 767
ci8 clip(v, ±1)·127 ±127

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 exactly, with no clipping.
  • 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 & containers) inverts the same map, so a float round-trip is exact and an integer round-trip is exact only where it neither clipped nor truncated.

>>> 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 fs for tone/noise/pn, esno for bpsk/qpsk the sensible default

--snr 100 (the default) is cleansnr ≥ 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.

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)