use crate::constants::TAU;
use crate::StarfieldError;
use rand::Rng;
pub fn circular_mean(angles: &[f64]) -> Option<f64> {
if angles.is_empty() {
return None;
}
let s: f64 = angles.iter().map(|a| a.sin()).sum();
let c: f64 = angles.iter().map(|a| a.cos()).sum();
if s.hypot(c) < 1e-12 * angles.len() as f64 {
return None;
}
Some(s.atan2(c).rem_euclid(TAU))
}
pub fn mean_resultant_length(angles: &[f64]) -> f64 {
if angles.is_empty() {
return 0.0;
}
let n = angles.len() as f64;
let s: f64 = angles.iter().map(|a| a.sin()).sum();
let c: f64 = angles.iter().map(|a| a.cos()).sum();
s.hypot(c) / n
}
pub fn circular_std(angles: &[f64]) -> f64 {
let r = mean_resultant_length(angles);
if r <= 0.0 {
f64::INFINITY
} else if r >= 1.0 {
0.0
} else {
(-2.0 * r.ln()).sqrt()
}
}
pub fn rayleigh_z(angles: &[f64]) -> f64 {
let n = angles.len() as f64;
let r_bar = mean_resultant_length(angles);
n * r_bar * r_bar
}
pub fn rayleigh_p_value(angles: &[f64]) -> f64 {
let n = angles.len() as f64;
if n == 0.0 {
return 1.0;
}
let r_bar = mean_resultant_length(angles);
let z = n * r_bar * r_bar;
let p = (-z).exp()
* (1.0 + (2.0 * z - z * z) / (4.0 * n)
- (24.0 * z - 132.0 * z * z + 76.0 * z.powi(3) - 9.0 * z.powi(4)) / (288.0 * n * n));
p.clamp(0.0, 1.0)
}
pub fn kuiper_statistic(angles: &[f64]) -> f64 {
let n = angles.len();
if n == 0 {
return 0.0;
}
let mut u: Vec<f64> = angles.iter().map(|a| a.rem_euclid(TAU) / TAU).collect();
u.sort_by(|a, b| a.partial_cmp(b).unwrap());
let nf = n as f64;
let mut d_plus: f64 = 0.0;
let mut d_minus: f64 = 0.0;
for (i, &ui) in u.iter().enumerate() {
d_plus = d_plus.max((i as f64 + 1.0) / nf - ui);
d_minus = d_minus.max(ui - i as f64 / nf);
}
d_plus + d_minus
}
pub fn kuiper_p_value(angles: &[f64]) -> f64 {
let n = angles.len() as f64;
if n < 2.0 {
return 1.0;
}
let v = kuiper_statistic(angles);
let lambda = (n.sqrt() + 0.155 + 0.24 / n.sqrt()) * v;
if lambda < 0.4 {
return 1.0;
}
let mut p = 0.0;
for j in 1..=100 {
let jf = j as f64;
let a = 4.0 * jf * jf * lambda * lambda;
let term = (2.0 * a - 2.0) * (-0.5 * a).exp();
p += term;
if term.abs() < 1e-12 {
break;
}
}
p.clamp(0.0, 1.0)
}
pub fn wrap_to_pi(angle: f64) -> f64 {
let mut a = angle.rem_euclid(TAU);
if a > std::f64::consts::PI {
a -= TAU;
}
a
}
#[derive(Debug, Clone)]
pub struct JointSignificance {
pub observed_z_a: f64,
pub observed_z_b: f64,
pub p_a: f64,
pub p_b: f64,
pub p_joint: f64,
pub n_iterations: usize,
}
pub fn monte_carlo_joint_significance(
angles_a: &[f64],
angles_b: &[f64],
n_iterations: usize,
rng: &mut impl Rng,
) -> JointSignificance {
let observed_z_a = rayleigh_z(angles_a);
let observed_z_b = rayleigh_z(angles_b);
let mut count_a = 0usize;
let mut count_b = 0usize;
let mut count_both = 0usize;
let mut sample_a = vec![0.0; angles_a.len()];
let mut sample_b = vec![0.0; angles_b.len()];
for _ in 0..n_iterations {
for a in sample_a.iter_mut() {
*a = rng.random::<f64>() * TAU;
}
for b in sample_b.iter_mut() {
*b = rng.random::<f64>() * TAU;
}
let z_a = rayleigh_z(&sample_a);
let z_b = rayleigh_z(&sample_b);
let a_exceeds = z_a >= observed_z_a;
let b_exceeds = z_b >= observed_z_b;
if a_exceeds {
count_a += 1;
}
if b_exceeds {
count_b += 1;
}
if a_exceeds && b_exceeds {
count_both += 1;
}
}
JointSignificance {
observed_z_a,
observed_z_b,
p_a: count_a as f64 / n_iterations as f64,
p_b: count_b as f64 / n_iterations as f64,
p_joint: count_both as f64 / n_iterations as f64,
n_iterations,
}
}
pub fn monte_carlo_joint_significance_with<R: Rng, F: FnMut(&mut R) -> (f64, f64)>(
angles_a: &[f64],
angles_b: &[f64],
n_iterations: usize,
rng: &mut R,
mut draw: F,
) -> Result<JointSignificance, StarfieldError> {
if angles_a.len() != angles_b.len() {
return Err(StarfieldError::DataError(format!(
"joint significance with a paired null requires equal-length angle sets ({} vs {})",
angles_a.len(),
angles_b.len()
)));
}
let observed_z_a = rayleigh_z(angles_a);
let observed_z_b = rayleigh_z(angles_b);
let n = angles_a.len();
let mut count_a = 0usize;
let mut count_b = 0usize;
let mut count_both = 0usize;
let mut sample_a = vec![0.0; n];
let mut sample_b = vec![0.0; n];
for _ in 0..n_iterations {
for k in 0..n {
let (a, b) = draw(rng);
sample_a[k] = a;
sample_b[k] = b;
}
let z_a = rayleigh_z(&sample_a);
let z_b = rayleigh_z(&sample_b);
let a_exceeds = z_a >= observed_z_a;
let b_exceeds = z_b >= observed_z_b;
if a_exceeds {
count_a += 1;
}
if b_exceeds {
count_b += 1;
}
if a_exceeds && b_exceeds {
count_both += 1;
}
}
Ok(JointSignificance {
observed_z_a,
observed_z_b,
p_a: count_a as f64 / n_iterations as f64,
p_b: count_b as f64 / n_iterations as f64,
p_joint: count_both as f64 / n_iterations as f64,
n_iterations,
})
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use rand::SeedableRng;
use std::f64::consts::PI;
#[test]
fn test_circular_mean_wraps_correctly() {
let angles = [0.1, TAU - 0.1];
let mean = circular_mean(&angles).unwrap();
assert!(!(0.01..=TAU - 0.01).contains(&mean), "mean = {mean}");
}
#[test]
fn test_circular_mean_balanced_is_none() {
let angles = [0.0, PI / 2.0, PI, 3.0 * PI / 2.0];
assert!(circular_mean(&angles).is_none());
assert!(circular_mean(&[]).is_none());
}
#[test]
fn test_resultant_length_limits() {
assert_relative_eq!(
mean_resultant_length(&[1.0, 1.0, 1.0]),
1.0,
epsilon = 1e-12
);
assert!(mean_resultant_length(&[0.0, PI]) < 1e-12);
}
#[test]
fn test_rayleigh_clustered_vs_uniform() {
let clustered: Vec<f64> = (0..10).map(|k| 1.0 + 0.05 * k as f64).collect();
assert!(rayleigh_p_value(&clustered) < 1e-3);
let uniform: Vec<f64> = (0..10).map(|k| k as f64 * TAU / 10.0).collect();
assert!(rayleigh_p_value(&uniform) > 0.9);
}
#[test]
fn test_rayleigh_small_n_correction_matters() {
let angles = [0.0, 0.4, 0.9, 1.5, 2.2, 3.0];
let n = angles.len() as f64;
let r = mean_resultant_length(&angles);
let z = n * r * r;
let naive = (-z).exp();
let corrected = rayleigh_p_value(&angles);
assert!(
(naive - corrected).abs() / naive > 0.01,
"correction too small: naive {naive:.4}, corrected {corrected:.4}"
);
}
#[test]
fn test_rayleigh_z_clustered() {
let angles = vec![1.0; 10];
let z = rayleigh_z(&angles);
assert!(
(z - 10.0).abs() < 0.01,
"z = {z:.2}, expected 10.0 for identical angles"
);
}
#[test]
fn test_rayleigh_z_uniform() {
let n = 100;
let angles: Vec<f64> = (0..n).map(|i| TAU * i as f64 / n as f64).collect();
let z = rayleigh_z(&angles);
assert!(z < 0.1, "z = {z:.4}, expected near 0 for uniform");
}
#[test]
fn test_kuiper_uniform_vs_clustered() {
let uniform: Vec<f64> = (0..20).map(|k| k as f64 * TAU / 20.0).collect();
let clustered: Vec<f64> = (0..20).map(|k| 1.0 + 0.02 * k as f64).collect();
assert!(kuiper_statistic(&clustered) > kuiper_statistic(&uniform));
assert!(kuiper_p_value(&clustered) < 0.01);
assert!(kuiper_p_value(&uniform) > 0.5);
}
#[test]
fn test_kuiper_rotation_invariant() {
let angles: Vec<f64> = vec![0.2, 1.1, 2.0, 4.5, 5.9];
let rotated: Vec<f64> = angles.iter().map(|a| (a + 2.7).rem_euclid(TAU)).collect();
assert_relative_eq!(
kuiper_statistic(&angles),
kuiper_statistic(&rotated),
epsilon = 1e-12
);
}
#[test]
fn test_wrap_to_pi() {
assert_relative_eq!(wrap_to_pi(3.0 * PI), PI, epsilon = 1e-12);
assert_relative_eq!(wrap_to_pi(-0.5), -0.5, epsilon = 1e-12);
assert_relative_eq!(wrap_to_pi(TAU + 0.5), 0.5, epsilon = 1e-12);
}
#[test]
fn test_joint_significance_clustered() {
let a: Vec<f64> = (0..10).map(|k| 1.0 + 0.05 * k as f64).collect();
let b: Vec<f64> = (0..10).map(|k| 4.0 + 0.05 * k as f64).collect();
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let result = monte_carlo_joint_significance(&a, &b, 5000, &mut rng);
assert!(result.p_a < 0.01, "p_a = {}", result.p_a);
assert!(result.p_b < 0.01, "p_b = {}", result.p_b);
assert!(result.p_joint <= result.p_a.min(result.p_b));
assert_eq!(result.n_iterations, 5000);
}
#[test]
fn test_joint_significance_uniform_not_significant() {
let a: Vec<f64> = (0..10).map(|k| k as f64 * TAU / 10.0).collect();
let b: Vec<f64> = (0..10).map(|k| (k as f64 + 0.5) * TAU / 10.0).collect();
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let result = monte_carlo_joint_significance(&a, &b, 2000, &mut rng);
assert!(result.p_a > 0.5, "p_a = {}", result.p_a);
assert!(result.p_b > 0.5, "p_b = {}", result.p_b);
}
#[test]
fn test_joint_significance_seeded() {
let a: Vec<f64> = (0..8).map(|k| 1.0 + 0.2 * k as f64).collect();
let b: Vec<f64> = (0..8).map(|k| 2.0 + 0.3 * k as f64).collect();
let mut rng1 = rand::rngs::StdRng::seed_from_u64(7);
let mut rng2 = rand::rngs::StdRng::seed_from_u64(7);
let r1 = monte_carlo_joint_significance(&a, &b, 1000, &mut rng1);
let r2 = monte_carlo_joint_significance(&a, &b, 1000, &mut rng2);
assert_eq!(r1.p_joint, r2.p_joint);
}
#[test]
fn test_joint_significance_with_uniform_null_matches() {
let a: Vec<f64> = (0..10).map(|k| 1.0 + 0.05 * k as f64).collect();
let b: Vec<f64> = (0..10).map(|k| 4.0 + 0.05 * k as f64).collect();
let mut rng = rand::rngs::StdRng::seed_from_u64(13);
let result = monte_carlo_joint_significance_with(&a, &b, 5000, &mut rng, |rng| {
(rng.random::<f64>() * TAU, rng.random::<f64>() * TAU)
})
.unwrap();
assert!(result.p_joint < 0.01, "p_joint = {}", result.p_joint);
}
#[test]
fn test_joint_significance_with_rejects_mismatched_lengths() {
let a = [0.0, 1.0, 2.0];
let b = [0.0, 1.0];
let mut rng = rand::rngs::StdRng::seed_from_u64(1);
let result = monte_carlo_joint_significance_with(&a, &b, 10, &mut rng, |rng| {
(rng.random::<f64>(), rng.random::<f64>())
});
assert!(result.is_err());
}
}