use crate::types::Seconds;
use serde::Serialize;
pub fn overlapping_adev(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let n = phase.len();
assert!(m >= 1, "m must be >= 1");
assert!(n > 2 * m, "need more than 2m phase samples");
let tau = m as f64 * tau0;
let count = n - 2 * m;
let mut sumsq = 0.0;
for i in 0..count {
let d = phase[i + 2 * m] - 2.0 * phase[i + m] + phase[i];
sumsq += d * d;
}
(sumsq / (2.0 * count as f64 * tau * tau)).sqrt()
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct AdevPoint {
pub tau_s: f64,
pub adev: f64,
pub n_samples: usize,
#[serde(default)]
pub noise: Option<PowerLawNoise>,
#[serde(default)]
pub edf: f64,
#[serde(default)]
pub ci_lo: f64,
#[serde(default)]
pub ci_hi: f64,
}
pub fn overlapping_adev_curve(phase: &[f64], tau0: Seconds) -> Vec<AdevPoint> {
const MIN_OVERLAPS: usize = 8;
const CONF: f64 = 0.95;
let n = phase.len();
let noise = classify_power_law(phase, tau0);
let mut out = Vec::new();
let mut m = 1usize;
while n > 2 * m && (n - 2 * m) >= MIN_OVERLAPS {
let adev = overlapping_adev(phase, tau0, m);
let edf = match noise {
Some(nz) => edf_overlapping_adev(nz, n, m),
None => conservative_edf(n, m),
};
let ci = deviation_ci(adev, edf, CONF);
out.push(AdevPoint {
tau_s: m as f64 * tau0,
adev,
n_samples: n - 2 * m,
noise,
edf,
ci_lo: ci.lo,
ci_hi: ci.hi,
});
m *= 2;
}
out
}
pub fn modified_adev(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let n = phase.len();
assert!(m >= 1, "m must be >= 1");
assert!(n > 3 * m, "need at least 3m+1 phase samples for MDEV");
let tau = m as f64 * tau0;
let outer = n - 3 * m + 1; let second_diff = |i: usize| phase[i + 2 * m] - 2.0 * phase[i + m] + phase[i];
let mut inner: f64 = (0..m).map(second_diff).sum();
let mut acc = inner * inner;
for j in 1..outer {
inner += second_diff(j + m - 1) - second_diff(j - 1);
acc += inner * inner;
}
let mm = m as f64;
(acc / (2.0 * mm * mm * tau * tau * outer as f64)).sqrt()
}
pub fn time_deviation(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let tau = m as f64 * tau0;
tau / 3.0_f64.sqrt() * modified_adev(phase, tau0, m)
}
pub fn hadamard_adev(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let n = phase.len();
assert!(m >= 1, "m must be >= 1");
assert!(n > 3 * m, "need more than 3m phase samples for HDEV");
let tau = m as f64 * tau0;
let count = n - 3 * m;
let mut sumsq = 0.0;
for i in 0..count {
let d = phase[i + 3 * m] - 3.0 * phase[i + 2 * m] + 3.0 * phase[i + m] - phase[i];
sumsq += d * d;
}
(sumsq / (6.0 * tau * tau * count as f64)).sqrt()
}
fn normal_quantile(p: f64) -> f64 {
assert!(p > 0.0 && p < 1.0, "quantile probability must be in (0,1)");
const A: [f64; 6] = [
-3.969683028665376e+01,
2.209460984245205e+02,
-2.759285104469687e+02,
1.383_577_518_672_69e2,
-3.066479806614716e+01,
2.506628277459239e+00,
];
const B: [f64; 5] = [
-5.447609879822406e+01,
1.615858368580409e+02,
-1.556989798598866e+02,
6.680131188771972e+01,
-1.328068155288572e+01,
];
const C: [f64; 6] = [
-7.784894002430293e-03,
-3.223964580411365e-01,
-2.400758277161838e+00,
-2.549732539343734e+00,
4.374664141464968e+00,
2.938163982698783e+00,
];
const D: [f64; 4] = [
7.784695709041462e-03,
3.224671290700398e-01,
2.445134137142996e+00,
3.754408661907416e+00,
];
let plow = 0.02425;
let phigh = 1.0 - plow;
if p < plow {
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 <= phigh {
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)
}
}
fn chi2_quantile(p: f64, nu: f64) -> f64 {
let z = normal_quantile(p);
let t = 2.0 / (9.0 * nu);
let base = 1.0 - t + z * t.sqrt();
nu * base * base * base
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DeviationCi {
pub dev: f64,
pub lo: f64,
pub hi: f64,
pub edf: f64,
}
pub fn deviation_ci(dev: f64, edf: f64, conf: f64) -> DeviationCi {
assert!(edf > 0.0 && conf > 0.0 && conf < 1.0);
let alpha = 1.0 - conf;
let chi2_hi = chi2_quantile(1.0 - alpha / 2.0, edf); let chi2_lo = chi2_quantile(alpha / 2.0, edf); DeviationCi {
dev,
lo: dev * (edf / chi2_hi).sqrt(),
hi: dev * (edf / chi2_lo).sqrt(),
edf,
}
}
pub fn conservative_edf(n: usize, m: usize) -> f64 {
((n / m).saturating_sub(1)).max(1) as f64
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum PowerLawNoise {
WhitePm,
FlickerPm,
WhiteFm,
FlickerFm,
RandomWalkFm,
}
impl PowerLawNoise {
pub fn alpha(self) -> i32 {
match self {
PowerLawNoise::WhitePm => 2,
PowerLawNoise::FlickerPm => 1,
PowerLawNoise::WhiteFm => 0,
PowerLawNoise::FlickerFm => -1,
PowerLawNoise::RandomWalkFm => -2,
}
}
pub fn from_mdev_slope(mu: f64) -> Self {
match ((2.0 * mu).round() as i32).clamp(-3, 1) {
-3 => PowerLawNoise::WhitePm,
-2 => PowerLawNoise::FlickerPm,
-1 => PowerLawNoise::WhiteFm,
0 => PowerLawNoise::FlickerFm,
_ => PowerLawNoise::RandomWalkFm,
}
}
}
pub fn edf_overlapping_adev(noise: PowerLawNoise, n: usize, m: usize) -> f64 {
let nn = n as f64;
let mm = m as f64;
let edf = match noise {
PowerLawNoise::WhitePm => (nn + 1.0) * (nn - 2.0 * mm) / (2.0 * (nn - mm)),
PowerLawNoise::FlickerPm => {
let a = (nn - 1.0) / mm;
let b = (2.0 * mm + 1.0) * (nn - 1.0) / 4.0;
(a.ln() * b.ln()).sqrt().exp()
}
PowerLawNoise::WhiteFm => {
(3.0 * (nn - 1.0) / (2.0 * mm) - 2.0 * (nn - 2.0) / nn)
* (4.0 * mm * mm / (4.0 * mm * mm + 5.0))
}
PowerLawNoise::FlickerFm => {
if m == 1 {
2.0 * (nn - 2.0) / (2.3 * nn - 4.9)
} else {
5.0 * nn * nn / (4.0 * mm * (nn + 3.0 * mm))
}
}
PowerLawNoise::RandomWalkFm => {
let nm1 = nn - 1.0;
let a = (nn - 2.0) / (mm * nm1 * nm1);
let b = nm1 * nm1 - 3.0 * mm * nm1 + 4.0 * mm * mm;
a * b
}
};
edf.max(1.0)
}
pub fn classify_power_law(phase: &[f64], tau0: Seconds) -> Option<PowerLawNoise> {
let n = phase.len();
let mut pts: Vec<(f64, f64)> = Vec::new();
let mut m = 1usize;
while n > 3 * m && (n - 3 * m + 1) >= 8 {
let md = modified_adev(phase, tau0, m);
if md > 0.0 {
pts.push(((m as f64 * tau0).log10(), md.log10()));
}
m *= 2;
}
if pts.len() < 3 {
return None;
}
let k = pts.len() as f64;
let sx: f64 = pts.iter().map(|p| p.0).sum();
let sy: f64 = pts.iter().map(|p| p.1).sum();
let sxx: f64 = pts.iter().map(|p| p.0 * p.0).sum();
let sxy: f64 = pts.iter().map(|p| p.0 * p.1).sum();
let slope = (k * sxy - sx * sy) / (k * sxx - sx * sx);
Some(PowerLawNoise::from_mdev_slope(slope))
}
pub fn theo1_tau(tau0: Seconds, m: usize) -> f64 {
0.75 * m as f64 * tau0
}
pub fn theo1(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let n = phase.len();
assert!(
m >= 2 && m % 2 == 0,
"Theo1 requires an even averaging factor m >= 2"
);
assert!(n > m, "need more than m phase samples for Theo1");
let half = m / 2;
let count = n - m; let mut acc = 0.0;
for i in 0..count {
let mut inner = 0.0;
for d in 0..half {
let w = 1.0 / (half - d) as f64;
let term = (phase[i] - phase[i + half - d]) + (phase[i + m] - phase[i + half + d]);
inner += w * term * term;
}
acc += inner;
}
let tau = m as f64 * tau0;
(acc / (0.75 * count as f64 * tau * tau)).sqrt()
}
pub fn theo1_curve(phase: &[f64], tau0: Seconds) -> Vec<AdevPoint> {
let n = phase.len();
let mut out = Vec::new();
if n < 3 {
return out;
}
let mut m = 2usize;
while m < n {
out.push(AdevPoint {
tau_s: theo1_tau(tau0, m),
adev: theo1(phase, tau0, m),
n_samples: n - m,
noise: None,
edf: 0.0,
ci_lo: 0.0,
ci_hi: 0.0,
});
m *= 2;
}
out
}
pub fn theo_br_bias(phase: &[f64], tau0: Seconds) -> f64 {
let n = phase.len();
if n < 24 {
return 1.0;
}
let kmax = (n / 6).saturating_sub(3); let mut sum = 0.0;
let mut count = 0.0;
for i in 0..=kmax {
let k_av = 9 + 3 * i; let k_th = 12 + 4 * i; if n <= 2 * k_av || n <= k_th {
break;
}
let avar = overlapping_adev(phase, tau0, k_av).powi(2);
let theo = theo1(phase, tau0, k_th).powi(2);
if theo > 0.0 {
sum += avar / theo;
count += 1.0;
}
}
if count > 0.0 {
sum / count
} else {
1.0
}
}
pub fn theo_br(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let t1 = theo1(phase, tau0, m);
(theo_br_bias(phase, tau0) * t1 * t1).sqrt()
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct TheoHPoint {
pub tau_s: f64,
pub dev: f64,
pub n_samples: usize,
pub from_theo: bool,
}
pub fn theoh_curve(phase: &[f64], tau0: Seconds) -> Vec<TheoHPoint> {
let n = phase.len();
let mut out = Vec::new();
if n < 24 {
return out;
}
let adev_max_m = (n / 10).max(1);
let mut m = 1usize;
while m <= adev_max_m && n > 2 * m {
out.push(TheoHPoint {
tau_s: m as f64 * tau0,
dev: overlapping_adev(phase, tau0, m),
n_samples: n - 2 * m,
from_theo: false,
});
m *= 2;
}
let bias = theo_br_bias(phase, tau0);
let last_tau = out.last().map(|p| p.tau_s).unwrap_or(tau0);
let mut em = (((last_tau / tau0) / 0.75).ceil() as usize).max(2);
if em % 2 == 1 {
em += 1;
}
let em_max = (n - 1) & !1usize;
let mut last_em = 0usize;
while em <= em_max {
let t1 = theo1(phase, tau0, em);
out.push(TheoHPoint {
tau_s: theo1_tau(tau0, em),
dev: (bias * t1 * t1).sqrt(),
n_samples: n - em,
from_theo: true,
});
last_em = em;
let next = (em * 2).max(em + 2);
em = if next % 2 == 0 { next } else { next + 1 };
}
if em_max >= 2 && last_em < em_max {
let t1 = theo1(phase, tau0, em_max);
out.push(TheoHPoint {
tau_s: theo1_tau(tau0, em_max),
dev: (bias * t1 * t1).sqrt(),
n_samples: n - em_max,
from_theo: true,
});
}
out
}
pub fn total_deviation(phase: &[f64], tau0: Seconds, m: usize) -> f64 {
let n = phase.len();
assert!(m >= 1, "m must be >= 1");
assert!(n >= 3, "need at least 3 phase samples for TOTVAR");
assert!(m < n, "TOTVAR requires m <= N-1");
let ext_len = 3 * n - 4;
let mut x = vec![0.0_f64; ext_len];
for (k, slot) in x.iter_mut().take(n - 2).enumerate() {
*slot = 2.0 * phase[0] - phase[n - 2 - k];
}
x[(n - 2)..(2 * n - 2)].copy_from_slice(phase);
for k in 0..(n - 2) {
x[2 * n - 2 + k] = 2.0 * phase[n - 1] - phase[n - 2 - k];
}
let mid = n - 2; let mut acc = 0.0;
for j in 0..(n - 2) {
let c = mid + 1 + j;
let v = x[c - m] - 2.0 * x[c] + x[c + m];
acc += v * v;
}
let tau = m as f64 * tau0;
(acc / (2.0 * tau * tau * (n - 2) as f64)).sqrt()
}
pub fn total_deviation_curve(phase: &[f64], tau0: Seconds) -> Vec<AdevPoint> {
let n = phase.len();
let mut out = Vec::new();
if n < 3 {
return out;
}
let mut m = 1usize;
while m < n {
out.push(AdevPoint {
tau_s: m as f64 * tau0,
adev: total_deviation(phase, tau0, m),
n_samples: n - 2,
noise: None,
edf: 0.0,
ci_lo: 0.0,
ci_hi: 0.0,
});
m *= 2;
}
out
}
#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
pub struct MtiePoint {
pub tau_s: f64,
pub mtie_s: f64,
pub n_windows: usize,
}
pub fn mtie(phase: &[f64], m: usize) -> f64 {
assert!(m >= 1, "m must be >= 1");
let win = m + 1;
assert!(
phase.len() >= win,
"need at least m+1 phase samples for MTIE"
);
let mut worst = 0.0_f64;
for seg in phase.windows(win) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for &x in seg {
lo = lo.min(x);
hi = hi.max(x);
}
worst = worst.max(hi - lo);
}
worst
}
pub fn mtie_curve(phase: &[f64], tau0: Seconds) -> Vec<MtiePoint> {
let n = phase.len();
let mut out = Vec::new();
let mut m = 1usize;
while m < n {
out.push(MtiePoint {
tau_s: m as f64 * tau0,
mtie_s: mtie(phase, m),
n_windows: n - m,
});
m *= 2;
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_phase_has_zero_adev() {
let phase = [0.0, 2.0, 4.0, 6.0, 8.0];
assert_eq!(overlapping_adev(&phase, 1.0, 1), 0.0);
}
#[test]
fn mtie_hand_derived() {
let phase = [0.0, 1.0, 3.0, 2.0, 5.0];
assert_eq!(mtie(&phase, 1), 3.0);
assert_eq!(mtie(&phase, 2), 3.0);
assert_eq!(mtie(&phase, 4), 5.0);
}
#[test]
fn mtie_is_monotone_nondecreasing_in_tau() {
let phase = white_fm_phase(3.0e-12, 200, 11);
let curve = mtie_curve(&phase, 1.0);
assert!(curve.len() >= 3);
for w in curve.windows(2) {
assert!(
w[1].mtie_s >= w[0].mtie_s - 1e-18,
"MTIE dropped from {} to {} as tau grew",
w[0].mtie_s,
w[1].mtie_s
);
}
}
#[test]
fn mtie_linear_phase_equals_full_swing() {
let a = 2.5;
let phase: Vec<f64> = (0..20).map(|i| a * i as f64).collect();
for m in [1usize, 2, 4, 8] {
assert!((mtie(&phase, m) - a * m as f64).abs() < 1e-12);
}
}
#[test]
fn hand_derived_adev() {
let phase = [0.0, 1.0, 3.0, 6.0];
let adev = overlapping_adev(&phase, 1.0, 1);
assert!(
(adev - std::f64::consts::FRAC_1_SQRT_2).abs() < 1e-12,
"adev={adev}"
);
}
#[test]
fn linear_frequency_drift_adev_is_exact() {
let a = 3.0e-12; let tau0 = 1.0;
let n = 4096;
let phase: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64 * tau0;
0.5 * a * t * t
})
.collect();
for &m in &[1usize, 2, 4, 16, 64, 256] {
let tau = m as f64 * tau0;
let expect = a * tau / std::f64::consts::SQRT_2;
let got = overlapping_adev(&phase, tau0, m);
assert!(
(got - expect).abs() / expect < 1e-9,
"m={m}: adev={got} vs exact {expect}"
);
}
}
#[test]
fn adev_curve_is_octave_spaced_with_decreasing_overlaps() {
let a = 1.0e-12;
let phase: Vec<f64> = (0..1000)
.map(|i| {
let t = i as f64;
0.5 * a * t * t
})
.collect();
let curve = overlapping_adev_curve(&phase, 1.0);
assert!(curve.len() >= 5, "curve too short: {}", curve.len());
for w in curve.windows(2) {
assert!(
(w[1].tau_s / w[0].tau_s - 2.0).abs() < 1e-9,
"tau not octave-spaced"
);
assert!(
w[1].n_samples < w[0].n_samples,
"overlap count should decrease"
);
assert!(
(w[1].adev / w[0].adev - 2.0).abs() < 1e-6,
"drift ADEV slope != +1"
);
}
assert!(curve.iter().all(|p| p.n_samples >= 8));
}
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
fn nonoverlapping_adev(phase: &[f64], tau0: f64, m: usize) -> f64 {
let tau = m as f64 * tau0;
let blocks = phase.len() / m; assert!(blocks >= 3, "need at least 3 frequency blocks");
let ybar: Vec<f64> = (0..blocks)
.map(|k| (phase[(k + 1) * m] - phase[k * m]) / tau)
.take(blocks - 1) .collect();
let mut sumsq = 0.0;
for w in ybar.windows(2) {
let d = w[1] - w[0];
sumsq += d * d;
}
(sumsq / (2.0 * (ybar.len() - 1) as f64)).sqrt()
}
fn white_fm_phase(sigma0: f64, n: usize, seed: u64) -> Vec<f64> {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let dist = Normal::new(0.0, sigma0).unwrap();
let mut x = 0.0;
let mut phase = Vec::with_capacity(n);
phase.push(0.0);
for _ in 1..n {
let y = dist.sample(&mut rng); x += y; phase.push(x);
}
phase
}
fn loglog_slope(curve: &[AdevPoint]) -> f64 {
let n = curve.len() as f64;
let xs: Vec<f64> = curve.iter().map(|p| p.tau_s.log10()).collect();
let ys: Vec<f64> = curve.iter().map(|p| p.adev.log10()).collect();
let sx: f64 = xs.iter().sum();
let sy: f64 = ys.iter().sum();
let sxx: f64 = xs.iter().map(|x| x * x).sum();
let sxy: f64 = xs.iter().zip(&ys).map(|(x, y)| x * y).sum();
(n * sxy - sx * sy) / (n * sxx - sx * sx)
}
#[test]
fn overlapping_matches_nonoverlapping_estimator_on_drift() {
let a = 2.5e-12;
let tau0 = 1.0;
let n = 4096;
let phase: Vec<f64> = (0..n)
.map(|i| {
let t = i as f64 * tau0;
0.5 * a * t * t
})
.collect();
for &m in &[1usize, 2, 8, 64] {
let ov = overlapping_adev(&phase, tau0, m);
let nov = nonoverlapping_adev(&phase, tau0, m);
let exact = a * (m as f64 * tau0) / std::f64::consts::SQRT_2;
assert!(
(ov - exact).abs() / exact < 1e-9,
"m={m}: overlapping {ov} vs exact {exact}"
);
assert!(
(nov - exact).abs() / exact < 1e-9,
"m={m}: non-overlapping {nov} vs exact {exact}"
);
}
}
#[test]
fn white_fm_magnitude_matches_sigma0_over_sqrt_tau() {
let sigma0 = 4.0e-12;
let n = 16384;
for &m in &[1usize, 4, 16, 64] {
let mut var_sum = 0.0;
let seeds = [11u64, 22, 33, 44, 55, 66, 77, 88];
for &s in &seeds {
let phase = white_fm_phase(sigma0, n, s);
let a = overlapping_adev(&phase, 1.0, m);
var_sum += a * a;
}
let adev = (var_sum / seeds.len() as f64).sqrt();
let expect = sigma0 / (m as f64).sqrt();
let rel = (adev - expect).abs() / expect;
assert!(
rel < 0.1,
"m={m}: white-FM adev={adev} vs {expect}, rel={rel}"
);
}
}
#[test]
fn white_fm_loglog_slope_is_minus_half() {
let phase = white_fm_phase(3.0e-12, 1 << 15, 12345);
let curve = overlapping_adev_curve(&phase, 1.0);
let slope = loglog_slope(&curve);
assert!(
(slope + 0.5).abs() < 0.07,
"white-FM log-log slope = {slope}, want -0.5"
);
}
#[test]
fn random_walk_fm_loglog_slope_is_plus_half() {
let mut rng = ChaCha8Rng::seed_from_u64(99);
let dist = Normal::new(0.0, 1.0e-13).unwrap();
let n = 1 << 15;
let mut y = 0.0; let mut x = 0.0;
let mut phase = Vec::with_capacity(n);
phase.push(0.0);
for _ in 1..n {
y += dist.sample(&mut rng); x += y;
phase.push(x);
}
let curve = overlapping_adev_curve(&phase, 1.0);
let slope = loglog_slope(&curve);
assert!(
(slope - 0.5).abs() < 0.1,
"RW-FM log-log slope = {slope}, want +0.5"
);
}
#[test]
fn adev_is_scale_linear() {
let phase = white_fm_phase(1.0e-12, 4096, 7);
let scaled: Vec<f64> = phase.iter().map(|x| 3.0 * x).collect();
for &m in &[1usize, 3, 9] {
let base = overlapping_adev(&phase, 1.0, m);
let big = overlapping_adev(&scaled, 1.0, m);
assert!(
(big - 3.0 * base).abs() / (3.0 * base) < 1e-12,
"scale linearity broke at m={m}"
);
}
}
#[test]
fn adev_ignores_constant_offset_and_frequency_offset() {
let phase = white_fm_phase(2.0e-12, 4096, 21);
let c0 = 1.0e-9;
let c1 = 1.0e-12;
let shifted: Vec<f64> = phase
.iter()
.enumerate()
.map(|(i, x)| x + c0 + c1 * i as f64)
.collect();
for &m in &[1usize, 2, 7, 31] {
let base = overlapping_adev(&phase, 1.0, m);
let got = overlapping_adev(&shifted, 1.0, m);
assert!(
(got - base).abs() / base < 1e-9,
"offset/frequency invariance broke at m={m}"
);
}
}
#[test]
fn curve_overlap_count_is_n_minus_2m_and_taus_are_octaves() {
let phase = white_fm_phase(1.0e-12, 5000, 3);
let tau0 = 0.5;
let curve = overlapping_adev_curve(&phase, tau0);
let mut m = 1usize;
for p in &curve {
assert_eq!(
p.n_samples,
phase.len() - 2 * m,
"overlap count wrong at m={m}"
);
assert!(
(p.tau_s - m as f64 * tau0).abs() < 1e-12,
"tau off the octave grid at m={m}"
);
m *= 2;
}
}
#[test]
fn curve_is_empty_for_too_few_samples() {
assert!(overlapping_adev_curve(&[0.0; 8], 1.0).is_empty());
assert!(overlapping_adev_curve(&[0.0; 9], 1.0).is_empty());
assert!(!overlapping_adev_curve(&[0.0; 10], 1.0).is_empty());
}
#[test]
fn curve_is_deterministic_for_identical_input() {
let phase = white_fm_phase(1.0e-12, 4096, 555);
let a = overlapping_adev_curve(&phase, 1.0);
let b = overlapping_adev_curve(&phase, 1.0);
assert_eq!(a, b);
}
fn loglog_slope_of<F: Fn(&[f64], f64, usize) -> f64>(phase: &[f64], f: F) -> f64 {
let pts: Vec<(f64, f64)> = [1usize, 2, 4, 8, 16, 32, 64]
.iter()
.map(|&m| (m as f64, f(phase, 1.0, m)))
.filter(|&(_, v)| v > 0.0)
.collect();
let n = pts.len() as f64;
let xs: Vec<f64> = pts.iter().map(|p| p.0.log10()).collect();
let ys: Vec<f64> = pts.iter().map(|p| p.1.log10()).collect();
let sx: f64 = xs.iter().sum();
let sy: f64 = ys.iter().sum();
let sxx: f64 = xs.iter().map(|x| x * x).sum();
let sxy: f64 = xs.iter().zip(&ys).map(|(x, y)| x * y).sum();
(n * sxy - sx * sy) / (n * sxx - sx * sx)
}
#[test]
fn mdev_hand_derived_small_case() {
let phase = [0.0, 1.0, 3.0, 6.0, 10.0, 15.0];
let md = modified_adev(&phase, 1.0, 1);
let ad = overlapping_adev(&phase, 1.0, 1);
assert!(
(md - ad).abs() < 1e-12,
"MDEV(m=1) {md} should equal ADEV(m=1) {ad}"
);
}
#[test]
fn tdev_is_tau_over_sqrt3_times_mdev() {
let phase = white_fm_phase(2.0e-12, 8192, 4);
for &m in &[1usize, 4, 16] {
let tau = m as f64;
let expect = tau / 3.0_f64.sqrt() * modified_adev(&phase, 1.0, m);
assert!((time_deviation(&phase, 1.0, m) - expect).abs() < 1e-18 * expect.max(1e-18));
}
}
#[test]
fn mdev_white_fm_slope_is_minus_half() {
let phase = white_fm_phase(3.0e-12, 1 << 14, 1234);
let slope = loglog_slope_of(&phase, modified_adev);
assert!(
(slope + 0.5).abs() < 0.1,
"MDEV white-FM slope {slope}, want -0.5"
);
}
#[test]
fn hadamard_rejects_linear_frequency_drift() {
let a = 5.0e-12;
let phase: Vec<f64> = (0..2048)
.map(|i| 0.5 * a * (i as f64) * (i as f64))
.collect();
for &m in &[1usize, 4, 16] {
let h = hadamard_adev(&phase, 1.0, m);
let ad = overlapping_adev(&phase, 1.0, m);
assert!(h < 1e-9 * ad, "HDEV {h} should reject drift (ADEV {ad})");
}
}
#[test]
fn hadamard_white_fm_slope_is_minus_half() {
let phase = white_fm_phase(3.0e-12, 1 << 14, 4321);
let slope = loglog_slope_of(&phase, hadamard_adev);
assert!(
(slope + 0.5).abs() < 0.12,
"HDEV white-FM slope {slope}, want -0.5"
);
}
#[test]
fn normal_and_chi2_quantiles_match_known_values() {
assert!((normal_quantile(0.975) - 1.959_963_98).abs() < 1e-6);
assert!((normal_quantile(0.5)).abs() < 1e-9);
assert!((normal_quantile(0.025) + 1.959_963_98).abs() < 1e-6);
assert!(
(chi2_quantile(0.5, 10.0) - 9.342).abs() < 0.05,
"{}",
chi2_quantile(0.5, 10.0)
);
assert!(
(chi2_quantile(0.95, 20.0) - 31.410).abs() < 0.1,
"{}",
chi2_quantile(0.95, 20.0)
);
assert!(
(chi2_quantile(0.025, 20.0) - 9.591).abs() < 0.1,
"{}",
chi2_quantile(0.025, 20.0)
);
}
#[test]
fn confidence_interval_brackets_and_tightens() {
let dev = 1.0e-12;
let ci = deviation_ci(dev, 30.0, 0.95);
assert!(ci.lo < dev && dev < ci.hi, "CI must bracket the estimate");
let wide = deviation_ci(dev, 5.0, 0.95);
assert!(
(wide.hi - wide.lo) > (ci.hi - ci.lo),
"fewer edf must give a wider interval"
);
let c99 = deviation_ci(dev, 30.0, 0.99);
assert!(
(c99.hi - c99.lo) > (ci.hi - ci.lo),
"99% must be wider than 95%"
);
assert_eq!(conservative_edf(1000, 10), 99.0);
assert_eq!(conservative_edf(5, 10), 1.0); }
#[test]
fn mdev_slope_maps_to_noise_type() {
use PowerLawNoise::*;
assert_eq!(PowerLawNoise::from_mdev_slope(-1.5), WhitePm);
assert_eq!(PowerLawNoise::from_mdev_slope(-1.0), FlickerPm);
assert_eq!(PowerLawNoise::from_mdev_slope(-0.5), WhiteFm);
assert_eq!(PowerLawNoise::from_mdev_slope(0.0), FlickerFm);
assert_eq!(PowerLawNoise::from_mdev_slope(0.5), RandomWalkFm);
assert_eq!(PowerLawNoise::from_mdev_slope(-3.0), WhitePm);
assert_eq!(PowerLawNoise::from_mdev_slope(2.0), RandomWalkFm);
assert_eq!(WhitePm.alpha(), 2);
assert_eq!(WhiteFm.alpha(), 0);
assert_eq!(RandomWalkFm.alpha(), -2);
}
#[test]
fn edf_formulas_match_nist_sp1065_table5() {
use PowerLawNoise::*;
let cases = [
(WhitePm, 30.333_333_333_333_332_f64),
(FlickerPm, 40.270_309_312_727_306),
(WhiteFm, 20.115_942_028_985_508),
(FlickerFm, 16.842_105_263_157_894),
(RandomWalkFm, 12.797_556_059_460_822),
];
for (nz, want) in cases {
let got = edf_overlapping_adev(nz, 64, 4);
assert!(
(got - want).abs() / want < 1e-12,
"{nz:?}: got {got}, want {want}"
);
}
}
#[test]
fn flicker_fm_at_m1_edf_floors_at_one() {
assert_eq!(edf_overlapping_adev(PowerLawNoise::FlickerFm, 64, 1), 1.0);
}
#[test]
fn edf_ordering_white_pm_richest_random_walk_poorest() {
use PowerLawNoise::*;
let (n, m) = (64, 8);
let wpm = edf_overlapping_adev(WhitePm, n, m);
let wfm = edf_overlapping_adev(WhiteFm, n, m);
let ffm = edf_overlapping_adev(FlickerFm, n, m);
let rwfm = edf_overlapping_adev(RandomWalkFm, n, m);
assert!(
wpm > wfm && wfm > ffm && ffm > rwfm,
"edf must fall as the spectrum reddens: WPM {wpm} WFM {wfm} FFM {ffm} RWFM {rwfm}"
);
}
#[test]
fn white_fm_edf_predicts_estimator_variance() {
const N: usize = 129;
const M: usize = 8;
const K: usize = 4000;
let vars: Vec<f64> = (0..K as u64)
.map(|seed| {
let a = overlapping_adev(&white_fm_phase(1.0, N, seed), 1.0, M);
a * a
})
.collect();
let k = K as f64;
let mean = vars.iter().sum::<f64>() / k;
let var = vars.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / k;
let empirical_edf = 2.0 * mean * mean / var;
let formula = edf_overlapping_adev(PowerLawNoise::WhiteFm, N, M);
let rel = (empirical_edf - formula).abs() / formula;
assert!(
rel < 0.2,
"empirical edf {empirical_edf:.3} vs NIST formula {formula:.3}, rel={rel:.3}"
);
let conservative = conservative_edf(N, M);
assert!(
empirical_edf > conservative,
"empirical edf {empirical_edf:.3} should exceed conservative {conservative}"
);
}
#[test]
fn classify_power_law_identifies_white_fm() {
let phase = white_fm_phase(3.0e-12, 1 << 14, 2027);
assert_eq!(
classify_power_law(&phase, 1.0),
Some(PowerLawNoise::WhiteFm)
);
}
#[test]
fn adev_curve_carries_noise_typed_confidence_band() {
let phase = white_fm_phase(2.0e-12, 8192, 99);
let curve = overlapping_adev_curve(&phase, 1.0);
assert!(!curve.is_empty(), "curve must have points");
for p in &curve {
assert_eq!(
p.noise,
Some(PowerLawNoise::WhiteFm),
"white FM should be identified at tau={}",
p.tau_s
);
assert!(p.edf > 0.0, "edf must be positive at tau={}", p.tau_s);
assert!(
p.ci_lo > 0.0 && p.ci_lo <= p.adev && p.adev <= p.ci_hi,
"band must bracket adev at tau={}: [{}, {}] vs {}",
p.tau_s,
p.ci_lo,
p.ci_hi,
p.adev
);
}
}
#[test]
fn theo1_effective_tau_is_three_quarters_m_tau0() {
assert!((theo1_tau(1.0, 10) - 7.5).abs() < 1e-12);
assert!((theo1_tau(0.5, 8) - 3.0).abs() < 1e-12);
}
#[test]
fn theo1_ignores_constant_offset_and_frequency() {
let phase = white_fm_phase(2.0e-12, 4096, 21);
let c0 = 1.0e-9;
let c1 = 1.0e-12;
let shifted: Vec<f64> = phase
.iter()
.enumerate()
.map(|(i, x)| x + c0 + c1 * i as f64)
.collect();
for &m in &[2usize, 8, 32, 128] {
let base = theo1(&phase, 1.0, m);
let got = theo1(&shifted, 1.0, m);
assert!(
(got - base).abs() / base < 1e-9,
"Theo1 offset/frequency invariance broke at m={m}"
);
}
}
#[test]
fn theo1_white_fm_tracks_allan_closed_form() {
let sigma0 = 4.0e-12;
let n = 4096;
let seeds = [11u64, 22, 33, 44, 55, 66, 77, 88];
for &m in &[10usize, 20, 40, 100] {
let var = seeds
.iter()
.map(|&s| {
let t = theo1(&white_fm_phase(sigma0, n, s), 1.0, m);
t * t
})
.sum::<f64>()
/ seeds.len() as f64;
let dev = var.sqrt();
let expect = sigma0 / (0.75 * m as f64).sqrt();
let rel = (dev - expect).abs() / expect;
assert!(
rel < 0.05,
"m={m}: Theo1={dev} vs σ0/√(0.75m)={expect}, rel={rel}"
);
}
}
#[test]
fn totvar_equals_overlapping_adev_at_m1() {
let phase = [0.0, 1.0, 3.0, 6.0, 10.0, 9.0, 7.0, 12.0, 20.0, 18.0];
let tv = total_deviation(&phase, 1.0, 1);
let ad = overlapping_adev(&phase, 1.0, 1);
assert!(
(tv - ad).abs() < 1e-12,
"TOTVAR(1) {tv} must equal OADEV(1) {ad}"
);
let p2 = white_fm_phase(3.0e-12, 1024, 7);
assert!(
(total_deviation(&p2, 1.0, 1) - overlapping_adev(&p2, 1.0, 1)).abs() < 1e-18,
"TOTVAR(1) must equal OADEV(1) on the long record too"
);
}
#[test]
fn totvar_tracks_overlapping_adev_white_fm() {
let sigma0 = 4.0e-12;
let n = 4096;
let seeds = [11u64, 22, 33, 44, 55, 66, 77, 88];
for &m in &[1usize, 2, 4, 8, 16, 64] {
let mut tv_var = 0.0;
let mut ad_var = 0.0;
for &s in &seeds {
let phase = white_fm_phase(sigma0, n, s);
let tv = total_deviation(&phase, 1.0, m);
let ad = overlapping_adev(&phase, 1.0, m);
tv_var += tv * tv;
ad_var += ad * ad;
}
let tv = (tv_var / seeds.len() as f64).sqrt();
let ad = (ad_var / seeds.len() as f64).sqrt();
let rel = (tv - ad).abs() / ad;
assert!(rel < 0.06, "m={m}: TOTVAR={tv} vs OADEV={ad}, rel={rel}");
}
}
#[test]
fn totvar_ignores_constant_offset_and_frequency() {
let phase = white_fm_phase(2.0e-12, 2048, 21);
let c0 = 1.0e-9;
let c1 = 1.0e-12;
let shifted: Vec<f64> = phase
.iter()
.enumerate()
.map(|(i, x)| x + c0 + c1 * i as f64)
.collect();
for &m in &[1usize, 2, 7, 31, 255] {
let base = total_deviation(&phase, 1.0, m);
let got = total_deviation(&shifted, 1.0, m);
assert!(
(got - base).abs() / base < 1e-9,
"TOTVAR offset/frequency invariance broke at m={m}"
);
}
}
#[test]
fn theo1_and_totvar_white_fm_loglog_slope_is_minus_half() {
let phase = white_fm_phase(3.0e-12, 1 << 12, 2718);
let theo_slope = loglog_slope(&theo1_curve(&phase, 1.0));
let tv_slope = loglog_slope(&total_deviation_curve(&phase, 1.0));
assert!(
(theo_slope + 0.5).abs() < 0.08,
"Theo1 white-FM slope {theo_slope}"
);
assert!(
(tv_slope + 0.5).abs() < 0.08,
"TOTVAR white-FM slope {tv_slope}"
);
}
#[test]
fn theo_br_pairing_is_unbiased_for_white_fm() {
for i in 0..50usize {
let k_av = (9 + 3 * i) as f64;
let k_th = (12 + 4 * i) as f64;
let expected_ratio = 0.75 * k_th / k_av;
assert!(
(expected_ratio - 1.0).abs() < 1e-12,
"i={i}: white-FM expected AVAR/Theo1 ratio {expected_ratio} != 1"
);
}
let sigma0 = 4.0e-12;
let phase = white_fm_phase(sigma0, 1024, 99);
let bias = theo_br_bias(&phase, 1.0);
assert!(
bias.is_finite() && (0.3..3.0).contains(&bias),
"white-FM TheoBR bias factor {bias} out of the order-unity band"
);
let m = 50usize;
let br = theo_br(&phase, 1.0, m);
let expect = sigma0 / (0.75 * m as f64).sqrt();
let ratio = br / expect;
assert!(
(0.6..1.7).contains(&ratio),
"TheoBR({m})={br} vs σ0/√(0.75m)={expect}, ratio {ratio} out of band"
);
}
#[test]
fn theoh_curve_extends_past_adev_and_tracks_white_fm_slope() {
let phase = white_fm_phase(3.0e-12, 1024, 1234);
let adev = overlapping_adev_curve(&phase, 1.0);
let theoh = theoh_curve(&phase, 1.0);
assert!(!theoh.is_empty(), "ThêoH curve must have points");
let adev_max_tau = adev.iter().map(|p| p.tau_s).fold(0.0, f64::max);
let theoh_max_tau = theoh.iter().map(|p| p.tau_s).fold(0.0, f64::max);
assert!(
theoh_max_tau > 1.4 * adev_max_tau,
"ThêoH max tau {theoh_max_tau} should reach well past ADEV max tau {adev_max_tau}"
);
assert!(
theoh.iter().any(|p| !p.from_theo),
"needs a short-tau ADEV region"
);
assert!(
theoh.iter().any(|p| p.from_theo),
"needs a long-tau Theo region"
);
assert!(theoh.iter().all(|p| p.dev.is_finite() && p.dev > 0.0));
let xs: Vec<f64> = theoh.iter().map(|p| p.tau_s.log10()).collect();
let ys: Vec<f64> = theoh.iter().map(|p| p.dev.log10()).collect();
let k = xs.len() as f64;
let sx: f64 = xs.iter().sum();
let sy: f64 = ys.iter().sum();
let sxx: f64 = xs.iter().map(|x| x * x).sum();
let sxy: f64 = xs.iter().zip(&ys).map(|(x, y)| x * y).sum();
let slope = (k * sxy - sx * sy) / (k * sxx - sx * sx);
assert!(
(slope + 0.5).abs() < 0.1,
"ThêoH white-FM slope {slope}, want -0.5"
);
}
#[test]
fn theoh_reaches_about_three_quarters_of_the_record() {
let phase = white_fm_phase(2.0e-12, 1000, 5);
let theoh = theoh_curve(&phase, 1.0);
let max_tau = theoh.iter().map(|p| p.tau_s).fold(0.0, f64::max);
assert!(
max_tau > 0.7 * phase.len() as f64,
"ThêoH max tau {max_tau} should reach ~0.75·N = {}",
0.75 * phase.len() as f64
);
}
}