Skip to main content

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(&times, 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}