use crate::math::{mean_f32, std_dev_f32, sqrt_f32};
#[derive(Debug, Clone, Copy)]
pub struct TypeBContributor {
pub name: &'static str,
pub u_b: f32,
pub source: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct UncertaintyBudget {
pub n_observations: usize,
pub mean: f32,
pub std_dev: f32,
pub u_a: f32,
pub u_b_combined: f32,
pub u_c: f32,
pub coverage_factor: f32,
pub expanded_uncertainty: f32,
pub rho_gum: f32,
pub wss_verified: bool,
}
#[derive(Debug, Clone)]
pub struct UncertaintyConfig {
pub coverage_factor: f32,
pub type_b: [Option<TypeBContributor>; 8],
pub type_b_count: usize,
}
impl Default for UncertaintyConfig {
fn default() -> Self {
Self {
coverage_factor: 3.0,
type_b: [None; 8],
type_b_count: 0,
}
}
}
impl UncertaintyConfig {
pub fn typical_sdr() -> Self {
let mut cfg = Self::default();
cfg.add_type_b(TypeBContributor {
name: "receiver_noise_figure",
u_b: 0.005,
source: "manufacturer_specification_±0.5dB",
});
cfg.add_type_b(TypeBContributor {
name: "adc_quantization",
u_b: 0.001,
source: "14bit_ADC_Q_div_sqrt12",
});
cfg.add_type_b(TypeBContributor {
name: "thermal_gain_drift",
u_b: 0.003,
source: "0.02dB_per_C_over_10C_range",
});
cfg
}
pub fn add_type_b(&mut self, contrib: TypeBContributor) -> bool {
if self.type_b_count >= 8 { return false; }
self.type_b[self.type_b_count] = Some(contrib);
self.type_b_count += 1;
true
}
}
pub fn compute_budget(
healthy_norms: &[f32],
config: &UncertaintyConfig,
wss_verified: bool,
) -> Option<UncertaintyBudget> {
if healthy_norms.is_empty() {
return None;
}
let n = healthy_norms.len();
let mean = mean_f32(healthy_norms);
let std_dev = std_dev_f32(healthy_norms);
let u_a = std_dev / sqrt_f32(n as f32);
let mut u_b_sq = 0.0_f32;
for i in 0..config.type_b_count {
if let Some(ref c) = config.type_b[i] {
u_b_sq += c.u_b * c.u_b;
}
}
let u_b_combined = sqrt_f32(u_b_sq);
let u_c = sqrt_f32(u_a * u_a + u_b_combined * u_b_combined);
let expanded = config.coverage_factor * u_c;
let rho_gum = mean + expanded;
Some(UncertaintyBudget {
n_observations: n,
mean,
std_dev,
u_a,
u_b_combined,
u_c,
coverage_factor: config.coverage_factor,
expanded_uncertainty: expanded,
rho_gum,
wss_verified,
})
}
pub const CRLB_MARGIN_THRESHOLD: f32 = 3.0;
#[derive(Debug, Clone, Copy)]
pub struct CrlbFloor {
pub snr_db: f32,
pub n_observations: usize,
pub crlb_phase_var: f32,
pub crlb_freq_var: f32,
pub rho_physics_floor: f32,
pub rho_above_physics_floor: bool,
pub rho_margin_factor: f32,
pub crlb_alert: bool,
}
pub fn compute_crlb_floor(
snr_db: f32,
n_observations: usize,
rho: f32,
) -> Option<CrlbFloor> {
if n_observations == 0 { return None; }
if snr_db < -60.0 { return None; }
let gamma = pow10_approx(snr_db / 10.0);
if gamma <= 0.0 { return None; }
let n = n_observations as f32;
let n3 = n * n * n;
let two_pi_sq = 4.0 * 9.869_604_f32;
let crlb_phase = 1.0 / (n * gamma);
let crlb_freq = 6.0 / (n3 * gamma * two_pi_sq);
let rho_floor = 1.0 / crate::math::sqrt_f32(gamma);
let above = rho > rho_floor;
let margin = if rho_floor > 0.0 { rho / rho_floor } else { f32::MAX };
let alert = margin < CRLB_MARGIN_THRESHOLD;
Some(CrlbFloor {
snr_db,
n_observations,
crlb_phase_var: crlb_phase,
crlb_freq_var: crlb_freq,
rho_physics_floor: rho_floor,
rho_above_physics_floor: above,
rho_margin_factor: margin,
crlb_alert: alert,
})
}
fn pow10_approx(x: f32) -> f32 {
pow2_approx(x * 3.321_928_f32)
}
fn pow2_approx(y: f32) -> f32 {
let y = if y > 120.0 { 120.0 } else if y < -120.0 { -120.0 } else { y };
let n = if y >= 0.0 { y as i32 } else { y as i32 - 1 };
let frac = y - n as f32; let ln2 = 0.693_147_f32;
let mantissa = 1.0 + frac * (ln2 + frac * (0.240_226_f32 + frac * 0.055_504_f32));
if n >= 0 {
let mut acc = 1.0_f32;
for _ in 0..n { acc *= 2.0; }
acc * mantissa
} else {
let mut acc = 1.0_f32;
for _ in 0..(-n) { acc *= 0.5; }
acc * mantissa
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn budget_from_constant_window() {
let norms = [0.05_f32; 100];
let config = UncertaintyConfig::default();
let budget = compute_budget(&norms, &config, true).unwrap();
assert!((budget.mean - 0.05).abs() < 1e-4);
assert!(budget.u_a < 1e-4, "u_A should be ~0 for constant window");
assert!((budget.rho_gum - 0.05).abs() < 1e-3);
assert!(budget.wss_verified);
}
#[test]
fn budget_with_type_b_contributors() {
let norms = [0.05_f32; 100];
let config = UncertaintyConfig::typical_sdr();
let budget = compute_budget(&norms, &config, true).unwrap();
assert!(budget.rho_gum > budget.mean,
"ρ_GUM must exceed mean with Type B contributors");
assert!(budget.u_b_combined > 0.0);
assert!(budget.u_c > 0.0);
}
#[test]
fn budget_type_a_decreases_with_n() {
let norms_small: [f32; 10] = core::array::from_fn(|i| 0.05 + i as f32 * 0.001);
let norms_large: [f32; 100] = core::array::from_fn(|i| 0.05 + (i % 10) as f32 * 0.001);
let config = UncertaintyConfig::default();
let b_small = compute_budget(&norms_small, &config, true).unwrap();
let b_large = compute_budget(&norms_large, &config, true).unwrap();
assert!(b_large.u_a < b_small.u_a,
"u_A must decrease with more observations: {} vs {}", b_large.u_a, b_small.u_a);
}
#[test]
fn budget_coverage_factor_scales() {
let norms: [f32; 50] = core::array::from_fn(|i| 0.05 + (i as f32 * 0.001).sin() * 0.01);
let mut cfg_k2 = UncertaintyConfig::default();
cfg_k2.coverage_factor = 2.0;
let mut cfg_k3 = UncertaintyConfig::default();
cfg_k3.coverage_factor = 3.0;
let b2 = compute_budget(&norms, &cfg_k2, true).unwrap();
let b3 = compute_budget(&norms, &cfg_k3, true).unwrap();
assert!(b3.expanded_uncertainty > b2.expanded_uncertainty,
"k=3 must give larger U than k=2");
}
#[test]
fn returns_none_for_empty() {
assert!(compute_budget(&[], &UncertaintyConfig::default(), true).is_none());
}
#[test]
fn crlb_returns_none_for_zero_obs() {
assert!(compute_crlb_floor(10.0, 0, 0.1).is_none());
}
#[test]
fn crlb_returns_none_below_practical_floor() {
assert!(compute_crlb_floor(-70.0, 100, 0.1).is_none());
}
#[test]
fn crlb_high_snr_low_variance() {
let c = compute_crlb_floor(30.0, 100, 0.2).unwrap();
assert!(c.crlb_phase_var < 1e-3,
"high-SNR CRLB_phase should be very small: {}", c.crlb_phase_var);
assert!(c.rho_above_physics_floor);
}
#[test]
fn crlb_low_snr_alert_raised() {
let c = compute_crlb_floor(-10.0, 50, 0.5).unwrap();
assert!(c.crlb_alert,
"low-SNR with small ρ must raise CRLB alert: margin={}", c.rho_margin_factor);
assert!(!c.rho_above_physics_floor);
}
#[test]
fn crlb_rho_above_floor_no_alert_if_large_margin() {
let c = compute_crlb_floor(20.0, 100, 0.5).unwrap();
assert!(!c.crlb_alert,
"large margin must not alert: margin={}", c.rho_margin_factor);
assert!(c.rho_margin_factor > CRLB_MARGIN_THRESHOLD);
}
#[test]
fn crlb_freq_var_decreases_with_more_obs() {
let c100 = compute_crlb_floor(0.0, 100, 0.5).unwrap();
let c200 = compute_crlb_floor(0.0, 200, 0.5).unwrap();
assert!(c200.crlb_freq_var < c100.crlb_freq_var,
"CRLB_freq must decrease with more observations");
}
}