Skip to main content

dsfb_rf/
impairment.rs

1//! RF hardware and channel impairment injection for verification harnesses.
2//!
3//! ## Purpose
4//!
5//! Provides **deterministic, physics-grounded** perturbation functions that
6//! inject realistic hardware impairments into a synthetic residual-norm stream.
7//! These are used by the Continuous Rigor pipeline (Stage II: Impairment
8//! Injection) to prove the Observer Contract holds under conditions that
9//! RadioML-class synthetic data systematically omits.
10//!
11//! ## Impairment Models
12//!
13//! ### 1. I/Q Amplitude Imbalance
14//!
15//! SDR hardware (RTL-SDR, HackRF) exhibits a differential gain between
16//! the I and Q baseband paths.  The received pair becomes:
17//!
18//! ```text
19//! r_I' = r_I · (1 + ε/2)
20//! r_Q' = r_Q · (1 − ε/2)
21//! ```
22//!
23//! where ε ∈ [0, 0.10] is the fractional amplitude imbalance.
24//! To first order the Euclidean norm shifts by:
25//!
26//! ```text
27//! ‖r'‖ ≈ ‖r‖ · sqrt(1 + ε · cos(2φ))   ≈ ‖r‖ · (1 + ε/2 · cos(2φ))
28//! ```
29//!
30//! where φ is the instantaneous carrier phase at that sample.
31//! Worst case: δ‖r‖ ≤ ε · ‖r‖ / 2.
32//! Reference: Windisch & Fettweis, "Performance Degradation Due to I/Q
33//! Imbalance in Multi-Carrier Direct-Conversion Transceivers," IEEE
34//! GLOBECOM 2003.
35//!
36//! ### 2. DC Offset
37//!
38//! A static complex bias d = (d_I, d_Q) shifts the IQ centroid away from
39//! zero.  The norm perturbation (first-order Taylor) is:
40//!
41//! ```text
42//! ‖r + d‖ ≈ ‖r‖ + (d_I · cos φ + d_Q · sin φ)
43//! ```
44//!
45//! Typical RTL-SDR: d_rms ∈ [0.01, 0.02] of normalised full scale.
46//! Reference: Vankka, "Methods of Modulation Classification," 1997.
47//!
48//! ### 3. Cubic PA Compression
49//!
50//! The memoryless third-order AM/AM model describes PA non-linearity at
51//! moderate back-off (< 3 dB IBO):
52//!
53//! ```text
54//! r_out = r_in · (1 − k₃ · |r_in|²)
55//! ```
56//!
57//! where k₃ > 0 is the cubic compression coefficient.  For IBO = P_in −
58//! P_1dB, the 1 dB compression point corresponds to k₃ · |r_1|² ≈ 0.145.
59//! This model is used on PAWR Colosseum testbed captures to represent the
60//! PA non-linearity in high-power nodes.
61//! Reference: Cripps, "RF Power Amplifiers for Wireless Communications,"
62//! Artech House, 2006, §4.
63//!
64//! ### 4. ADC Quantisation Noise (GUM §4.3.7)
65//!
66//! For an N-bit linear PCM quantiser with full-scale range [−1, +1], the
67//! standard uncertainty due to quantisation is:
68//!
69//! ```text
70//! u_q = LSB / sqrt(12) = 2^(1−N) / sqrt(12)
71//! ```
72//!
73//! per ISO/IEC Guide 98-3 (GUM) §4.3.7.  This is the one-sigma additive
74//! white noise on the residual norm.
75//!
76//! ### 5. Phase Noise (Leeson's Model)
77//!
78//! For a phase jitter σ_φ (radian rms, integrated over [f_low, f_high]),
79//! the norm perturbation on a unit-magnitude carrier is:
80//!
81//! ```text
82//! δ‖r‖ ≈ |sin(δφ)| ≈ |δφ|   (small-angle, valid for σ_φ < 0.3 rad)
83//! ```
84//!
85//! The Leeson single-sideband phase noise spectrum is:
86//!
87//! ```text
88//! L(f_m) [dBc/Hz] = 10 log10 [ (f₀/(2·Q·f_m))² · (F·k·T·(P_s)⁻¹/2) ]
89//! ```
90//!
91//! We parameterise the impairment model purely by σ_φ, which the user
92//! computes from the hardware data sheet's phase noise plot integrated
93//! over the relevant noise bandwidth.
94//! Reference: Leeson, D.B., Proc. IEEE, 54(2), 1966.
95//!
96//! ### 6. Ionospheric Scintillation (ESA / GPS L-band)
97//!
98//! The amplitude scintillation index S4 is defined as:
99//!
100//! ```text
101//! S4² = (⟨I²⟩ − ⟨I⟩²) / ⟨I⟩²
102//! ```
103//!
104//! where I = |r|² is the instantaneous received intensity.  We model
105//! the amplitude perturbation as r' = r · (1 + S4 · w) where w is a
106//! sample from the deterministic LCG noise sequence.
107//!
108//! S4 classification (CCIR 652-1):
109//! - S4 < 0.30:  Weak   (no link impact)
110//! - S4 ∈ [0.30, 0.60]:  Moderate (cycle-slip risk above 5 dB SNR margin)
111//! - S4 > 0.60:  Strong  (immediate link degradation)
112//!
113//! Reference: Fremouw & Rino (1973); Kintner et al., GPS Solutions, 11(2)
114//! 2007.
115//!
116//! ### 7. Doppler Steady-State Tracking Error
117//!
118//! A Doppler shift f_D [Hz] on a first-order PLL with velocity constant
119//! K_v [Hz/rad] produces a steady-state phase error:
120//!
121//! ```text
122//! θ_ss = 2π · f_D / K_v   [rad]
123//! ```
124//!
125//! which elevates the residual norm floor by approximately θ_ss for a
126//! normalised unit-gain loop.  For GPS L1 at v = 5 km/s, f_D ≈ 26 Hz.
127//! Reference: Kaplan & Hegarty, "GPS Principles and Applications," §5.4.
128//!
129//! ## Design Invariants
130//!
131//! - `no_std`, `no_alloc`, zero `unsafe`
132//! - All functions are pure: no side effects, no heap allocation
133//! - Deterministic: LCG seed produces reproducible noise sequences
134//! - Bounded: all outputs are finite (saturation arithmetic)
135//! - All perturbation magnitudes are documented with authoritative references
136
137// ── Deterministic LCG pseudo-random noise ─────────────────────────────────
138
139/// Advance one step of the Knuth-style LCG (modulus 2³², Knuth TAOCP §3.6).
140///
141/// Parameters: a = 1664525, c = 1013904223, m = 2³² (implicit overflow).
142/// This is the same LCG used in Numerical Recipes and is well-characterised
143/// for noise injection in simulation harnesses.
144#[inline]
145pub const fn lcg_step(seed: u32) -> u32 {
146    seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223)
147}
148
149/// Convert an LCG state to a sample in U[−1, +1].
150#[inline]
151pub fn lcg_uniform(seed: u32) -> f32 {
152    // Map 0..u32::MAX → [0, 1) then shift to [−1, +1)
153    (seed as f32) * (2.0 / u32::MAX as f32) - 1.0
154}
155
156// ── I/Q Amplitude Imbalance ────────────────────────────────────────────────
157
158/// Apply I/Q amplitude imbalance to a residual norm sample.
159///
160/// # Arguments
161/// * `norm`    — ‖r(k)‖, the current residual norm (≥ 0)
162/// * `phi_rad` — instantaneous carrier phase φ(k) in radians
163/// * `epsilon` — fractional amplitude imbalance ε ∈ [0, 0.10]
164///
165/// # Returns
166/// Perturbed norm ‖r'(k)‖ ≥ 0.
167///
168/// # First-order model
169/// δ‖r‖ = ‖r‖ · (ε/2) · cos(2φ)
170/// Worst-case upper bound: δ‖r‖ ≤ (ε/2) · ‖r‖
171#[inline]
172pub fn apply_iq_imbalance(norm: f32, phi_rad: f32, epsilon: f32) -> f32 {
173    // First-order Taylor expansion of sqrt(1 + ε·cos(2φ))
174    let perturbation = (epsilon * 0.5) * cos_approx(2.0 * phi_rad);
175    (norm * (1.0 + perturbation)).max(0.0)
176}
177
178// ── DC Offset ─────────────────────────────────────────────────────────────
179
180/// Apply a complex DC offset bias (d_I, d_Q) to a residual norm.
181///
182/// # Arguments
183/// * `norm`    — ‖r(k)‖, the current residual norm
184/// * `phi_rad` — instantaneous carrier phase φ(k) in radians
185/// * `dc_i`   — in-phase DC offset magnitude (normalised)
186/// * `dc_q`   — quadrature DC offset magnitude (normalised)
187///
188/// # Returns
189/// Perturbed norm.  This is always ≥ 0.
190///
191/// # Model
192/// ‖r + d‖ ≈ ‖r‖ + d_I·cos(φ) + d_Q·sin(φ)   (first-order Taylor)
193#[inline]
194pub fn apply_dc_offset(norm: f32, phi_rad: f32, dc_i: f32, dc_q: f32) -> f32 {
195    let bias = dc_i * cos_approx(phi_rad) + dc_q * sin_approx(phi_rad);
196    (norm + bias).max(0.0)
197}
198
199// ── PA Compression ────────────────────────────────────────────────────────
200
201/// Apply third-order memoryless AM/AM PA compression.
202///
203/// # Arguments
204/// * `norm` — ‖r_in‖, input norm (normalised to full scale)
205/// * `k3`   — cubic compression coefficient k₃ > 0
206///
207/// # Returns
208/// Compressed output norm.  Saturates at `norm` for negative k3 artefacts.
209///
210/// # Model
211/// r_out = r_in · (1 − k₃ · |r_in|²)
212/// The 1 dB compression point occurs at k₃ · |r_1|² ≈ 0.145.
213#[inline]
214pub fn apply_pa_compression(norm: f32, k3: f32) -> f32 {
215    let factor = 1.0 - k3 * norm * norm;
216    // Clamp: once the signal enters hard saturation the cubic over-shoots;
217    // we clamp to [0, norm] which corresponds to AM suppression but not reversal.
218    (norm * factor.max(0.0)).max(0.0)
219}
220
221// ── ADC Quantisation Noise ────────────────────────────────────────────────
222
223/// GUM-compliant ADC quantisation noise standard uncertainty.
224///
225/// Returns u_q = 2^(1−N) / sqrt(12) per ISO/IEC Guide 98-3 §4.3.7.
226///
227/// # Arguments
228/// * `n_bits` — ADC word width N (e.g. 8, 12, 14, 16)
229///
230/// # Returns
231/// Standard uncertainty u_q for one I or Q sample (normalised to FS = 1).
232///
233/// # Examples
234/// N=8:  u_q ≈ 1.13×10⁻³   (RTL-SDR)
235/// N=12: u_q ≈ 7.07×10⁻⁵   (LimeSDR)
236/// N=14: u_q ≈ 1.77×10⁻⁵   (USRP X310 effective)
237#[inline]
238pub fn quantization_noise_std(n_bits: u32) -> f32 {
239    // LSB = 2^(1−N) for FS = 1; u_q = LSB / sqrt(12)
240    let lsb = libm_pow2(1i32 - n_bits as i32);
241    lsb / 3.464_101_6 // sqrt(12) = 3.4641...
242}
243
244/// Apply ADC quantisation noise to a residual norm (additive white model).
245///
246/// Adds a deterministic LCG-generated noise sample scaled by u_q to the
247/// input norm.  The noise is bounded to ±3·u_q (three-sigma clip).
248#[inline]
249pub fn apply_quantization_noise(norm: f32, n_bits: u32, seed: u32) -> (f32, u32) {
250    let u_q = quantization_noise_std(n_bits);
251    let noise_seed = lcg_step(seed);
252    let w = lcg_uniform(noise_seed); // w ∈ [−1, +1]
253    let perturbed = (norm + u_q * w * 3.0).max(0.0);
254    (perturbed, noise_seed)
255}
256
257// ── Phase Noise ───────────────────────────────────────────────────────────
258
259/// Apply LO phase noise (Leeson model parameterised by σ_φ) to a norm.
260///
261/// # Arguments
262/// * `norm`     — ‖r(k)‖ (normalised)
263/// * `sigma_phi` — integrated phase noise σ_φ in radians rms
264/// * `seed`     — LCG state for this sample
265///
266/// # Returns
267/// `(perturbed_norm, new_seed)`
268///
269/// # Model
270/// δ‖r‖ ≈ ‖r‖ · |sin(δφ)|.  For σ_φ < 0.3 rad, sin(δφ) ≈ δφ.
271/// The sample δφ ~ σ_φ · w where w ∈ [−1, +1] (three-sigma uniform proxy).
272#[inline]
273pub fn apply_phase_noise(norm: f32, sigma_phi: f32, seed: u32) -> (f32, u32) {
274    let noise_seed = lcg_step(seed);
275    let delta_phi = sigma_phi * lcg_uniform(noise_seed); // rad sample
276    // |sin(δφ)| ≈ |δφ| for small angle — exact for δφ < 0.3 rad
277    let perturbation = norm * sin_approx(delta_phi).abs();
278    let perturbed = (norm + perturbation).max(0.0);
279    (perturbed, noise_seed)
280}
281
282// ── Ionospheric Scintillation ──────────────────────────────────────────────
283
284/// S4 amplitude scintillation index classification (CCIR 652-1).
285#[derive(Debug, Clone, Copy, PartialEq)]
286pub enum ScintillationClass {
287    /// S4 < 0.30: no link impact expected.
288    Weak,
289    /// S4 ∈ [0.30, 0.60]: cycle-slip risk above 5 dB SNR margin.
290    Moderate,
291    /// S4 > 0.60: immediate link degradation expected.
292    Strong,
293}
294
295/// Classify the S4 scintillation index per CCIR 652-1.
296#[inline]
297pub const fn classify_s4(s4: f32) -> ScintillationClass {
298    if s4 < 0.30 {
299        ScintillationClass::Weak
300    } else if s4 < 0.60 {
301        ScintillationClass::Moderate
302    } else {
303        ScintillationClass::Strong
304    }
305}
306
307/// Apply ionospheric amplitude scintillation to a residual norm.
308///
309/// # Arguments
310/// * `norm` — ‖r(k)‖ (normalised received amplitude)
311/// * `s4`   — amplitude scintillation index S4 ∈ [0, 1]
312/// * `seed` — LCG state
313///
314/// # Returns
315/// `(perturbed_norm, new_seed, scintillation_class)`
316///
317/// # Model
318/// r' = r · (1 + S4 · w)  where w ~ U[−1, +1].
319/// The S4² definition ensures E[|r'|²] = |r|² · (1 + S4²/3) — bounded variance.
320#[inline]
321pub fn apply_scintillation(norm: f32, s4: f32, seed: u32) -> (f32, u32, ScintillationClass) {
322    let noise_seed = lcg_step(seed);
323    let w = lcg_uniform(noise_seed);
324    let perturbed = (norm * (1.0 + s4 * w)).max(0.0);
325    let cls = classify_s4(s4);
326    (perturbed, noise_seed, cls)
327}
328
329// ── Doppler Steady-State Tracking Error ──────────────────────────────────
330
331/// Steady-state residual norm elevation due to Doppler (first-order PLL).
332///
333/// For a first-order PLL with velocity constant K_v [Hz/rad], a Doppler
334/// shift f_D [Hz] produces a phase tracking error:
335///
336/// ```text
337/// θ_ss = 2π · f_D / K_v   [rad]
338/// ```
339///
340/// The elevated residual norm floor is θ_ss · `nominal_norm` for a
341/// carrier-normalised loop.
342///
343/// # Arguments
344/// * `f_d_hz`       — Doppler shift [Hz] (positive = approaching)
345/// * `k_v_hz`       — PLL first-order velocity constant K_v [Hz/rad]
346/// * `nominal_norm` — quiescent residual norm ‖r₀‖ at zero Doppler
347///
348/// # Returns
349/// Elevated residual norm floor.
350///
351/// # Example
352/// GPS L1 (1575.42 MHz), v = 300 m/s aircraft: f_D = f_c · v/c ≈ 1.58 Hz.
353/// A typical GPS PLL K_v = 200 Hz/rad → θ_ss ≈ 0.050 rad.
354#[inline]
355pub fn doppler_residual_floor(f_d_hz: f32, k_v_hz: f32, nominal_norm: f32) -> f32 {
356    if k_v_hz <= 0.0 {
357        return nominal_norm;
358    }
359    let theta_ss = core::f32::consts::TAU * f_d_hz.abs() / k_v_hz;
360    (nominal_norm + theta_ss * nominal_norm).max(nominal_norm)
361}
362
363// ── Compound Impairment Vector ─────────────────────────────────────────────
364
365/// All hardware impairments parameterised in one structure.
366///
367/// Used by Stage II of the Continuous Rigor pipeline to inject a
368/// reproducible, physics-calibrated impairment set into the synthetic
369/// baseline from Stage I.  Each field defaults to "zero impairment."
370#[derive(Debug, Clone, Copy)]
371pub struct ImpairmentVector {
372    /// I/Q amplitude imbalance ε ∈ [0, 0.10].  RTL-SDR typical: 0.012.
373    pub iq_imbalance_epsilon: f32,
374    /// DC offset in-phase component d_I (normalised).  RTL-SDR: 0.015.
375    pub dc_offset_i: f32,
376    /// DC offset quadrature component d_Q (normalised).  RTL-SDR: 0.010.
377    pub dc_offset_q: f32,
378    /// PA cubic compression coefficient k₃.  PAWR PA: 0.30.  0 = disabled.
379    pub pa_k3: f32,
380    /// ADC word width N for quantisation noise.  0 = disabled.
381    pub adc_bits: u32,
382    /// Integrated LO phase noise σ_φ [rad rms].  TCXO: 0.05 rad. 0 = disabled.
383    pub phase_noise_sigma: f32,
384    /// Ionospheric S4 scintillation index ∈ [0, 1].  0 = disabled.
385    pub scintillation_s4: f32,
386}
387
388impl ImpairmentVector {
389    /// RTL-SDR v3 impairment profile.
390    ///
391    /// Based on community-characterised hardware measurements from
392    /// IQEngine.org RTL-SDR capture archive.
393    pub const RTL_SDR: Self = Self {
394        iq_imbalance_epsilon: 0.012,
395        dc_offset_i: 0.015,
396        dc_offset_q: 0.010,
397        pa_k3: 0.0,
398        adc_bits: 8,
399        phase_noise_sigma: 0.08,
400        scintillation_s4: 0.0,
401    };
402
403    /// USRP X310 / N310 impairment profile.
404    ///
405    /// Ettus Research X310 with SBX daughterboard, factory-calibrated.
406    pub const USRP_X310: Self = Self {
407        iq_imbalance_epsilon: 0.001,
408        dc_offset_i: 0.001,
409        dc_offset_q: 0.001,
410        pa_k3: 0.0,
411        adc_bits: 14,
412        phase_noise_sigma: 0.015,
413        scintillation_s4: 0.0,
414    };
415
416    /// PAWR Colosseum node profile (FPGA + medium-power PA).
417    ///
418    /// Derived from Colosseum RF front-end characterisation data,
419    /// Northeastern University, 2021.
420    pub const COLOSSEUM_NODE: Self = Self {
421        iq_imbalance_epsilon: 0.004,
422        dc_offset_i: 0.003,
423        dc_offset_q: 0.002,
424        pa_k3: 0.12,
425        adc_bits: 12,
426        phase_noise_sigma: 0.025,
427        scintillation_s4: 0.0,
428    };
429
430    /// ESA L-band receiver under moderate ionospheric scintillation.
431    ///
432    /// S4 = 0.40 (moderate), based on ISMR (Ionospheric Scintillation
433    /// Monitor Receiver) data from ESA GNSS Science Support Centre.
434    pub const ESA_L_BAND_MODERATE: Self = Self {
435        iq_imbalance_epsilon: 0.002,
436        dc_offset_i: 0.001,
437        dc_offset_q: 0.001,
438        pa_k3: 0.0,
439        adc_bits: 14,
440        phase_noise_sigma: 0.020,
441        scintillation_s4: 0.40,
442    };
443
444    /// ESA L-band receiver under strong ionospheric scintillation.
445    ///
446    /// S4 = 0.70 (strong), near link-loss threshold.
447    pub const ESA_L_BAND_STRONG: Self = Self {
448        iq_imbalance_epsilon: 0.002,
449        dc_offset_i: 0.001,
450        dc_offset_q: 0.001,
451        pa_k3: 0.0,
452        adc_bits: 14,
453        phase_noise_sigma: 0.020,
454        scintillation_s4: 0.70,
455    };
456
457    /// Zero impairment ("physics-only" baseline for Stage I).
458    pub const NONE: Self = Self {
459        iq_imbalance_epsilon: 0.0,
460        dc_offset_i: 0.0,
461        dc_offset_q: 0.0,
462        pa_k3: 0.0,
463        adc_bits: 0,
464        phase_noise_sigma: 0.0,
465        scintillation_s4: 0.0,
466    };
467}
468
469/// Apply the full ImpairmentVector to a single norm sample.
470///
471/// Impairments are applied in the order they occur in the signal chain:
472/// 1. PA compression (transmitter, Colosseum)
473/// 2. Ionospheric scintillation (propagation)
474/// 3. DC offset (receiver front-end)
475/// 4. I/Q imbalance (receiver baseband)
476/// 5. Phase noise (LO)
477/// 6. ADC quantisation (ADC)
478///
479/// # Arguments
480/// * `norm`     — ‖r(k)‖ before impairment
481/// * `phi_rad`  — carrier phase φ(k) at this sample (radians)
482/// * `seed`     — current LCG state (for stochastic impairments)
483/// * `imp`      — compound impairment vector
484///
485/// # Returns
486/// `(perturbed_norm, new_seed)`
487pub fn apply_all(norm: f32, phi_rad: f32, seed: u32, imp: ImpairmentVector) -> (f32, u32) {
488    let mut r = norm;
489    let mut s = seed;
490
491    // 1. PA compression
492    if imp.pa_k3 > 0.0 {
493        r = apply_pa_compression(r, imp.pa_k3);
494    }
495
496    // 2. Ionospheric scintillation
497    if imp.scintillation_s4 > 0.0 {
498        let (rr, ss, _) = apply_scintillation(r, imp.scintillation_s4, s);
499        r = rr;
500        s = ss;
501    }
502
503    // 3. DC offset
504    if imp.dc_offset_i.abs() > 0.0 || imp.dc_offset_q.abs() > 0.0 {
505        r = apply_dc_offset(r, phi_rad, imp.dc_offset_i, imp.dc_offset_q);
506    }
507
508    // 4. I/Q imbalance
509    if imp.iq_imbalance_epsilon > 0.0 {
510        r = apply_iq_imbalance(r, phi_rad, imp.iq_imbalance_epsilon);
511    }
512
513    // 5. Phase noise
514    if imp.phase_noise_sigma > 0.0 {
515        let (rr, ss) = apply_phase_noise(r, imp.phase_noise_sigma, s);
516        r = rr;
517        s = ss;
518    }
519
520    // 6. ADC quantisation
521    if imp.adc_bits > 0 {
522        let (rr, ss) = apply_quantization_noise(r, imp.adc_bits, s);
523        r = rr;
524        s = ss;
525    }
526
527    (r.max(0.0), s)
528}
529
530// ── Trig approximations (no_std, no libm dependency) ──────────────────────
531
532/// Minimax polynomial approximation of sin(x) over [−π, π], max error < 5×10⁻⁴.
533///
534/// Coefficients from Abramowitz & Stegun 4.3.97, range-reduced via
535/// periodic extension.  Suitable for impairment simulation; not suitable
536/// for navigation-grade computation.
537#[inline]
538pub fn sin_approx(x: f32) -> f32 {
539    // Range reduction to [−π, π]. f32 finite magnitudes fit in < 2^128;
540    // 64 iterations covers practical inputs without unbounded spin on
541    // adversarial values.
542    use core::f32::consts::{PI, TAU};
543    let mut x = x;
544    for _ in 0..64 {
545        if x <= PI { break; }
546        x -= TAU;
547    }
548    for _ in 0..64 {
549        if x >= -PI { break; }
550        x += TAU;
551    }
552    // A&S polynomial approximation
553    let b = 4.0 / PI;
554    let c = -4.0 / (PI * PI);
555    let y = b * x + c * x * x.abs();
556    // Refinement pass (Bhaskara I-style, max error ~5×10⁻⁴)
557    0.225 * (y * y.abs() - y) + y
558}
559
560/// Minimax polynomial approximation of cos(x).
561#[inline]
562pub fn cos_approx(x: f32) -> f32 {
563    sin_approx(x + core::f32::consts::FRAC_PI_2)
564}
565
566/// Compute 2^n as f32 for integer n (exact for |n| ≤ 126).
567#[inline]
568fn libm_pow2(n: i32) -> f32 {
569    // Use bit manipulation on f32 exponent field (correct for 0 < 2^n < f32::MAX)
570    // Exponent is stored with bias 127: bits = (n + 127) << 23
571    if n < -126 { return 0.0; }
572    if n > 127  { return f32::MAX; }
573    f32::from_bits(((n + 127) as u32) << 23)
574}
575
576// ── Tests ─────────────────────────────────────────────────────────────────
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn iq_imbalance_bounded() {
584        // δ‖r‖ ≤ (ε/2) · ‖r‖
585        let norm = 0.1_f32;
586        let epsilon = 0.08_f32;
587        for k in 0..16u32 {
588            let phi = k as f32 * (core::f32::consts::PI / 8.0);
589            let r = apply_iq_imbalance(norm, phi, epsilon);
590            let max_delta = (epsilon * 0.5 + 1e-6) * norm;
591            assert!(
592                (r - norm).abs() <= max_delta,
593                "IQ imbalance exceeded bound: r={r}, norm={norm}, delta={}, max={}",
594                (r - norm).abs(), max_delta
595            );
596        }
597    }
598
599    #[test]
600    fn dc_offset_small_positive() {
601        // With dc_i=0.01, dc_q=0 and phi=0, norm should increase by ~dc_i
602        let norm = 0.1_f32;
603        let r = apply_dc_offset(norm, 0.0, 0.01, 0.0);
604        let delta = (r - norm).abs();
605        // cos(0)=1 → expected +0.01
606        assert!(delta < 0.02, "DC offset perturbation too large: {delta}");
607    }
608
609    #[test]
610    fn pa_compression_reduces_norm() {
611        // High input → compressed output < input
612        let norm = 0.5_f32;
613        let k3 = 0.30_f32;
614        let r = apply_pa_compression(norm, k3);
615        assert!(r <= norm, "PA compression should reduce or equal input: {r} vs {norm}");
616        assert!(r > 0.0, "PA compression should not go negative: {r}");
617    }
618
619    #[test]
620    fn pa_compression_identity_at_zero() {
621        let r = apply_pa_compression(0.0, 0.30);
622        assert!(r == 0.0);
623    }
624
625    #[test]
626    fn quantization_noise_std_n8() {
627        // N=8: u_q = 2^(1-8) / sqrt(12) = 2^(-7) / 3.4641 ≈ 7.81e-3 / 3.464 ≈ 2.25e-3
628        let u = quantization_noise_std(8);
629        assert!(u > 1e-3 && u < 1e-2, "8-bit u_q out of expected range: {u}");
630    }
631
632    #[test]
633    fn quantization_noise_std_n14() {
634        // N=14: much smaller
635        let u8 = quantization_noise_std(8);
636        let u14 = quantization_noise_std(14);
637        assert!(u14 < u8 * 0.1, "14-bit should be << 8-bit: {u14} vs {u8}");
638    }
639
640    #[test]
641    fn phase_noise_bounded() {
642        let norm = 0.1_f32;
643        let sigma = 0.05_f32;
644        let (r, _) = apply_phase_noise(norm, sigma, 42);
645        // Perturbation bounded by sigma * norm
646        assert!((r - norm).abs() < sigma + 1e-4, "Phase noise exceeded sigma bound");
647    }
648
649    #[test]
650    fn scintillation_weak_small_perturbation() {
651        let norm = 0.1_f32;
652        let s4 = 0.20_f32; // weak
653        let (r, _, cls) = apply_scintillation(norm, s4, 12345);
654        assert_eq!(cls, ScintillationClass::Weak);
655        // Perturbation bounded by S4 * norm
656        assert!((r - norm).abs() <= s4 * norm + 1e-6);
657    }
658
659    #[test]
660    fn scintillation_strong_classifies_correctly() {
661        let (_, _, cls) = apply_scintillation(0.1, 0.70, 999);
662        assert_eq!(cls, ScintillationClass::Strong);
663    }
664
665    #[test]
666    fn doppler_floor_elevated() {
667        let nominal = 0.05_f32;
668        let elevated = doppler_residual_floor(1.58, 200.0, nominal);
669        assert!(elevated > nominal, "Doppler should elevate floor: {elevated} vs {nominal}");
670    }
671
672    #[test]
673    fn apply_all_none_is_identity() {
674        let norm = 0.1_f32;
675        let (r, _) = apply_all(norm, 0.0, 42, ImpairmentVector::NONE);
676        assert!((r - norm).abs() < 1e-6, "Zero impairment should be identity: {r} vs {norm}");
677    }
678
679    #[test]
680    fn apply_all_rtl_sdr_bounded() {
681        let norm = 0.1_f32;
682        // RTL-SDR impairments should not change norm by more than 30% of norm
683        let (r, _) = apply_all(norm, 1.0, 7777, ImpairmentVector::RTL_SDR);
684        assert!((r - norm).abs() < 0.3 * norm + 0.05,
685            "RTL-SDR impairment too large: {r} vs {norm}");
686    }
687
688    #[test]
689    fn lcg_period_deterministic() {
690        let s0 = 123456789u32;
691        let s1 = lcg_step(s0);
692        let s2 = lcg_step(s1);
693        let s1b = lcg_step(s0);
694        assert_eq!(s1, s1b, "LCG must be deterministic");
695        assert_ne!(s1, s2, "Consecutive LCG states must differ");
696    }
697
698    #[test]
699    fn sin_approx_zero_and_halfpi() {
700        let s0 = sin_approx(0.0);
701        let s_halfpi = sin_approx(core::f32::consts::FRAC_PI_2);
702        assert!(s0.abs() < 0.01, "sin(0) ≈ 0: got {s0}");
703        assert!((s_halfpi - 1.0).abs() < 0.01, "sin(π/2) ≈ 1: got {s_halfpi}");
704    }
705
706    #[test]
707    fn classify_s4_boundaries() {
708        assert_eq!(classify_s4(0.10), ScintillationClass::Weak);
709        assert_eq!(classify_s4(0.45), ScintillationClass::Moderate);
710        assert_eq!(classify_s4(0.75), ScintillationClass::Strong);
711    }
712}