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:
Per-device parameter store. FingerprintParams (Pydantic v2 model) and DeviceRegistry (content-addressed cache) live in
rfgen.device_fingerprint. The registry mapsdevice_idto a deterministic per-deviceFingerprintParamsdraw.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 fromctx.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:
The orchestrator declares a device population (e.g., 25 LoRa devices, 16 Wi-Fi devices).
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 samedevice_idreturn the cached instance.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).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 |
linear |
3 |
Carrier frequency offset |
linear (modulation by |
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.
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 |
|
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:
where g = (10^(g_dB/20) − 1) is the linear gain mismatch and φ is in radians.
Equivalently, y = α·x + β·conj(x) with:
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 |
|---|---|---|
|
|
per device |
|
|
per device |
DC offset (LO leakage)¶
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:
Parameters per virtual device:
Symbol |
Meaning |
Range |
|---|---|---|
|
Carrier frequency |
scene-set |
|
Loaded Q of resonator |
|
|
1/f corner frequency |
|
|
Amp noise factor |
|
|
Signal at amp input |
set by amp-noise budget |
|
Boltzmann constant |
|
|
Temperature |
|
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.
Output: \(y[n] = G(|x[n]|) \cdot e^{j \angle x[n]}\) (preserves phase).
Parameters:
Symbol |
Range |
Notes |
|---|---|---|
|
|
dB form: 0–20 dB |
|
|
saturation point |
|
|
smoothness; |
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.
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 |
|---|---|---|
|
|
AM-AM gain (canonical fit |
|
|
AM-AM saturation (canonical fit |
|
|
AM-PM gain (canonical fit |
|
|
AM-PM saturation (canonical fit |
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:
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 |
|---|---|
|
|
|
|
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_frontendmodule 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_splitconfig knobs make this trivial.
See Also¶
Reference / API / Device Fingerprint:
FingerprintParamsandDeviceRegistryreference.Reference / API / RX Frontend: RX-side concretes.
ORACLE (Sankhe et al., 2019; arXiv:1812.01124): the canonical reference
WiSig, SMoRFFI: empirical parameter distributions