use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
use std::f64::consts::SQRT_2;
pub fn erf(x: f64) -> f64 {
let sign = if x < 0.0 { -1.0 } else { 1.0 };
let x = x.abs();
let t = 1.0 / (1.0 + 0.327_591_1 * x);
let y = 1.0
- (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
+ 0.254_829_592)
* t
* (-x * x).exp();
sign * y
}
pub fn normal_cdf(x: f64) -> f64 {
0.5 * (1.0 + erf(x / SQRT_2))
}
pub fn normal_inv_cdf(p: f64) -> f64 {
if p <= 0.0 {
return f64::NEG_INFINITY;
}
if p >= 1.0 {
return f64::INFINITY;
}
const A: [f64; 6] = [
-3.969_683_028_665_376e1,
2.209_460_984_245_205e2,
-2.759_285_104_469_687e2,
1.383_577_518_672_69e2,
-3.066_479_806_614_716e1,
2.506_628_277_459_239e0,
];
const B: [f64; 5] = [
-5.447_609_879_822_406e1,
1.615_858_368_580_409e2,
-1.556_989_798_598_866e2,
6.680_131_188_771_972e1,
-1.328_068_155_288_572e1,
];
const C: [f64; 6] = [
-7.784_894_002_430_293e-3,
-3.223_964_580_411_365e-1,
-2.400_758_277_161_838e0,
-2.549_732_539_343_734e0,
4.374_664_141_464_968e0,
2.938_163_982_698_783e0,
];
const D: [f64; 4] = [
7.784_695_709_041_462e-3,
3.224_671_290_700_398e-1,
2.445_134_137_142_996e0,
3.754_408_661_907_416e0,
];
let p_low = 0.024_25;
let p_high = 1.0 - p_low;
if p < p_low {
let q = (-2.0 * p.ln()).sqrt();
(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
} else if p <= p_high {
let q = p - 0.5;
let r = q * q;
(((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q
/ (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0)
} else {
let q = (-2.0 * (1.0 - p).ln()).sqrt();
-(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5])
/ ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0)
}
}
pub fn llr(y: f64, mu: f64, sigma: f64) -> f64 {
let s2 = sigma * sigma;
(mu * y) / s2 - (mu * mu) / (2.0 * s2)
}
pub fn detection_boundary(sigma: f64, p_fa: f64) -> f64 {
sigma * normal_inv_cdf(1.0 - p_fa / 2.0)
}
pub fn chi2_1_threshold(p_fa: f64) -> f64 {
let z = normal_inv_cdf(1.0 - p_fa / 2.0);
z * z
}
pub fn chi2_inv_cdf(p: f64, dof: f64) -> f64 {
let k = dof.max(1e-9);
let z = normal_inv_cdf(p.clamp(1e-12, 1.0 - 1e-12));
let a = 2.0 / (9.0 * k);
let t = 1.0 - a + z * a.sqrt();
(k * t * t * t).max(0.0)
}
pub fn analytic_pmd(mu: f64, sigma: f64, gamma: f64) -> f64 {
let s = sigma.max(1e-300);
(normal_cdf((gamma - mu) / s) - normal_cdf((-gamma - mu) / s)).clamp(0.0, 1.0)
}
pub fn analytic_pd(mu: f64, sigma: f64, gamma: f64) -> f64 {
1.0 - analytic_pmd(mu, sigma, gamma)
}
pub fn monte_carlo_pfa_pmd(mu: f64, sigma: f64, gamma: f64, n: usize, seed: u64) -> (f64, f64) {
let n = n.max(1);
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let h0 = Normal::new(0.0, sigma.max(1e-300)).unwrap();
let h1 = Normal::new(mu, sigma.max(1e-300)).unwrap();
let mut false_alarms = 0usize;
let mut misses = 0usize;
for _ in 0..n {
if h0.sample(&mut rng).abs() > gamma {
false_alarms += 1;
}
if h1.sample(&mut rng).abs() <= gamma {
misses += 1;
}
}
(false_alarms as f64 / n as f64, misses as f64 / n as f64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn erf_and_normal_cdf_match_textbook_values() {
assert!((erf(0.0)).abs() < 1e-9);
assert!((erf(1.0) - 0.842_700_79).abs() < 1e-6);
assert!((erf(0.5) - 0.520_499_88).abs() < 1e-6);
assert!((erf(-1.0) + 0.842_700_79).abs() < 1e-6);
assert!((normal_cdf(0.0) - 0.5).abs() < 1e-9);
assert!((normal_cdf(1.0) - 0.841_344_75).abs() < 1e-6);
assert!((normal_cdf(2.0) - 0.977_249_87).abs() < 1e-6);
assert!((normal_cdf(-1.0) - 0.158_655_25).abs() < 1e-6);
}
#[test]
fn inverse_cdf_inverts_the_cdf() {
assert!((normal_inv_cdf(0.5)).abs() < 1e-9);
assert!((normal_inv_cdf(0.975) - 1.959_963_98).abs() < 1e-6);
assert!((normal_inv_cdf(0.99) - 2.326_347_87).abs() < 1e-6);
assert!((normal_inv_cdf(0.995) - 2.575_829_30).abs() < 1e-6);
for &p in &[0.001, 0.05, 0.3, 0.7, 0.95, 0.999] {
assert!((normal_cdf(normal_inv_cdf(p)) - p).abs() < 1e-6, "p={p}");
}
}
#[test]
fn analytic_pmd_matches_hand_computation() {
let gamma = detection_boundary(1.0, 0.01);
assert!((gamma - 2.575_829_30).abs() < 1e-5, "gamma={gamma}");
let pmd = analytic_pmd(2.0, 1.0, gamma);
assert!((pmd - 0.717_67).abs() < 1e-3, "pmd={pmd}");
assert!(analytic_pmd(10.0, 1.0, gamma) < 1e-6);
assert!((chi2_1_threshold(0.01) - gamma * gamma).abs() < 1e-9);
}
#[test]
fn chi2_inv_cdf_matches_table_values() {
for &(p, dof, table, tol) in &[
(0.95, 2.0, 5.991, 0.06),
(0.95, 5.0, 11.070, 0.05),
(0.95, 10.0, 18.307, 0.03),
(0.95, 50.0, 67.505, 0.05),
(0.975, 100.0, 129.561, 0.1),
(0.025, 100.0, 74.222, 0.1),
] {
let got = chi2_inv_cdf(p, dof);
assert!(
(got - table).abs() < tol,
"chi2_inv_cdf(p={p}, dof={dof}) = {got}, table {table}"
);
}
assert!(chi2_inv_cdf(0.5, 4.0) < 4.0 && chi2_inv_cdf(0.5, 4.0) > 3.0);
}
#[test]
fn llr_threshold_is_equivalent_to_a_y_threshold() {
let (mu, sigma) = (3.0, 1.5);
assert!(llr(1.0, mu, sigma) < llr(2.0, mu, sigma));
assert!((llr(mu / 2.0, mu, sigma)).abs() < 1e-12);
}
#[test]
fn monte_carlo_recovers_the_analytic_operating_point() {
let (sigma, p_fa, mu) = (1.0, 0.01, 2.0);
let gamma = detection_boundary(sigma, p_fa);
let pmd_analytic = analytic_pmd(mu, sigma, gamma);
let (mc_pfa, mc_pmd) = monte_carlo_pfa_pmd(mu, sigma, gamma, 200_000, 12345);
assert!((mc_pfa - p_fa).abs() < 0.003, "mc_pfa={mc_pfa}");
assert!(
(mc_pmd - pmd_analytic).abs() / pmd_analytic < 0.03,
"mc_pmd={mc_pmd} vs analytic={pmd_analytic}"
);
}
#[test]
fn monte_carlo_is_deterministic_in_the_seed() {
let g = detection_boundary(1.0, 0.01);
assert_eq!(
monte_carlo_pfa_pmd(2.0, 1.0, g, 10_000, 7),
monte_carlo_pfa_pmd(2.0, 1.0, g, 10_000, 7)
);
}
}