Power Spectra & Measurements¶
doppler computes spectral metrics through one pipeline:
A single FFT-and-window-and-average implementation — the PSD estimator in
doppler.spectral — produces the power spectrum, and every measurement analyzer
in doppler.measure (ToneMeasure, IMDMeasure, NPRMeasure) consumes that
same averaged spectrum. There is no second copy of the window→FFT→power maths.
This guide is the usage walk-through. For the metric equations and IEEE Std 1241 conventions see the Measurement Suite design guide; for the full method/attribute reference see the spectral and measure API pages.
The 30-second version
import numpy as np
from doppler.spectral import PSD
from doppler.measure import ToneMeasure
fs, n = 100e6, 1 << 14
x = np.cos(2 * np.pi * 10.017e6 * np.arange(8 * n) / fs).astype(np.float32)
# display spectrum: 8 averaged segments, one-sided, in dBFS
w = PSD(n=n, fs=fs, window="kaiser", beta=12.0)
w.accumulate_real(x)
psd = w.psd_db() # averaged power spectrum, dB
# ADC/tone metrics from the *same* averaged-spectrum engine
r = ToneMeasure(n=n, fs=fs, dynamic_range_db=90.0).analyze(x)
r.enob, r.sfdr_dbc, r.fund_dbfs # named ToneMetrics result
The averaging PSD — PSD¶
PSD is a stateful Welch-method (averaging) power-spectral-density estimator. It
owns a window, a zero-padded FFT and a per-bin averager; you feed it frames and
read the averaged spectrum.
Create¶
from doppler.spectral import PSD
w = PSD(
n=4096, # segment / window length (samples)
fs=100e6, # sample rate (Hz)
window="kaiser", # "hann" or "kaiser"
beta=12.0, # Kaiser shape (ignored for Hann)
pad=2, # zero-pad factor → nfft = next_pow2(n * pad)
full_scale=1.0, # amplitude that reads 0 dBFS in the dB getters
bits=0, # bits>0 sets full_scale = 2**(bits-1) (ADC dBFS)
mode="mean", # "mean" | "exp" | "maxhold" | "minhold"
alpha=0.1, # EMA factor (exp mode only)
)
w.n # 4096 — segment length (sets the resolution bandwidth)
w.nfft # 8192 — zero-padded transform length (sets the bin spacing)
nis the segment length — it sets the resolution bandwidth (rbw ≈ enbw · fs / n). Longer segments resolve closer tones.padzero-pads each segment tonfft = next_pow2(n · pad). Padding interpolates the spectrum (finer bin spacing, smoother peaks) without improving resolution — it does not sharpen two close tones, it just draws the lobe with more points.full_scale/bitsset the 0-dBFS reference for the dB getters. With the defaultfull_scale=1.0, a unit-amplitude tone peaks at 0 dB. For an ADC, passbits=B— it setsfull_scale = 2**(B-1)so codes read directly in dBFS.bitsis the single definition of that conversion: the measurement analyzers andSpecantake the samebits/full_scaleand read the reference back from this core.
Feed data — real or complex, segmented and averaged¶
A capture longer than n is split into floor(len / n) non-overlapping
segments; each is windowed, (zero-padded,) FFT'd and folded into the running
average. Pass more data to average more segments — averaging trades frequency
snapshots for a smoother, lower-variance noise floor.
import numpy as np
# a running capture: cf32 frames (complex baseband) plus one real block
fs = 100e6
cf32_frames = [ # each frame: any multiple of n samples
np.exp(2j * np.pi * 3e6 * np.arange(4 * w.n) / fs).astype(np.complex64)
for _ in range(3)
]
real_capture = np.cos(
2 * np.pi * 10e6 * np.arange(8 * w.n) / fs
).astype(np.float32)
# complex baseband (cf32)
for frame in cf32_frames: # each frame: any multiple of n samples
w.accumulate(frame)
# real input (f32) — folded to a one-sided spectrum
w.accumulate_real(real_capture) # averages floor(len/n) segments
w.count # number of segments folded in so far
w.reset() # discard the average; re-seed on next accumulate
Read the spectrum¶
# display spectra (DC-centred, in dBFS w.r.t. full_scale)
psd_db = w.psd_db() # averaged power spectrum, dB (None if empty)
psd_dbhz = w.psd_dbhz() # PSD, dB/Hz (ENBW / fs normalised)
# raw linear power (cg²-normalised; full_scale NOT applied)
two = w.power_twosided() # length nfft, DC-centred
one = w.power_onesided() # length nfft//2 + 1, folded to [0, fs/2]
# band / level statistics
per = w.band_power(np.array([-2e5, -1e5, 1e5, 2e5])) # dB per [lo,hi] band
total = w.total_band_power(np.array([-2e5, -1e5, 1e5, 2e5]))
obw = w.occupied_bw(0.99) # occupied bandwidth, Hz
nf = w.noise_floor() # median dB level
snr = w.snr(-1e5, 1e5) # peak-in-band minus noise floor, dB
sfdr = w.sfdr(min_db=-120.0) # spurious-free dynamic range, dB
All spectra are DC-centred (fftshift), matching find_peaks_f32's
bin → frequency convention, so peaks compose directly:
from doppler.spectral import find_peaks_f32
peaks = find_peaks_f32(w.psd_db(), n_peaks=5, min_db=-60.0)
dB getters vs. linear accessors
psd_db/psd_dbhz/band_power/… apply full_scale (they read in dBFS).
power_onesided/power_twosided return the raw coherent-gain-normalised
linear power — they ignore full_scale. The measurement analyzers consume
the linear accessors and apply their own references, which is why one PSD
engine serves both the display and the metrics.
The measurement suite¶
ToneMeasure, IMDMeasure and NPRMeasure are single-shot in API — you
call analyze(capture) once — but each one drives the same PSD engine
internally, so they consume an averaged spectrum. The capture you pass sets
how much averaging happens:
len(capture) == n→ one segment (a single periodogram).len(capture) == k·n→kaveraged segments → a tighter noise floor and more stable SNR/SINAD/ENOB/NPR.
n is the segment length; pass a longer capture to buy averaging without
changing the resolution bandwidth.
Single-tone ADC metrics — ToneMeasure¶
import numpy as np
from doppler.measure import ToneMeasure
fs, n = 100e6, 1 << 14
# 8 segments worth of capture → 8-way averaged spectrum
x = np.cos(2 * np.pi * 10.017e6 * np.arange(8 * n) / fs).astype(np.float32)
m = ToneMeasure(n=n, fs=fs, n_harmonics=8, dynamic_range_db=90.0)
r = m.analyze(x) # named ToneMetrics result
r.enob, r.sinad, r.sfdr_dbc, r.thd # attribute access
r.fund_freq, r.fund_dbfs, r.worst_spur_freq
# the analyzer's own display spectrum (same window/nfft/average as the metrics):
spectrum = m.spectrum_dbfs(x) # DC-centred dBFS, length nfft
For an ideal B-bit converter, characterise it by quantising through
doppler.cvt.ADC and reading enob ≈ B:
from doppler.cvt import ADC
bits = 12
adc = ADC(bits, 0.0, 0) # 0 dBFS at amplitude 1.0
codes = adc.steps((0.999 * np.sin(2 * np.pi * 1234.567 * np.arange(n) / n))
.astype(np.float32)).astype(np.float32)
r = ToneMeasure(n=n, bits=bits).analyze(codes) # bits sets dBFS
assert abs(r.enob - bits) < 0.3
Two-tone intermodulation / TOI — IMDMeasure¶
from doppler.measure import IMDMeasure
# a real two-tone capture at 9 and 11 MHz (equal amplitude)
t = np.arange(8 * n) / fs
two_tone_capture = (0.4 * np.sin(2 * np.pi * 9.0e6 * t)
+ 0.4 * np.sin(2 * np.pi * 11.0e6 * t)).astype(np.float32)
m = IMDMeasure(n=n, fs=fs, dynamic_range_db=90.0)
r = m.analyze(two_tone_capture) # finds the two strongest tones automatically
r.imd3_dbc, r.imd2_dbc, r.toi_dbfs # third/second-order products & intercept
r.f1, r.f2, r.imd3_lo_freq, r.imd3_hi_freq
Notched-noise NPR — NPRMeasure¶
from doppler.measure import NPRMeasure
m = NPRMeasure(n=n, fs=fs, bits=bits)
# band/notch geometry (Hz) is an analyze() argument, so one estimator sweeps notches
r = m.analyze(codes, active_lo=1e6, active_hi=49e6,
notch_lo=24e6, notch_hi=26e6, guard_hz=0.5e6)
r.npr_db, r.inband_psd_dbfs, r.notch_psd_dbfs
Natural parameters — the Specan¶
PSD and the measurement analyzers speak DSP parameters — segment length
n, Kaiser beta, zero-pad. A spectrum-analyzer operator speaks instrument
parameters — center, span, RBW, reference level. doppler.analyzer.Specan
is the C-first bridge: it composes the DDC
tuner/decimator and the same PSD PSD core, takes the instrument knobs, and
emits a ready-to-plot dB display band.
import numpy as np
from doppler.analyzer import Specan
from doppler.spectral import find_peaks_f32
sa = Specan(fs=2.048e6, span=200e3, rbw=500.0, center=0.0)
sa.n, sa.beta, sa.rbw # the DSP grid it derived from span/rbw
# a real cf32 capture (30 kHz tone in light noise), split into blocks
k = np.arange(1 << 16)
iq_stream = np.array_split(
(np.exp(2j * np.pi * 30e3 * k / 2.048e6)
+ 0.01 * (np.random.standard_normal(k.size)
+ 1j * np.random.standard_normal(k.size))).astype(np.complex64),
16,
)
for chunk in iq_stream: # any cf32 block size
db = sa.execute(chunk.astype(np.complex64))
if db is None: # not enough samples yet
continue
peaks = find_peaks_f32(db, 5, -60.0) # bin i → center + (i−mid)·fs_out/nfft
sa.retune(50e3) # seamless LO retune; no rebuild
The mapping is the one a spec-an makes for you: the span picks the
decimation rate (fs_out = span·1.28), the RBW picks the window length
(coarse) and the Kaiser beta (fine, solved so the window ENBW is the
requested RBW), and the display is cropped to the central ±span/2 band. So you
ask for "500 Hz RBW over a 200 kHz span" and never touch beta or n. This is
the same C object that backs the doppler.specan live
display — see the analyzer API page for the full
reference.
Choosing parameters¶
| Goal | Lever |
|---|---|
| Resolve two close tones | larger n (finer rbw) — not pad |
| Smoother lobes / sub-bin peak reads | pad = 2 (or more) |
| Lower-variance noise floor, stable SNR/NPR | feed more segments (len = k·n) |
| Read ADC codes directly in dBFS | bits = B (sets full_scale=2**(B-1)) |
| Lower sidelobes (catch small spurs) | window="kaiser" with larger beta |
| Peak-hold / min-hold display | mode="maxhold" / "minhold" |
Drive by center / span / RBW, not n/beta |
doppler.analyzer.Specan (above) |
See also¶
- Python: spectral API — full
PSD+ helper reference - Python: measurement API —
ToneMeasurereference - Measurement Suite design guide — metric equations
- Spectral & Measurement API Map — how the PSD
core,
measure, andanalyzer.Specanfit together - Gallery: Measurement Suite and IMD & NPR — rendered end-to-end demos