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}