Skip to main content

dsfb_rf/
uncertainty.rs

1//! GUM-compliant uncertainty budget for admissibility envelopes.
2//!
3//! ## Theoretical Basis: Guide to the Expression of Uncertainty in Measurement (GUM)
4//!
5//! GUM (JCGM 100:2008 / ISO/IEC Guide 98-3) and IEEE 1764 require that
6//! measurement uncertainty budgets decompose the total uncertainty into
7//! Type A (statistical) and Type B (systematic) contributions.
8//!
9//! DSFB applies this framework to the admissibility envelope radius ρ:
10//!
11//! **Type A (statistical):** Standard uncertainty of the healthy-window
12//! residual norm, derived from N independent observations:
13//!   u_A = σ_healthy / √N
14//!
15//! **Type B (systematic):** Known systematic contributors that affect
16//! the residual but are not captured in the calibration window:
17//! - Receiver noise figure uncertainty (typically ±0.5 dB → mapped to norm)
18//! - ADC quantization noise (Q / √12 for Q = LSB in residual norm units)
19//! - Temperature-dependent gain variation (manufacturer specification)
20//! - Clock/LO phase noise floor contribution
21//!
22//! **Combined standard uncertainty:**
23//!   u_c = √(u_A² + Σᵢ u_B,i²)
24//!
25//! **Expanded uncertainty (coverage k=3 for 99.7% confidence):**
26//!   U = k · u_c = 3 · u_c
27//!
28//! The expanded uncertainty U is the principled basis for setting ρ.
29//! Using ρ = μ + U (with k=3) provides a GUM-traceable envelope rather
30//! than an ad-hoc "3σ rule."
31//!
32//! ## Pre-condition: WSS Verification
33//!
34//! GUM requires that Type A uncertainty be derived from a stationary process.
35//! The stationarity module (`stationarity.rs`) provides the WSS pre-condition.
36//! If the calibration window fails WSS verification, the GUM uncertainty
37//! budget is flagged as unreliable.
38//!
39//! ## Design
40//!
41//! - `no_std`, `no_alloc`, zero `unsafe`
42//! - O(n) single-pass computation from healthy window norms
43
44use crate::math::{mean_f32, std_dev_f32, sqrt_f32};
45
46/// A single Type B systematic uncertainty contributor.
47#[derive(Debug, Clone, Copy)]
48pub struct TypeBContributor {
49    /// Human-readable name of the contributor.
50    pub name: &'static str,
51    /// Standard uncertainty in residual norm units.
52    pub u_b: f32,
53    /// Source description (manufacturer spec, calibration certificate, etc.).
54    pub source: &'static str,
55}
56
57/// Complete GUM uncertainty budget for the admissibility envelope.
58#[derive(Debug, Clone, Copy)]
59pub struct UncertaintyBudget {
60    /// Number of observations in the calibration window.
61    pub n_observations: usize,
62    /// Healthy window mean μ.
63    pub mean: f32,
64    /// Healthy window standard deviation σ.
65    pub std_dev: f32,
66    /// Type A standard uncertainty: u_A = σ / √N.
67    pub u_a: f32,
68    /// Combined Type B standard uncertainty: √(Σ u_B,i²).
69    pub u_b_combined: f32,
70    /// Combined standard uncertainty: u_c = √(u_A² + u_B²).
71    pub u_c: f32,
72    /// Coverage factor k (default: 3.0 for 99.7% confidence).
73    pub coverage_factor: f32,
74    /// Expanded uncertainty: U = k · u_c.
75    pub expanded_uncertainty: f32,
76    /// GUM-derived envelope radius: ρ = μ + U.
77    pub rho_gum: f32,
78    /// Whether the WSS pre-condition was satisfied.
79    pub wss_verified: bool,
80}
81
82/// Configuration for the GUM uncertainty budget.
83#[derive(Debug, Clone)]
84pub struct UncertaintyConfig {
85    /// Coverage factor k. Default 3.0 (99.7% confidence interval).
86    pub coverage_factor: f32,
87    /// Type B systematic uncertainty contributors.
88    /// Fixed-capacity array for no_alloc compatibility.
89    pub type_b: [Option<TypeBContributor>; 8],
90    /// Number of populated Type B entries.
91    pub type_b_count: usize,
92}
93
94impl Default for UncertaintyConfig {
95    fn default() -> Self {
96        Self {
97            coverage_factor: 3.0,
98            type_b: [None; 8],
99            type_b_count: 0,
100        }
101    }
102}
103
104impl UncertaintyConfig {
105    /// Create a config with typical RF receiver Type B contributors.
106    ///
107    /// Uses conservative estimates for common SDR receivers (USRP B200 class):
108    /// - Noise figure uncertainty: ±0.005 normalized norm units (~0.5 dB)
109    /// - ADC quantization: ±0.001 (14-bit ADC → Q ≈ 6e-5, Q/√12 ≈ 1.7e-5)
110    /// - Thermal gain drift: ±0.003 (0.02 dB/°C over ±10°C range)
111    pub fn typical_sdr() -> Self {
112        let mut cfg = Self::default();
113        cfg.add_type_b(TypeBContributor {
114            name: "receiver_noise_figure",
115            u_b: 0.005,
116            source: "manufacturer_specification_±0.5dB",
117        });
118        cfg.add_type_b(TypeBContributor {
119            name: "adc_quantization",
120            u_b: 0.001,
121            source: "14bit_ADC_Q_div_sqrt12",
122        });
123        cfg.add_type_b(TypeBContributor {
124            name: "thermal_gain_drift",
125            u_b: 0.003,
126            source: "0.02dB_per_C_over_10C_range",
127        });
128        cfg
129    }
130
131    /// Add a Type B contributor. Returns false if the array is full.
132    pub fn add_type_b(&mut self, contrib: TypeBContributor) -> bool {
133        if self.type_b_count >= 8 { return false; }
134        self.type_b[self.type_b_count] = Some(contrib);
135        self.type_b_count += 1;
136        true
137    }
138}
139
140/// Compute the GUM uncertainty budget from healthy-window norms.
141///
142/// Returns `None` if the window is empty.
143pub fn compute_budget(
144    healthy_norms: &[f32],
145    config: &UncertaintyConfig,
146    wss_verified: bool,
147) -> Option<UncertaintyBudget> {
148    if healthy_norms.is_empty() {
149        return None;
150    }
151
152    let n = healthy_norms.len();
153    let mean = mean_f32(healthy_norms);
154    let std_dev = std_dev_f32(healthy_norms);
155
156    // Type A: u_A = σ / √N
157    let u_a = std_dev / sqrt_f32(n as f32);
158
159    // Type B: u_B = √(Σ u_B,i²)
160    let mut u_b_sq = 0.0_f32;
161    for i in 0..config.type_b_count {
162        if let Some(ref c) = config.type_b[i] {
163            u_b_sq += c.u_b * c.u_b;
164        }
165    }
166    let u_b_combined = sqrt_f32(u_b_sq);
167
168    // Combined: u_c = √(u_A² + u_B²)
169    let u_c = sqrt_f32(u_a * u_a + u_b_combined * u_b_combined);
170
171    // Expanded: U = k · u_c
172    let expanded = config.coverage_factor * u_c;
173
174    // GUM-derived ρ: μ + U
175    let rho_gum = mean + expanded;
176
177    Some(UncertaintyBudget {
178        n_observations: n,
179        mean,
180        std_dev,
181        u_a,
182        u_b_combined,
183        u_c,
184        coverage_factor: config.coverage_factor,
185        expanded_uncertainty: expanded,
186        rho_gum,
187        wss_verified,
188    })
189}
190
191// ── CRLB Floor ─────────────────────────────────────────────────────────────
192//
193// Cramér-Rao Lower Bound for phase and frequency estimation.
194//
195// For a single complex tone in AWGN with N observations at linear SNR γ:
196//   CRLB_phase = 1 / (N · γ)           [rad²]          (Kay 1993, §3.7)
197//   CRLB_freq  = 6 / (N³ · γ · (2π)²) [normalized Hz²] (Rife & Boorstyn 1974)
198//
199// The physics-noise floor for the admissibility radius is:
200//   ρ_floor = 1 / √γ
201//
202// If the current ρ is less than MARGIN_FACTOR × ρ_floor, the envelope
203// is operating dangerously close to the theoretical noise floor.
204// Margins < 3× constitute a CRLB alert (the envelope cannot reliably
205// distinguish admissible drift from measurement noise).
206//
207// References:
208//   Kay, S.M. (1993) "Fundamentals of Statistical Signal Processing:
209//       Estimation Theory," Prentice Hall, §3.7.  ISBN 0-13-345711-7.
210//   Rife, D.C. and Boorstyn, R.R. (1974) "Single-tone parameter
211//       estimation from discrete-time observations," IEEE Trans.
212//       Inf. Theory, 20(5):591–598. doi:10.1109/TIT.1974.1055282.
213//   Van Trees, H.L. (1968) "Detection, Estimation, Modulation Theory,"
214//       Part I. Wiley. §2.4.
215
216/// Minimum margin factor to avoid operating in the noise-floor regime.
217pub const CRLB_MARGIN_THRESHOLD: f32 = 3.0;
218
219/// Cramér-Rao Lower Bound floor and admissibility margin.
220///
221/// Encapsulates the theoretical noise-floor limits for phase and frequency
222/// estimation at a given SNR and observation count.
223#[derive(Debug, Clone, Copy)]
224pub struct CrlbFloor {
225    /// SNR used for computation, in dB.
226    pub snr_db: f32,
227    /// Number of observations (calibration window length).
228    pub n_observations: usize,
229    /// CRLB for phase variance [rad²]: 1 / (N · γ).
230    pub crlb_phase_var: f32,
231    /// CRLB for frequency variance [normalized]: 6 / (N³ · γ · (2π)²).
232    pub crlb_freq_var: f32,
233    /// Physics noise floor for ρ: 1 / √γ.
234    pub rho_physics_floor: f32,
235    /// Whether the current ρ is above the physics floor.
236    pub rho_above_physics_floor: bool,
237    /// Margin factor: ρ / ρ_floor. Values < CRLB_MARGIN_THRESHOLD warrant alert.
238    pub rho_margin_factor: f32,
239    /// Whether a CRLB margin alert is raised (margin < threshold).
240    pub crlb_alert: bool,
241}
242
243/// Compute the CRLB floor and admissibility margin for the given SNR and ρ.
244///
245/// - `snr_db`:        Observed SNR in dB (typically the calibration-window SNR estimate).
246/// - `n_observations`: Number of observations in the calibration window.
247/// - `rho`:           Current admissibility radius (from GUM budget or direct assignment).
248///
249/// Returns `None` if `n_observations == 0` or `snr_db` is extremely negative (< −60 dB).
250pub fn compute_crlb_floor(
251    snr_db: f32,
252    n_observations: usize,
253    rho: f32,
254) -> Option<CrlbFloor> {
255    if n_observations == 0 { return None; }
256    if snr_db < -60.0 { return None; } // below practical floor
257
258    // Linear SNR: γ = 10^(snr_db / 10)
259    let gamma = pow10_approx(snr_db / 10.0);
260    if gamma <= 0.0 { return None; }
261
262    let n = n_observations as f32;
263    let n3 = n * n * n;
264    let two_pi_sq = 4.0 * 9.869_604_f32; // (2π)² = 4π²
265
266    // CRLB phase: 1 / (N · γ)
267    let crlb_phase = 1.0 / (n * gamma);
268    // CRLB freq: 6 / (N³ · γ · (2π)²)
269    let crlb_freq = 6.0 / (n3 * gamma * two_pi_sq);
270
271    // Physics noise floor for ρ
272    let rho_floor = 1.0 / crate::math::sqrt_f32(gamma);
273
274    let above = rho > rho_floor;
275    let margin = if rho_floor > 0.0 { rho / rho_floor } else { f32::MAX };
276    let alert = margin < CRLB_MARGIN_THRESHOLD;
277
278    Some(CrlbFloor {
279        snr_db,
280        n_observations,
281        crlb_phase_var: crlb_phase,
282        crlb_freq_var: crlb_freq,
283        rho_physics_floor: rho_floor,
284        rho_above_physics_floor: above,
285        rho_margin_factor: margin,
286        crlb_alert: alert,
287    })
288}
289
290// ── Private math helpers (no libm) ─────────────────────────────────────────
291
292/// Compute 10^x without libm by reducing to 2^(x · log₂10).
293///
294/// Accurate to < 0.3% for |x| ≤ 10 (covers SNR range −100 dB to +100 dB after /10).
295fn pow10_approx(x: f32) -> f32 {
296    // 10^x = 2^(x · log₂(10));  log₂(10) ≈ 3.321_928
297    pow2_approx(x * 3.321_928_f32)
298}
299
300/// Compute 2^y for arbitrary float y.
301///
302/// Splits y into integer n and fractional f parts, uses a 3-term Horner
303/// polynomial for 2^f, then scales by 2^n via repeated multiply/divide.
304fn pow2_approx(y: f32) -> f32 {
305    // Clamp to a safe range to avoid overflow/underflow
306    let y = if y > 120.0 { 120.0 } else if y < -120.0 { -120.0 } else { y };
307    let n = if y >= 0.0 { y as i32 } else { y as i32 - 1 };
308    let frac = y - n as f32; // ∈ [0, 1)
309    // 2^frac ≈ 1 + frac·(ln2 + frac·(ln2²/2 + frac·ln2³/6)) — Taylor of exp(frac·ln2)
310    let ln2 = 0.693_147_f32;
311    let mantissa = 1.0 + frac * (ln2 + frac * (0.240_226_f32 + frac * 0.055_504_f32));
312    // Scale by 2^n
313    if n >= 0 {
314        let mut acc = 1.0_f32;
315        for _ in 0..n { acc *= 2.0; }
316        acc * mantissa
317    } else {
318        let mut acc = 1.0_f32;
319        for _ in 0..(-n) { acc *= 0.5; }
320        acc * mantissa
321    }
322}
323
324// ── Tests ──────────────────────────────────────────────────────────────────
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn budget_from_constant_window() {
332        let norms = [0.05_f32; 100];
333        let config = UncertaintyConfig::default();
334        let budget = compute_budget(&norms, &config, true).unwrap();
335        // σ = 0, so u_A = 0, u_c = 0, ρ = μ = 0.05
336        assert!((budget.mean - 0.05).abs() < 1e-4);
337        assert!(budget.u_a < 1e-4, "u_A should be ~0 for constant window");
338        assert!((budget.rho_gum - 0.05).abs() < 1e-3);
339        assert!(budget.wss_verified);
340    }
341
342    #[test]
343    fn budget_with_type_b_contributors() {
344        let norms = [0.05_f32; 100];
345        let config = UncertaintyConfig::typical_sdr();
346        let budget = compute_budget(&norms, &config, true).unwrap();
347        // With Type B contributors, ρ_GUM > μ
348        assert!(budget.rho_gum > budget.mean,
349            "ρ_GUM must exceed mean with Type B contributors");
350        assert!(budget.u_b_combined > 0.0);
351        assert!(budget.u_c > 0.0);
352    }
353
354    #[test]
355    fn budget_type_a_decreases_with_n() {
356        let norms_small: [f32; 10] = core::array::from_fn(|i| 0.05 + i as f32 * 0.001);
357        let norms_large: [f32; 100] = core::array::from_fn(|i| 0.05 + (i % 10) as f32 * 0.001);
358        let config = UncertaintyConfig::default();
359        let b_small = compute_budget(&norms_small, &config, true).unwrap();
360        let b_large = compute_budget(&norms_large, &config, true).unwrap();
361        assert!(b_large.u_a < b_small.u_a,
362            "u_A must decrease with more observations: {} vs {}", b_large.u_a, b_small.u_a);
363    }
364
365    #[test]
366    fn budget_coverage_factor_scales() {
367        let norms: [f32; 50] = core::array::from_fn(|i| 0.05 + (i as f32 * 0.001).sin() * 0.01);
368        let mut cfg_k2 = UncertaintyConfig::default();
369        cfg_k2.coverage_factor = 2.0;
370        let mut cfg_k3 = UncertaintyConfig::default();
371        cfg_k3.coverage_factor = 3.0;
372        let b2 = compute_budget(&norms, &cfg_k2, true).unwrap();
373        let b3 = compute_budget(&norms, &cfg_k3, true).unwrap();
374        assert!(b3.expanded_uncertainty > b2.expanded_uncertainty,
375            "k=3 must give larger U than k=2");
376    }
377
378    #[test]
379    fn returns_none_for_empty() {
380        assert!(compute_budget(&[], &UncertaintyConfig::default(), true).is_none());
381    }
382
383    // ── CRLB Floor Tests ───────────────────────────────────────────────────
384
385    #[test]
386    fn crlb_returns_none_for_zero_obs() {
387        assert!(compute_crlb_floor(10.0, 0, 0.1).is_none());
388    }
389
390    #[test]
391    fn crlb_returns_none_below_practical_floor() {
392        assert!(compute_crlb_floor(-70.0, 100, 0.1).is_none());
393    }
394
395    #[test]
396    fn crlb_high_snr_low_variance() {
397        let c = compute_crlb_floor(30.0, 100, 0.2).unwrap();
398        // At 30 dB SNR with 100 obs, CRLB_phase = 1/(100·1000) = 1e-5
399        assert!(c.crlb_phase_var < 1e-3,
400            "high-SNR CRLB_phase should be very small: {}", c.crlb_phase_var);
401        assert!(c.rho_above_physics_floor);
402    }
403
404    #[test]
405    fn crlb_low_snr_alert_raised() {
406        // At -10 dB SNR, γ ≈ 0.1, ρ_floor = 1/√0.1 ≈ 3.16
407        // ρ = 0.5 → margin ≈ 0.16 << 3.0 → alert
408        let c = compute_crlb_floor(-10.0, 50, 0.5).unwrap();
409        assert!(c.crlb_alert,
410            "low-SNR with small ρ must raise CRLB alert: margin={}", c.rho_margin_factor);
411        assert!(!c.rho_above_physics_floor);
412    }
413
414    #[test]
415    fn crlb_rho_above_floor_no_alert_if_large_margin() {
416        // At 20 dB SNR, γ=100, ρ_floor = 1/10 = 0.1
417        // ρ = 0.5 → margin = 5.0 > 3.0 → no alert
418        let c = compute_crlb_floor(20.0, 100, 0.5).unwrap();
419        assert!(!c.crlb_alert,
420            "large margin must not alert: margin={}", c.rho_margin_factor);
421        assert!(c.rho_margin_factor > CRLB_MARGIN_THRESHOLD);
422    }
423
424    #[test]
425    fn crlb_freq_var_decreases_with_more_obs() {
426        let c100 = compute_crlb_floor(0.0, 100, 0.5).unwrap();
427        let c200 = compute_crlb_floor(0.0, 200, 0.5).unwrap();
428        assert!(c200.crlb_freq_var < c100.crlb_freq_var,
429            "CRLB_freq must decrease with more observations");
430    }
431}