u_analytics/point_process.rs
1//! Trend tests and the power-law fit for the event times of a point process.
2//!
3//! The data is one sequence of event times `0 < t₁ ≤ t₂ ≤ … ≤ tₙ` observed on
4//! a single unit — the failures of one repairable system, the incidents of
5//! one service — and the question is whether events arrive at a constant
6//! rate (a homogeneous Poisson process, HPP) or a changing one. That is a
7//! different question from [`mann_kendall_test`](crate::testing::mann_kendall_test)
8//! (a trend in an ordered series of values) and from
9//! [`weibull`](crate::weibull) fitting (independent lifetimes of many units,
10//! which assumes exactly the absence of trend these tests check for).
11//!
12//! # Observation
13//!
14//! [`Truncation::Time`]`(T)`: the unit was watched until `T ≥ tₙ`, whatever
15//! happened. [`Truncation::Failure`]: watching stopped at the last event, so
16//! `T = tₙ` and that event carries no information about the trend — the
17//! statistics use the `n − 1` events before it.
18//!
19//! # Methods
20//!
21//! With `m` the number of events used (`n` time-truncated, `n − 1`
22//! failure-truncated) and `T` the end of observation:
23//!
24//! - **Laplace test** — `U = (Σtᵢ/m − T/2) / (T·√(1/(12m)))`, approximately
25//! standard normal under an HPP. `U > 0`: events crowd towards the end
26//! (rate increasing).
27//! - **MIL-HDBK-189 test** — `χ² = 2·Σ ln(T/tᵢ)`, exactly χ² with `2m`
28//! degrees of freedom under an HPP. A *small* value means events crowd
29//! towards the end (rate increasing).
30//! - **Power-law process** (Crow-AMSAA) — intensity `λβt^(β−1)`, expected
31//! events `λt^β`. MLE `β̂ = n / Σ ln(T/tᵢ)` (the same sum as the MIL-HDBK-189
32//! statistic, over the same events), `λ̂ = n / T^β̂`. `β̂` is biased upward;
33//! conditionally on `n`, `2nβ/β̂ ~ χ²(2m)`, so `E[β̂] = nβ/(m − 1)` and
34//! `β̄ = (m − 1)/n · β̂` is unbiased — `(n − 1)/n` time-truncated,
35//! `(n − 2)/n` failure-truncated. `β < 1`: rate decreasing; `β > 1`:
36//! increasing.
37//!
38//! # References
39//!
40//! - Ascher & Feingold (1984), *Repairable Systems Reliability*, ch. 5.
41//! - Crow (1974), "Reliability analysis for complex, repairable systems",
42//! in *Reliability and Biometry*, SIAM, 379–410.
43//! - MIL-HDBK-189C (2011), *Reliability Growth Management*, §5.
44//! - IEC 61710 (2013), *Power law model — Goodness-of-fit tests and
45//! estimation methods*.
46//! - Rausand & Høyland (2004), *System Reliability Theory*, 2nd ed., §7.4.
47
48use u_numflow::special::{chi_squared_cdf, standard_normal_sf};
49
50/// How observation of the unit ended.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum Truncation {
53 /// Watched until this time, which is at or after the last event.
54 Time(f64),
55 /// Watching stopped at the last event.
56 Failure,
57}
58
59/// Which way the observed rate moves — the sign of the deviation from a
60/// constant rate, not a significance verdict (see `p_value`).
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum TrendDirection {
63 /// Events crowd towards the end of observation.
64 Increasing,
65 /// Events crowd towards the start.
66 Decreasing,
67 /// Exactly as a constant rate would place them on average.
68 Flat,
69}
70
71/// Why event times were refused.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum PointProcessError {
74 /// Fewer events than the method needs.
75 TooFewEvents {
76 /// The least number of events the method accepts.
77 min: usize,
78 /// The number given.
79 got: usize,
80 },
81 /// An event time or the end of observation is NaN or infinite.
82 NotFinite {
83 /// The event's position; `None` for the end of observation.
84 index: Option<usize>,
85 },
86 /// An event time or the end of observation is not greater than 0.
87 NotPositive {
88 /// The event's position; `None` for the end of observation.
89 index: Option<usize>,
90 /// The value given.
91 got: f64,
92 },
93 /// An event time is earlier than the one before it.
94 Unordered {
95 /// The event's position.
96 index: usize,
97 },
98 /// An event time is after the end of observation.
99 AfterEnd {
100 /// The event's position.
101 index: usize,
102 },
103}
104
105impl std::fmt::Display for PointProcessError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match *self {
108 Self::TooFewEvents { min, got } => {
109 write!(f, "at least {min} events are needed, got {got}")
110 }
111 Self::NotFinite { index: Some(i) } => write!(f, "event {i} is not a finite number"),
112 Self::NotFinite { index: None } => write!(f, "the end of observation is not finite"),
113 Self::NotPositive {
114 index: Some(i),
115 got,
116 } => {
117 write!(f, "event {i} is at {got}; event times must be > 0")
118 }
119 Self::NotPositive { index: None, got } => {
120 write!(f, "the end of observation is {got}; it must be > 0")
121 }
122 Self::Unordered { index } => {
123 write!(f, "event {index} is earlier than event {}", index - 1)
124 }
125 Self::AfterEnd { index } => write!(f, "event {index} is after the end of observation"),
126 }
127 }
128}
129
130impl std::error::Error for PointProcessError {}
131
132/// A trend test's outcome.
133#[derive(Debug, Clone, Copy, PartialEq)]
134pub struct TrendTestResult {
135 /// The test statistic (`U` for Laplace, `χ²` for MIL-HDBK-189).
136 pub statistic: f64,
137 /// Two-sided p-value against a constant rate. The one-sided p-value in
138 /// the observed `direction` is half of it.
139 pub p_value: f64,
140 /// Which way the observed rate moves.
141 pub direction: TrendDirection,
142 /// Events the statistic used: `n`, or `n − 1` failure-truncated.
143 pub events_used: usize,
144 /// χ² degrees of freedom (`2 · events_used`); `None` for the Laplace test.
145 pub degrees_of_freedom: Option<f64>,
146}
147
148/// A power-law process fit.
149#[derive(Debug, Clone, Copy, PartialEq)]
150pub struct PowerLawFit {
151 /// Shape β̂ (MLE). `< 1`: rate decreasing; `> 1`: increasing.
152 pub beta: f64,
153 /// The unbiased shape `β̄ = (m − 1)/n · β̂`.
154 pub beta_unbiased: f64,
155 /// Scale λ̂ (MLE): expected events by time `t` are `λ̂·t^β̂`.
156 pub lambda: f64,
157 /// Intensity at the end of observation, `λ̂β̂T^(β̂−1)` (= `nβ̂/T`).
158 pub intensity_at_end: f64,
159 /// The end of observation `T` the fit used.
160 pub end: f64,
161 /// Number of events `n`.
162 pub events: usize,
163}
164
165/// Checks the event times and returns `(T, m)`: the end of observation and
166/// the number of events the statistics use.
167fn observe(
168 times: &[f64],
169 truncation: Truncation,
170 min: usize,
171) -> Result<(f64, usize), PointProcessError> {
172 for (i, &t) in times.iter().enumerate() {
173 if !t.is_finite() {
174 return Err(PointProcessError::NotFinite { index: Some(i) });
175 }
176 if t <= 0.0 {
177 return Err(PointProcessError::NotPositive {
178 index: Some(i),
179 got: t,
180 });
181 }
182 if i > 0 && t < times[i - 1] {
183 return Err(PointProcessError::Unordered { index: i });
184 }
185 }
186 if times.len() < min {
187 return Err(PointProcessError::TooFewEvents {
188 min,
189 got: times.len(),
190 });
191 }
192 let n = times.len();
193 match truncation {
194 Truncation::Time(end) => {
195 if !end.is_finite() {
196 return Err(PointProcessError::NotFinite { index: None });
197 }
198 if end <= 0.0 {
199 return Err(PointProcessError::NotPositive {
200 index: None,
201 got: end,
202 });
203 }
204 if let Some(i) = times.iter().position(|&t| t > end) {
205 return Err(PointProcessError::AfterEnd { index: i });
206 }
207 Ok((end, n))
208 }
209 Truncation::Failure => Ok((times[n - 1], n - 1)),
210 }
211}
212
213/// Minimum events: one used event, plus the dropped last one when failure-truncated.
214fn min_events(truncation: Truncation) -> usize {
215 match truncation {
216 Truncation::Time(_) => 1,
217 Truncation::Failure => 2,
218 }
219}
220
221fn direction(increasing: bool, decreasing: bool) -> TrendDirection {
222 if increasing {
223 TrendDirection::Increasing
224 } else if decreasing {
225 TrendDirection::Decreasing
226 } else {
227 TrendDirection::Flat
228 }
229}
230
231/// Laplace trend test against a constant event rate.
232///
233/// `U = (Σtᵢ/m − T/2) / (T·√(1/(12m)))` over the `m` events used, compared
234/// with the standard normal (two-sided). The normal approximation is already
235/// close for `m ≥ 4`.
236///
237/// # Errors
238/// [`PointProcessError`] for a time that is not finite or `≤ 0`, out of order,
239/// after the end of observation, or too few events (1 time-truncated, 2
240/// failure-truncated).
241///
242/// # Examples
243/// ```
244/// use u_analytics::point_process::{laplace_trend_test, Truncation};
245///
246/// // Minitab's example: six events, watched until 60.
247/// let r = laplace_trend_test(&[12.0, 15.0, 27.0, 34.0, 44.0, 53.0], Truncation::Time(60.0)).unwrap();
248/// assert!((r.statistic - 0.118).abs() < 1e-3);
249/// assert!((r.p_value - 0.906).abs() < 1e-3);
250/// ```
251pub fn laplace_trend_test(
252 times: &[f64],
253 truncation: Truncation,
254) -> Result<TrendTestResult, PointProcessError> {
255 let (end, m) = observe(times, truncation, min_events(truncation))?;
256 let mf = m as f64;
257 let mean = times[..m].iter().sum::<f64>() / mf;
258 let u = (mean - end / 2.0) / (end * (1.0 / (12.0 * mf)).sqrt());
259 Ok(TrendTestResult {
260 statistic: u,
261 p_value: (2.0 * standard_normal_sf(u.abs())).min(1.0),
262 direction: direction(u > 0.0, u < 0.0),
263 events_used: m,
264 degrees_of_freedom: None,
265 })
266}
267
268/// MIL-HDBK-189 test against a constant event rate.
269///
270/// `χ² = 2·Σ ln(T/tᵢ)` over the `m` events used, exactly χ²(2m) under a
271/// constant rate (two-sided). Small values mean the rate is increasing.
272///
273/// # Errors
274/// As [`laplace_trend_test`].
275///
276/// # Examples
277/// ```
278/// use u_analytics::point_process::{mil_hdbk_189_test, Truncation};
279///
280/// let r = mil_hdbk_189_test(&[12.0, 15.0, 27.0, 34.0, 44.0, 53.0], Truncation::Time(60.0)).unwrap();
281/// assert!((r.statistic - 9.593).abs() < 1e-3);
282/// assert_eq!(r.degrees_of_freedom, Some(12.0));
283/// assert!((r.p_value - 0.697).abs() < 1e-3);
284/// ```
285pub fn mil_hdbk_189_test(
286 times: &[f64],
287 truncation: Truncation,
288) -> Result<TrendTestResult, PointProcessError> {
289 let (end, m) = observe(times, truncation, min_events(truncation))?;
290 let chi2 = 2.0 * times[..m].iter().map(|&t| (end / t).ln()).sum::<f64>();
291 let df = 2.0 * m as f64;
292 let lower = chi_squared_cdf(chi2, df);
293 Ok(TrendTestResult {
294 statistic: chi2,
295 p_value: (2.0 * lower.min(1.0 - lower)).clamp(0.0, 1.0),
296 // The χ² mean under a constant rate is df; below it, events crowd late.
297 direction: direction(chi2 < df, chi2 > df),
298 events_used: m,
299 degrees_of_freedom: Some(df),
300 })
301}
302
303/// Maximum-likelihood fit of a power-law process (Crow-AMSAA).
304///
305/// # Errors
306/// As [`laplace_trend_test`], with at least 2 events time-truncated and 3
307/// failure-truncated (fewer leave `β̄ ≤ 0`). Events that all sit at the end
308/// of observation leave `β̂` unbounded and are refused as too few.
309///
310/// # Examples
311/// ```
312/// use u_analytics::point_process::{power_law_process_fit, Truncation};
313///
314/// let fit = power_law_process_fit(&[12.0, 15.0, 27.0, 34.0, 44.0, 53.0], Truncation::Time(60.0)).unwrap();
315/// assert!((fit.beta - 1.2509).abs() < 1e-4);
316/// assert!((fit.beta_unbiased - fit.beta * 5.0 / 6.0).abs() < 1e-12);
317/// // λ̂·T^β̂ reproduces the observed count.
318/// assert!((fit.lambda * 60f64.powf(fit.beta) - 6.0).abs() < 1e-9);
319/// ```
320pub fn power_law_process_fit(
321 times: &[f64],
322 truncation: Truncation,
323) -> Result<PowerLawFit, PointProcessError> {
324 let min = min_events(truncation) + 1;
325 let (end, m) = observe(times, truncation, min)?;
326 let n = times.len();
327 let sum: f64 = times[..m].iter().map(|&t| (end / t).ln()).sum();
328 if sum <= 0.0 {
329 // Every used event sits at the end: no spread to estimate β from.
330 return Err(PointProcessError::TooFewEvents { min, got: 0 });
331 }
332 let nf = n as f64;
333 let beta = nf / sum;
334 let lambda = nf / end.powf(beta);
335 Ok(PowerLawFit {
336 beta,
337 beta_unbiased: (m as f64 - 1.0) / nf * beta,
338 lambda,
339 intensity_at_end: nf * beta / end,
340 end,
341 events: n,
342 })
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 const T: [f64; 6] = [12.0, 15.0, 27.0, 34.0, 44.0, 53.0];
350
351 /// Minitab 16 (Rausand, NTNU TMA4275 slides): MIL-Hdbk-189 9.59 (DF 12,
352 /// p 0.697), Laplace 0.12 (p 0.906), shape 1.25093, scale 0.238749 on
353 /// time scaled by T = 60.
354 #[test]
355 fn matches_the_published_example() {
356 let l = laplace_trend_test(&T, Truncation::Time(60.0)).unwrap();
357 assert!((l.statistic - 5.0 / 1800f64.sqrt()).abs() < 1e-12);
358 assert!((l.p_value - 0.906).abs() < 5e-4);
359 assert_eq!(l.direction, TrendDirection::Increasing);
360 let m = mil_hdbk_189_test(&T, Truncation::Time(60.0)).unwrap();
361 assert!((m.statistic - 9.59).abs() < 5e-3);
362 assert!((m.p_value - 0.697).abs() < 5e-4);
363 assert_eq!(m.direction, TrendDirection::Increasing);
364 let f = power_law_process_fit(&T, Truncation::Time(60.0)).unwrap();
365 assert!((f.beta - 1.25093).abs() < 5e-6);
366 // Minitab's scale θ with W(t) = (t/θ)^β, here on t/60: θ = (1/λ)^(1/β) / 60.
367 let theta = (1.0 / f.lambda).powf(1.0 / f.beta) / 60.0;
368 assert!((theta - 0.238749).abs() < 5e-6);
369 assert!((f.intensity_at_end - 6.0 * f.beta / 60.0).abs() < 1e-12);
370 }
371
372 #[test]
373 fn failure_truncation_drops_the_last_event() {
374 let t = [5.0, 9.0, 20.0, 31.0, 40.0];
375 let ft = mil_hdbk_189_test(&t, Truncation::Failure).unwrap();
376 let tt = mil_hdbk_189_test(&t[..4], Truncation::Time(40.0)).unwrap();
377 assert_eq!(ft.statistic, tt.statistic);
378 assert_eq!((ft.events_used, ft.degrees_of_freedom), (4, Some(8.0)));
379 let f = power_law_process_fit(&t, Truncation::Failure).unwrap();
380 // n = 5 in β̂, m = 4 in the sum; unbiased factor (n − 2)/n.
381 assert!((f.beta_unbiased - 3.0 / 5.0 * f.beta).abs() < 1e-12);
382 assert!((f.lambda * 40f64.powf(f.beta) - 5.0).abs() < 1e-9);
383 }
384
385 #[test]
386 fn early_crowding_is_decreasing() {
387 let t = [1.0, 2.0, 3.0, 5.0, 8.0, 50.0];
388 let l = laplace_trend_test(&t, Truncation::Time(100.0)).unwrap();
389 let m = mil_hdbk_189_test(&t, Truncation::Time(100.0)).unwrap();
390 assert_eq!(
391 (l.direction, m.direction),
392 (TrendDirection::Decreasing, TrendDirection::Decreasing)
393 );
394 assert!(
395 power_law_process_fit(&t, Truncation::Time(100.0))
396 .unwrap()
397 .beta
398 < 1.0
399 );
400 }
401
402 #[test]
403 fn refusals_name_the_event() {
404 use PointProcessError as E;
405 let tt = Truncation::Time(10.0);
406 assert_eq!(
407 laplace_trend_test(&[], tt),
408 Err(E::TooFewEvents { min: 1, got: 0 })
409 );
410 assert_eq!(
411 laplace_trend_test(&[3.0], Truncation::Failure),
412 Err(E::TooFewEvents { min: 2, got: 1 })
413 );
414 assert_eq!(
415 power_law_process_fit(&[1.0, 2.0], Truncation::Failure),
416 Err(E::TooFewEvents { min: 3, got: 2 })
417 );
418 assert_eq!(
419 laplace_trend_test(&[1.0, f64::NAN], tt),
420 Err(E::NotFinite { index: Some(1) })
421 );
422 assert_eq!(
423 laplace_trend_test(&[0.0, 1.0], tt),
424 Err(E::NotPositive {
425 index: Some(0),
426 got: 0.0
427 })
428 );
429 assert_eq!(
430 laplace_trend_test(&[2.0, 1.0], tt),
431 Err(E::Unordered { index: 1 })
432 );
433 assert_eq!(
434 laplace_trend_test(&[2.0, 11.0], tt),
435 Err(E::AfterEnd { index: 1 })
436 );
437 assert_eq!(
438 laplace_trend_test(&[2.0], Truncation::Time(-1.0)),
439 Err(E::NotPositive {
440 index: None,
441 got: -1.0
442 })
443 );
444 assert_eq!(
445 laplace_trend_test(&[2.0], Truncation::Time(f64::INFINITY)),
446 Err(E::NotFinite { index: None })
447 );
448 assert_eq!(
449 power_law_process_fit(&[10.0, 10.0], tt),
450 Err(E::TooFewEvents { min: 2, got: 0 })
451 );
452 }
453
454 /// Under a constant rate the MIL-HDBK-189 p-value is uniform: at α = 0.05
455 /// about 5 % of simulated HPP paths reject (Monte Carlo, 4 000 paths).
456 #[test]
457 fn mil_hdbk_189_holds_its_size_under_a_constant_rate() {
458 use u_numflow::distributions::{Exponential, Sample};
459 let gaps = Exponential::new(1.0).unwrap();
460 let mut rng = u_numflow::random::create_rng(11);
461 let paths = 4000;
462 let mut rejected = 0;
463 for _ in 0..paths {
464 let mut t = 0.0;
465 let mut times = Vec::new();
466 loop {
467 t += gaps.sample(&mut rng);
468 if t > 20.0 {
469 break;
470 }
471 times.push(t);
472 }
473 if times.is_empty() {
474 continue;
475 }
476 if mil_hdbk_189_test(×, Truncation::Time(20.0))
477 .unwrap()
478 .p_value
479 < 0.05
480 {
481 rejected += 1;
482 }
483 }
484 let rate = rejected as f64 / paths as f64;
485 assert!((rate - 0.05).abs() < 0.015, "rejection rate {rate}");
486 }
487}