netspeed_cli/formatter/
stability.rs1#![allow(
4 clippy::cast_precision_loss,
5 clippy::cast_possible_truncation,
6 clippy::cast_sign_loss
7)]
8
9use crate::theme::{Colors, Theme};
10
11#[must_use]
13pub fn compute_cv(speeds: &[f64]) -> f64 {
14 if speeds.is_empty() {
15 return 0.0;
16 }
17 let n = speeds.len() as f64;
19 let mean = speeds.iter().sum::<f64>() / n;
20 if mean <= 0.0 {
21 return 0.0;
22 }
23 let variance = speeds.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / n;
24 let stddev = variance.sqrt();
25 (stddev / mean) * 100.0
26}
27
28#[must_use]
29pub fn format_stability_line(cv: f64, nc: bool, theme: Theme) -> String {
30 let label = if cv < 5.0 {
31 "rock-solid"
32 } else if cv < 10.0 {
33 "very stable"
34 } else if cv < 20.0 {
35 "moderate"
36 } else if cv < 35.0 {
37 "variable"
38 } else {
39 "unstable"
40 };
41 let text = format!("±{cv:.0}% {label}");
42 if nc {
43 text
44 } else if cv < 5.0 {
45 Colors::good(&text, theme)
46 } else if cv < 20.0 {
47 Colors::warn(&text, theme)
48 } else {
49 Colors::bad(&text, theme)
50 }
51}
52
53#[must_use]
54pub fn compute_percentiles(samples: &[f64]) -> Option<(f64, f64, f64)> {
55 let n = samples.len();
56 if n < 3 {
57 return None;
58 }
59 let mut data = samples.to_vec();
60 let p50_idx = n * 50 / 100;
61 let p95_idx = (n * 95 / 100).min(n - 1);
62 let p99_idx = (n * 99 / 100).min(n - 1);
63
64 data.select_nth_unstable_by(p99_idx, |a, b| {
66 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
67 });
68 let p99 = data[p99_idx];
69
70 data.select_nth_unstable_by(p95_idx, |a, b| {
72 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
73 });
74 let p95 = data[p95_idx];
75
76 data.select_nth_unstable_by(p50_idx, |a, b| {
78 a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
79 });
80 let p50 = data[p50_idx];
81
82 Some((p50, p95, p99))
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 #[test]
90 fn test_compute_cv_constant() {
91 let speeds = vec![100.0, 100.0, 100.0];
92 assert!(compute_cv(&speeds).abs() < f64::EPSILON);
93 }
94
95 #[test]
96 fn test_compute_cv_empty() {
97 assert!(compute_cv(&[]).abs() < f64::EPSILON);
98 }
99
100 #[test]
101 fn test_compute_percentiles_basic() {
102 let samples: Vec<f64> = (1..=100).map(f64::from).collect();
103 let result = compute_percentiles(&samples);
104 assert!(result.is_some());
105 let (p50, p95, p99) = result.unwrap();
106 assert!((p50 - 51.0).abs() < 1.0);
108 assert!((p95 - 96.0).abs() < 1.0);
109 assert!((p99 - 100.0).abs() < 1.0);
110 }
111
112 #[test]
113 fn test_compute_percentiles_too_few() {
114 assert!(compute_percentiles(&[1.0, 2.0]).is_none());
115 }
116}