Fingerprint Math

The mathematical specification of the per-device fingerprint module. Implements the ORACLE (Sankhe et al., 2019) impairment chain extended to cover the WiSig / SMoRFFI / 2026 fingerprinting literature.

The shipped implementation factors the math across two surfaces:

  1. Per-device parameter store. FingerprintParams (Pydantic v2 model) and DeviceRegistry (content-addressed cache) live in rfgen.device_fingerprint. The registry maps device_id to a deterministic per-device FingerprintParams draw.

  2. Impairment chain. Each impairment is a separate concrete BaseChannel subclass under rfgen.tx_impairments (LinearCFO, LeesonTXPhaseNoise, TorchSigTXIQImbalance, RappPA, SalehPA, LinearDACQuantizer). Each reads its per-device override from ctx.emitter_meta.extras["fingerprint_params"] and falls back to documented default-prior values when that slot is absent (the fingerprint-fallback contract).

There is no single DeviceFingerprint(BaseChannel) plugin that runs the full chain in one apply() call; the chain is composed by the scene composer in canonical pipeline order (see Composition order below) by dispatching the matching per-transformation concretes. Earlier drafts described a monolithic DeviceFingerprint class with an _sfo / _cfo / _iq_imbalance / _dc_offset / _phase_noise / _pa_nonlinearity / _dac_quantization chain on a single object; the shipped framework keeps the math identical but splits the operations across the per-transformation concretes for parallelism and per-stage observability.

Virtual device draw

A virtual device is a fixed sample from a parameter distribution, identified by device_id (a stable user-supplied identifier; in practice, the DeviceRegistry hashes the parameters via sha256 when callers need a content-addressed key). For a given run:

  1. The orchestrator declares a device population (e.g., 25 LoRa devices, 16 Wi-Fi devices).

  2. DeviceRegistry.draw is called per device_id; each first-time draw derives a sub-seed from the parent generator and the device id, samples each prior uniformly from the configured range, and caches the resulting FingerprintParams instance. Subsequent calls with the same device_id return the cached instance.

  3. Each emission attributed to that device uses the same FingerprintParams instance. The scene composer threads the params into ctx.emitter_meta.extras["fingerprint_params"] so every per-transformation concrete reads identical per-device values across emissions. Different emissions differ only in payload and time-varying realizations (phase noise sample path is regenerated per emission, but its PSD shape parameter is fixed).

  4. Cross-day / cross-receiver splits hold the device population fixed and vary only the channel realization.

import torch
from rfgen.device_fingerprint import DeviceRegistry, FingerprintParams

registry = DeviceRegistry()  # use documented default ranges
rng = torch.Generator().manual_seed(0)
params = registry.draw("device-001", rng=rng)
# params is a frozen FingerprintParams. The scene composer threads
# params.model_dump() through ctx.emitter_meta.extras["fingerprint_params"]
# so every TX-side concrete in tx_impairments.py reads the same per-device
# override.

Composition order

Order is fixed and matters; reordering changes the realized fingerprint signature materially.

#

Stage

Math nature

1

Baseband IQ from emitter (already pulse-shaped)

input

2

Sample-clock offset (resample by 1+ε)

linear

3

Carrier frequency offset

linear (modulation by exp(j2πΔf·t))

4

IQ imbalance

linear (real/imag mixing)

5

DC offset

linear (additive constant)

6

Phase noise multiplier

linear time-varying

7

PA nonlinearity (AM-AM and AM-PM)

memoryless nonlinear

8

DAC quantization

sample-wise quantization

Steps 2–6 are linear. Step 7 is memoryless nonlinear. Step 8 is quantization. The order matches physical hardware: baseband synthesis → DAC → analog mixing → PA → antenna. We invert the DAC/PA order from physical (DAC comes before PA in real hardware).

Why the inversion is a valid simulation approximation at SDR bit depths. At typical SDR DAC resolutions (12–16 bits), the ideal quantization noise floor is SNR_q 6.02b + 1.76 dB below full scale (per IEEE Std 1241-2010 [2]): ≈74 dB for 12-bit and ≈98 dB for 16-bit. PA nonlinearity becomes significant near the compression point, which operates 0–20 dB below saturation. The error introduced by swapping the two stages is proportional to O(quantization_noise × |∂G/∂r|), where ∂G/∂r is the derivative of the AM-AM gain with respect to amplitude. At 12+ bits, quantization noise is more than 70 dB below operating power, making this derivative term negligible; the simulated PA-then-DAC chain and the physical DAC-then-PA chain produce statistically indistinguishable fingerprint features.

When the approximation breaks down. At very low bit depths (6–8 bits, e.g. constrained IoT hardware), quantization noise is only 37–50 dB below full scale. At those depths, the PA sees correlated quantization spurs rather than a clean analog waveform, and the order matters. Simulations targeting fingerprints at 6–8 bits should apply DAC quantization before PA nonlinearity.

Validation target. Generate a signal with b = 12 and p = 2 (Rapp). Apply (PA, DAC) in simulation order; compare spectral regrowth and EVM to (DAC, PA). The two should agree within the quantization noise floor. MATLAB comm.MemorylessNonlinearity [9] provides a reference for both orderings.


Carrier frequency offset (CFO)

The TX local oscillator is offset from nominal by Δf Hz.

\[ y[n] = x[n] \cdot \exp\!\left(j \cdot 2\pi \cdot \Delta f \cdot \frac{n}{f_s}\right) \]
def _cfo(iq: torch.Tensor, cfo_hz: float, sample_rate: float) -> torch.Tensor:
    n = torch.arange(iq.shape[-1], device=iq.device)
    phase = 2 * torch.pi * cfo_hz * n / sample_rate
    rotor = torch.stack([torch.cos(phase), torch.sin(phase)])  # (2, N)
    return _complex_mul(iq, rotor)

Parameter ranges (held fixed per device):

Class

Δf / f_c (ppm)

Notes

Commercial radios

±40

e.g. ±100 kHz at 2.4 GHz

Lab USRPs

±0.5–2

with GPSDO

WiSig / SMoRFFI populations

σ ≈ 5–10

empirical

Cheap IoT (LoRa, BLE)

±20–40

crystal-driven

Sample from a Gaussian N(0, σ²) clipped to [−2σ, +2σ] or a uniform U(−Δ_max, +Δ_max) per the configured cfo_distribution.


Sample-clock offset (SFO)

The TX ADC clock is at (1 + ε) × f_s_nominal. Effect: slow time-domain dilation.

def _sfo(iq: torch.Tensor, sfo_ppm: float, sample_rate: float) -> torch.Tensor:
    # Polyphase resample by (1 + ε) where ε = sfo_ppm * 1e-6
    epsilon = sfo_ppm * 1e-6
    up, down = _rational_approx(1 + epsilon, max_denom=10000)
    return torchaudio.functional.resample(iq, orig_freq=down, new_freq=up)

Parameter range: ε [−30, +30] ppm per device.

CFO and SFO are typically correlated in real devices because both oscillators are derived from the same reference crystal: a crystal that runs slightly fast shifts both the carrier and the sample clock in the same direction. The config supports cfo_sfo_correlation [−1, +1]; the per-device draw produces a correlated bivariate Gaussian (CFO, SFO) from a 2×2 covariance matrix built from the per-axis ranges and this correlation.

Default: 0.7 (proposed-contract). The positive-correlation direction is physically correct. The magnitude 0.7 is a reasonable starting point: it reflects strong coupling (same crystal) while leaving room for device-specific voltage regulators and temperature compensation circuits that partially decouple the two offsets. It is not empirically verified against a specific device population. Before using this default to train models that must generalize to real hardware, measure the actual CFO/SFO joint distribution on your target hardware (e.g., using TorchSig’s ClockDrift OFDM pilot extraction or similar) and update cfo_sfo_correlation accordingly.


IQ imbalance (gain & phase)

Imperfect TX I/Q mixers introduce gain mismatch g_dB and phase mismatch φ_deg. Standard model:

\[\begin{split} \begin{aligned} y_I &= (1 + g/2) \cos(\phi/2)\, x_I - (1 + g/2) \sin(\phi/2)\, x_Q \\ y_Q &= (1 - g/2) \sin(\phi/2)\, x_I + (1 - g/2) \cos(\phi/2)\, x_Q \end{aligned} \end{split}\]

where g = (10^(g_dB/20) 1) is the linear gain mismatch and φ is in radians.

Equivalently, y = α·x + β·conj(x) with:

\[ \alpha = \tfrac{1}{2}\left[(1+\tfrac{g}{2})e^{-j\phi/2} + (1-\tfrac{g}{2})e^{+j\phi/2}\right], \quad \beta = \tfrac{1}{2}\left[(1+\tfrac{g}{2})e^{-j\phi/2} - (1-\tfrac{g}{2})e^{+j\phi/2}\right]^{\!*} \]
def _iq_imbalance(iq, g_db, phi_deg):
    g = 10 ** (g_db / 20) - 1
    phi = phi_deg * torch.pi / 180
    cphi, sphi = torch.cos(phi/2), torch.sin(phi/2)
    a, b = (1 + g/2), (1 - g/2)
    y_i = a * cphi * iq[0] - a * sphi * iq[1]
    y_q = b * sphi * iq[0] + b * cphi * iq[1]
    return torch.stack([y_i, y_q])

Parameter ranges:

Param

Range

Notes

g_db

[−1, +1] dB

per device

phi_deg

[−3, +3] degrees

per device


DC offset (LO leakage)

\[ y[n] = x[n] + (d_I + j \cdot d_Q) \]
def _dc_offset(iq, dc_i, dc_q):
    return iq + torch.tensor([[dc_i], [dc_q]], device=iq.device)

Parameter range: d_I, d_Q [−0.05, +0.05] relative to RMS amplitude. LO leakage shows up as a tone at zero baseband frequency in the spectrogram and is one of the most measurable per-device features.


Phase noise (Leeson model)

TX phase noise is a colored-noise process. Single-sideband PSD per Leeson:

\[ \mathcal{L}(f_m) = 10 \log_{10}\!\left[\frac{F k T}{2 P_s} \left(1 + \left(\frac{f_0}{2 Q_l f_m}\right)^{\!2}\right) \left(1 + \frac{f_c}{f_m}\right)\right] \quad [\text{dBc/Hz at offset } f_m] \]

Parameters per virtual device:

Symbol

Meaning

Range

f_0

Carrier frequency

scene-set

Q_l

Loaded Q of resonator

[10³, 10⁶] (cheap crystal → OCXO)

f_c

1/f corner frequency

[1 kHz, 100 kHz]

F

Amp noise factor

[1, 10] (linear, not dB)

P_s

Signal at amp input

set by amp-noise budget

k

Boltzmann constant

1.380649 × 10⁻²³ J/K

T

Temperature

290 K (room)

Resulting L(10 kHz) typically ∈ [−110, −80] dBc/Hz for commodity radios; −130 dBc/Hz for lab oscillators.

Implementation: shape-filter complex-valued white noise to match the target PSD, then multiply IQ by exp(j·φ_pn[n]):

def _build_phase_noise_filter(L_dbc_hz: callable, sample_rate, n_taps=4096):
    """Build an FIR filter whose magnitude response matches sqrt(L(f)).

    Pre-compute once per device (held fixed); the noise realization is per-emission.
    """
    freqs = torch.fft.fftfreq(n_taps, d=1/sample_rate)
    target_psd_db = L_dbc_hz(freqs.abs())            # dBc/Hz
    target_mag = 10 ** (target_psd_db / 20)           # linear sqrt
    # IFFT of magnitude response → linear-phase FIR
    h = torch.fft.ifft(target_mag).real
    return torch.fft.fftshift(h) * torch.hann_window(n_taps)


def _phase_noise(iq, pn_filter, sample_rate, rng):
    n = iq.shape[-1]
    white = torch.randn(n + len(pn_filter) - 1, generator=rng)
    phi = torch.nn.functional.conv1d(
        white.view(1, 1, -1),
        pn_filter.view(1, 1, -1),
    ).view(-1)[:n]
    rotor = torch.stack([torch.cos(phi), torch.sin(phi)])
    return _complex_mul(iq, rotor)

The filter is built once per device; the white-noise realization is per-emission so two emissions from the same device share PSD shape but not specific noise samples.


Power amplifier nonlinearity

Two memoryless models, selectable per device.

Rapp model (AM-AM, smooth saturation)

Standard for solid-state amplifiers in modern radios.

\[ |G(|x|)| = \frac{G_0 \cdot |x|}{\left(1 + \left(\frac{|x|}{V_{\text{sat}}}\right)^{\!2p}\right)^{\!\!1/(2p)}} \]

Output: \(y[n] = G(|x[n]|) \cdot e^{j \angle x[n]}\) (preserves phase).

Parameters:

Symbol

Range

Notes

G_0

[1.0, 10.0] linear gain (small-signal)

dB form: 0–20 dB

V_sat

[0.5, 2.0] × RMS amplitude

saturation point

p

[1, 5]

smoothness; p approaches hard limiter; p 2–3 typical

def _rapp(iq_complex, G0, V_sat, p):
    mag = iq_complex.abs()
    gain = G0 / (1 + (mag / V_sat) ** (2 * p)) ** (1 / (2 * p))
    return gain * torch.exp(1j * iq_complex.angle())

Saleh model (AM-AM and AM-PM)

Used for traveling-wave-tube amplifiers and as a more general nonlinearity. Includes phase distortion.

\[ A(r) = \frac{\alpha_a \cdot r}{1 + \beta_a \cdot r^2}, \qquad \Phi(r) = \frac{\alpha_\phi \cdot r^2}{1 + \beta_\phi \cdot r^2} \]

Output: \(y[n] = A(|x|) \cdot e^{j(\angle x[n] + \Phi(|x[n]|))}\).

Parameter ranges (drawn per device):

The ranges below admit the canonical Saleh-1981 TWTA fit \((\alpha_a, \beta_a, \alpha_\phi, \beta_\phi) = (2.1587, 1.1517, 4.0033, 9.1040)\) plus the ±5–10% device-to-device variation cited in fingerprint-parameter-priors.md. The fit is inside every range below; a device drawn at the canonical TWT default is in-spec.

Symbol

Range

Notes

α_a

[1.5, 2.5]

AM-AM gain (canonical fit 2.1587)

β_a

[0.5, 1.5]

AM-AM saturation (canonical fit 1.1517)

α_φ

[2.0, 5.0] rad

AM-PM gain (canonical fit 4.0033)

β_φ

[1.0, 12.0]

AM-PM saturation (canonical fit 9.1040)

Ordering

The PA is applied after pulse shaping and CFO/IQ-imbalance. Order matters: applying PA before pulse shaping changes spectral regrowth characteristics. The shipped framework pins this order via the Transformation enum (PA = 12 follows CFO = 15 in physical order but is dispatched per the documented per-group order); the scene composer reads each concrete’s transformation ClassVar to drive the canonical pipeline.


DAC quantization

Finite bit depth b (12–16 for SDRs, 8–10 for low-end IoT). With dither:

\[ y[n] = \frac{\text{round}\!\left((x[n] + d[n]) \cdot 2^{b-1}\right)}{2^{b-1}} \]
def _dac_quantization(iq, bits, dither_amplitude, rng):
    levels = 2 ** (bits - 1)
    dither = (torch.rand_like(iq, generator=rng) - 0.5) * 2 * dither_amplitude / levels
    return torch.round((iq + dither) * levels) / levels

Parameter ranges:

Param

Values

bits

{8, 10, 12, 14, 16}

dither_amplitude

[0, 1] LSB

Dither breaks the harmonic-distortion structure of bare quantization, modeling the dither LFSRs on real DACs. Setting dither_amplitude=0 reproduces undithered quantization (sharper harmonic spurs; useful for stress testing).


Per-device parameter draw

The shipped FingerprintParams model carries the canonical 11-field set (CFO, SFO, IQ-imbalance amplitude/phase, Rapp (p, A), Leeson PSD floor, Saleh (alpha_a, beta_a, alpha_phi, beta_phi), and the categorical pa_model selector). DC-offset and DAC-bit fields are not stored on FingerprintParams itself; DC-offset is folded into the IQ-imbalance closed form (Razavi differential model) and DAC bit depth is a class-level constructor argument on LinearDACQuantizer.

import torch
from rfgen.device_fingerprint import DeviceRegistry, FingerprintParams

registry = DeviceRegistry(
    cfo_hz_range=(-1000.0, 1000.0),
    sfo_ppm_range=(-20.0, 20.0),
    iq_imbalance_db_range=(-1.0, 1.0),
    iq_imbalance_rad_range=(-0.035, 0.035),
    pa_p_range=(1.5, 3.0),
    pa_a_range=(0.8, 1.2),
    phase_noise_dbc_hz_range=(-110.0, -90.0),
)
rng = torch.Generator().manual_seed(0)
params: FingerprintParams = registry.draw("device-001", rng=rng)

Each first-time draw derives a sub-seed from rng.initial_seed() and device_id via rfgen.rng.derive_rng, then draws each continuous prior via numpy.random.default_rng(...).uniform(low, high). pa_model is fixed at the registry-wide default_pa_model (closed-set enum, not a continuous draw). For reproducibility: same shard seed + same device population -> same fingerprints. See Reference / Determinism.

Why this enables fingerprinting tasks

This impairment chain reproduces what fingerprinting CNNs learn to recognize:

  • Per-device discriminability. Each device has a unique (CFO, IQ-imb, PA-curve, ...) fingerprint; CNNs trained on raw IQ pick up these features at low SNR.

  • Cross-day stability. The fingerprint stays the same when channels change, enabling cross-channel-realization splits that test domain-generalization.

  • Cross-receiver invariance. Receiver-side impairments live in the rfgen.rx_frontend module as a chain of separate concretes (LinearRXMixer, ScipyFIRIFFilter, ScipyPolyResampler, LinearLNANoise, LinearADCQuantizer, LeesonRXPhaseNoise, TorchSigRXIQImbalance, LinearAGC); switching them produces the cross-receiver bias observed in real fingerprinting datasets. See the RX Frontend reference for the per-class signatures.

  • Open-set support. Holding out a subset of virtual devices from training is the canonical evaluation protocol; device_population.train_split / device_population.holdout_split config knobs make this trivial.

See Also