use u_numflow::special::{chi_squared_cdf, standard_normal_sf};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Truncation {
Time(f64),
Failure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrendDirection {
Increasing,
Decreasing,
Flat,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PointProcessError {
TooFewEvents {
min: usize,
got: usize,
},
NotFinite {
index: Option<usize>,
},
NotPositive {
index: Option<usize>,
got: f64,
},
Unordered {
index: usize,
},
AfterEnd {
index: usize,
},
}
impl std::fmt::Display for PointProcessError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::TooFewEvents { min, got } => {
write!(f, "at least {min} events are needed, got {got}")
}
Self::NotFinite { index: Some(i) } => write!(f, "event {i} is not a finite number"),
Self::NotFinite { index: None } => write!(f, "the end of observation is not finite"),
Self::NotPositive {
index: Some(i),
got,
} => {
write!(f, "event {i} is at {got}; event times must be > 0")
}
Self::NotPositive { index: None, got } => {
write!(f, "the end of observation is {got}; it must be > 0")
}
Self::Unordered { index } => {
write!(f, "event {index} is earlier than event {}", index - 1)
}
Self::AfterEnd { index } => write!(f, "event {index} is after the end of observation"),
}
}
}
impl std::error::Error for PointProcessError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrendTestResult {
pub statistic: f64,
pub p_value: f64,
pub direction: TrendDirection,
pub events_used: usize,
pub degrees_of_freedom: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PowerLawFit {
pub beta: f64,
pub beta_unbiased: f64,
pub lambda: f64,
pub intensity_at_end: f64,
pub end: f64,
pub events: usize,
}
fn observe(
times: &[f64],
truncation: Truncation,
min: usize,
) -> Result<(f64, usize), PointProcessError> {
for (i, &t) in times.iter().enumerate() {
if !t.is_finite() {
return Err(PointProcessError::NotFinite { index: Some(i) });
}
if t <= 0.0 {
return Err(PointProcessError::NotPositive {
index: Some(i),
got: t,
});
}
if i > 0 && t < times[i - 1] {
return Err(PointProcessError::Unordered { index: i });
}
}
if times.len() < min {
return Err(PointProcessError::TooFewEvents {
min,
got: times.len(),
});
}
let n = times.len();
match truncation {
Truncation::Time(end) => {
if !end.is_finite() {
return Err(PointProcessError::NotFinite { index: None });
}
if end <= 0.0 {
return Err(PointProcessError::NotPositive {
index: None,
got: end,
});
}
if let Some(i) = times.iter().position(|&t| t > end) {
return Err(PointProcessError::AfterEnd { index: i });
}
Ok((end, n))
}
Truncation::Failure => Ok((times[n - 1], n - 1)),
}
}
fn min_events(truncation: Truncation) -> usize {
match truncation {
Truncation::Time(_) => 1,
Truncation::Failure => 2,
}
}
fn direction(increasing: bool, decreasing: bool) -> TrendDirection {
if increasing {
TrendDirection::Increasing
} else if decreasing {
TrendDirection::Decreasing
} else {
TrendDirection::Flat
}
}
pub fn laplace_trend_test(
times: &[f64],
truncation: Truncation,
) -> Result<TrendTestResult, PointProcessError> {
let (end, m) = observe(times, truncation, min_events(truncation))?;
let mf = m as f64;
let mean = times[..m].iter().sum::<f64>() / mf;
let u = (mean - end / 2.0) / (end * (1.0 / (12.0 * mf)).sqrt());
Ok(TrendTestResult {
statistic: u,
p_value: (2.0 * standard_normal_sf(u.abs())).min(1.0),
direction: direction(u > 0.0, u < 0.0),
events_used: m,
degrees_of_freedom: None,
})
}
pub fn mil_hdbk_189_test(
times: &[f64],
truncation: Truncation,
) -> Result<TrendTestResult, PointProcessError> {
let (end, m) = observe(times, truncation, min_events(truncation))?;
let chi2 = 2.0 * times[..m].iter().map(|&t| (end / t).ln()).sum::<f64>();
let df = 2.0 * m as f64;
let lower = chi_squared_cdf(chi2, df);
Ok(TrendTestResult {
statistic: chi2,
p_value: (2.0 * lower.min(1.0 - lower)).clamp(0.0, 1.0),
direction: direction(chi2 < df, chi2 > df),
events_used: m,
degrees_of_freedom: Some(df),
})
}
pub fn power_law_process_fit(
times: &[f64],
truncation: Truncation,
) -> Result<PowerLawFit, PointProcessError> {
let min = min_events(truncation) + 1;
let (end, m) = observe(times, truncation, min)?;
let n = times.len();
let sum: f64 = times[..m].iter().map(|&t| (end / t).ln()).sum();
if sum <= 0.0 {
return Err(PointProcessError::TooFewEvents { min, got: 0 });
}
let nf = n as f64;
let beta = nf / sum;
let lambda = nf / end.powf(beta);
Ok(PowerLawFit {
beta,
beta_unbiased: (m as f64 - 1.0) / nf * beta,
lambda,
intensity_at_end: nf * beta / end,
end,
events: n,
})
}
#[cfg(test)]
mod tests {
use super::*;
const T: [f64; 6] = [12.0, 15.0, 27.0, 34.0, 44.0, 53.0];
#[test]
fn matches_the_published_example() {
let l = laplace_trend_test(&T, Truncation::Time(60.0)).unwrap();
assert!((l.statistic - 5.0 / 1800f64.sqrt()).abs() < 1e-12);
assert!((l.p_value - 0.906).abs() < 5e-4);
assert_eq!(l.direction, TrendDirection::Increasing);
let m = mil_hdbk_189_test(&T, Truncation::Time(60.0)).unwrap();
assert!((m.statistic - 9.59).abs() < 5e-3);
assert!((m.p_value - 0.697).abs() < 5e-4);
assert_eq!(m.direction, TrendDirection::Increasing);
let f = power_law_process_fit(&T, Truncation::Time(60.0)).unwrap();
assert!((f.beta - 1.25093).abs() < 5e-6);
let theta = (1.0 / f.lambda).powf(1.0 / f.beta) / 60.0;
assert!((theta - 0.238749).abs() < 5e-6);
assert!((f.intensity_at_end - 6.0 * f.beta / 60.0).abs() < 1e-12);
}
#[test]
fn failure_truncation_drops_the_last_event() {
let t = [5.0, 9.0, 20.0, 31.0, 40.0];
let ft = mil_hdbk_189_test(&t, Truncation::Failure).unwrap();
let tt = mil_hdbk_189_test(&t[..4], Truncation::Time(40.0)).unwrap();
assert_eq!(ft.statistic, tt.statistic);
assert_eq!((ft.events_used, ft.degrees_of_freedom), (4, Some(8.0)));
let f = power_law_process_fit(&t, Truncation::Failure).unwrap();
assert!((f.beta_unbiased - 3.0 / 5.0 * f.beta).abs() < 1e-12);
assert!((f.lambda * 40f64.powf(f.beta) - 5.0).abs() < 1e-9);
}
#[test]
fn early_crowding_is_decreasing() {
let t = [1.0, 2.0, 3.0, 5.0, 8.0, 50.0];
let l = laplace_trend_test(&t, Truncation::Time(100.0)).unwrap();
let m = mil_hdbk_189_test(&t, Truncation::Time(100.0)).unwrap();
assert_eq!(
(l.direction, m.direction),
(TrendDirection::Decreasing, TrendDirection::Decreasing)
);
assert!(
power_law_process_fit(&t, Truncation::Time(100.0))
.unwrap()
.beta
< 1.0
);
}
#[test]
fn refusals_name_the_event() {
use PointProcessError as E;
let tt = Truncation::Time(10.0);
assert_eq!(
laplace_trend_test(&[], tt),
Err(E::TooFewEvents { min: 1, got: 0 })
);
assert_eq!(
laplace_trend_test(&[3.0], Truncation::Failure),
Err(E::TooFewEvents { min: 2, got: 1 })
);
assert_eq!(
power_law_process_fit(&[1.0, 2.0], Truncation::Failure),
Err(E::TooFewEvents { min: 3, got: 2 })
);
assert_eq!(
laplace_trend_test(&[1.0, f64::NAN], tt),
Err(E::NotFinite { index: Some(1) })
);
assert_eq!(
laplace_trend_test(&[0.0, 1.0], tt),
Err(E::NotPositive {
index: Some(0),
got: 0.0
})
);
assert_eq!(
laplace_trend_test(&[2.0, 1.0], tt),
Err(E::Unordered { index: 1 })
);
assert_eq!(
laplace_trend_test(&[2.0, 11.0], tt),
Err(E::AfterEnd { index: 1 })
);
assert_eq!(
laplace_trend_test(&[2.0], Truncation::Time(-1.0)),
Err(E::NotPositive {
index: None,
got: -1.0
})
);
assert_eq!(
laplace_trend_test(&[2.0], Truncation::Time(f64::INFINITY)),
Err(E::NotFinite { index: None })
);
assert_eq!(
power_law_process_fit(&[10.0, 10.0], tt),
Err(E::TooFewEvents { min: 2, got: 0 })
);
}
#[test]
fn mil_hdbk_189_holds_its_size_under_a_constant_rate() {
use u_numflow::distributions::{Exponential, Sample};
let gaps = Exponential::new(1.0).unwrap();
let mut rng = u_numflow::random::create_rng(11);
let paths = 4000;
let mut rejected = 0;
for _ in 0..paths {
let mut t = 0.0;
let mut times = Vec::new();
loop {
t += gaps.sample(&mut rng);
if t > 20.0 {
break;
}
times.push(t);
}
if times.is_empty() {
continue;
}
if mil_hdbk_189_test(×, Truncation::Time(20.0))
.unwrap()
.p_value
< 0.05
{
rejected += 1;
}
}
let rate = rejected as f64 / paths as f64;
assert!((rate - 0.05).abs() < 0.015, "rejection rate {rate}");
}
}