Skip to content

Measurement Suite — two-tone IMD/TOI & notched-noise NPR

measure IMD/NPR demo

The other two analysers in doppler.measureIMDMeasure (two-tone intermodulation and the third-order intercept) and NPRMeasure (notched-noise Noise Power Ratio) — the standard tests for a converter's large-signal linearity under multi-carrier loading. The companion to the single-tone ADC characterisation page.

What you're seeing

(a) Two-tone IMD spectrum. Two equal tones pass through a weak polynomial nonlinearity. IMDMeasure finds the two fundamentals and integrates the folded intermodulation products over their main lobes — IM2 at f₂−f₁ and IM3 at 2f₁−f₂ / 2f₂−f₁. One analyze() call returns imd2_dbc, imd3_dbc, the product frequencies, and the intercepts.

(b) Third-order intercept. As the two-tone drive rises, the fundamental climbs 1:1 and IM3 climbs 3:1; extrapolating the two slopes to their crossing gives the TOI (toi_dbfs) — the canonical large-signal linearity figure of merit, reported directly so you don't have to fit it yourself.

(c) Notched-noise NPR spectrum. Broadband (≈full-Nyquist) noise with a carved notch is driven into a 10-bit ADC. NPRMeasure averages the in-band PSD and the noise that quantisation + distortion has dumped into the notch; NPR is their ratio (npr_db), with the active-band / notch geometry passed as analyze() arguments plus a guard keep-out around the notch edges.

(d) NPR vs loading — measured vs. the ideal. NPR plotted against RMS loading (the convention; the Gaussian peak runs ~12–13 dB higher), overlaid with the ideal-quantiser curve from ADI MT-005 (Gray–Zeoli granular q²/12 + Gaussian-overload model). At low loading the notch floor is quantisation-limited (NPR climbs 6 dB/octave); past the clipping knee distortion fills the notch (NPR falls). The measured curve tracks the ideal and peaks at the optimal loading — ≈ −13 dBFS RMS, ≈ 52 dB for 10 bits.

Reproduce

python src/doppler/examples/measure_imd_npr_demo.py

The measurement objects

import numpy as np

from doppler.cvt import ADC
from doppler.measure import IMDMeasure, NPRMeasure
from doppler.source import AWGN, LO
from doppler.spectral import FFT

FS = 100e6  # 100 MHz sample rate
N = 1 << 14  # 16384-sample segment (sets the resolution bandwidth)
NAVG = 8  # segments averaged per measurement (Welch's method)
M = NAVG * N  # total capture length fed to analyze()

A2, A3 = 0.02, 0.05  # weak-nonlinearity coefficients (the DUT model)


def two_tone(f1, f2, amp, a2=A2, a3=A3):
    """Two doppler-NCO (`source.LO`) tones through a weak memoryless
    nonlinearity y = x + a2 x^2 + a3 x^3 — the DUT model that creates the
    controlled IM2/IM3 products.  The spectrum backdrop the demo plots comes
    from the analyzer's own `spectrum_dbfs`, not a hand-rolled periodogram.
    The capture spans `M = NAVG * N` so `analyze()` averages `NAVG` segments.
    """
    x = amp * (LO(f1 / FS).steps(M).real + LO(f2 / FS).steps(M).real)
    return (x + a2 * x**2 + a3 * x**3).astype(np.float32)


def notched_noise(rms, active_lo, active_hi, notch_lo, notch_hi, seed=0):
    """Band-limited noise with a carved notch, scaled to an **RMS** level
    `rms` relative to full scale (= 1.0) — the NPR test stimulus.

    doppler-native: white noise from `source.AWGN`, shaped in the frequency
    domain with `spectral.FFT` (forward + inverse).  The band/notch geometry is
    the device-under-test model.  NPR loading is RMS-to-full-scale by
    convention — the Gaussian peak is ~12-13 dB above the RMS (the crest
    factor), so clipping sets in well before the RMS reaches 0 dBFS."""
    k = np.arange(M)
    freqs = np.abs(np.where(k < M // 2, k, k - M)) * (FS / M)  # |Hz| per bin
    keep = (
        (freqs >= active_lo)
        & (freqs <= active_hi)
        & ~((freqs >= notch_lo) & (freqs <= notch_hi))
    )
    white = AWGN(seed, 1.0).generate(M)  # M = NAVG*N complex white samples
    spec = FFT(M, -1).execute_cf32(white) * keep  # FFT, mask band + notch
    x = (FFT(M, 1).execute_cf32(spec.astype(np.complex64)) / M).real
    return (x / np.sqrt(np.mean(x**2)) * rms).astype(np.float32)
# Two-tone IMD / third-order intercept
imd = IMDMeasure(n=N, fs=FS, dynamic_range_db=90.0)
r = imd.analyze(two_tone(9.013e6, 9.637e6, amp=0.35))
r.imd3_dbc, r.imd2_dbc, r.toi_dbfs        # products + intercept (dBFS)
r.imd3_lo_freq, r.imd3_hi_freq            # folded 2f₁−f₂, 2f₂−f₁

# Notched-noise NPR — band/notch edges (Hz) + a guard keep-out are call
# args. The stimulus is quantised by a 10-bit ADC at -12.4 dBFS RMS.
active_lo, active_hi = 1e6, 49e6
notch_lo, notch_hi, guard_hz = 24e6, 26e6, 0.5e6
noise = notched_noise(10 ** (-12.4 / 20), active_lo, active_hi,
                      notch_lo, notch_hi)
codes = ADC(10, 0.0, 0).steps(noise).astype(np.float32)

npr = NPRMeasure(n=N, fs=FS, bits=10)
g = npr.analyze(codes, active_lo, active_hi, notch_lo, notch_hi, guard_hz)
g.npr_db, g.inband_psd_dbfs, g.notch_psd_dbfs

See the design guide for the windowing, main-lobe integration and calibration conventions, and the Python API for the full field list.