use crate::constants::{BUFFERBLOAT_THRESHOLDS, GRADE_UNAVAILABLE, MIN_STABILITY_SAMPLES, STABILITY_THRESHOLDS};
use crate::model::{ConnectionQuality, RunResult};
pub fn bufferbloat_grade(bloat_ms: f64) -> &'static str {
let v = bloat_ms.max(0.0);
for (threshold, grade) in BUFFERBLOAT_THRESHOLDS {
if v <= *threshold {
return grade;
}
}
"F"
}
pub fn stability_grade(cv_pct: f64) -> &'static str {
for (threshold, grade) in STABILITY_THRESHOLDS {
if cv_pct <= *threshold {
return grade;
}
}
"F"
}
pub fn cv_percent(samples: &[f64]) -> Option<f64> {
if samples.len() < MIN_STABILITY_SAMPLES {
return None;
}
let n = samples.len() as f64;
let mean = samples.iter().sum::<f64>() / n;
if mean.abs() < f64::EPSILON {
return None;
}
let var = samples.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / (n - 1.0);
Some(var.sqrt() / mean * 100.0)
}
pub fn compute(
result: &RunResult,
dl_points: &[(f64, f64)],
ul_points: &[(f64, f64)],
) -> Option<ConnectionQuality> {
let idle = result.idle_latency.median_ms;
let dl_med = result.loaded_latency_download.median_ms;
let ul_med = result.loaded_latency_upload.median_ms;
let bloat_ms = match idle {
Some(i) => {
let deltas: Vec<f64> = [dl_med, ul_med]
.into_iter()
.flatten()
.map(|x| (x - i).max(0.0))
.collect();
if deltas.is_empty() {
None
} else {
Some(deltas.into_iter().fold(0.0_f64, f64::max))
}
}
None => None,
};
let dl_mbps: Vec<f64> = dl_points.iter().map(|(_, m)| *m).collect();
let ul_mbps: Vec<f64> = ul_points.iter().map(|(_, m)| *m).collect();
let cv_dl = cv_percent(&dl_mbps);
let cv_ul = cv_percent(&ul_mbps);
let cv_worst = match (cv_dl, cv_ul) {
(Some(a), Some(b)) => Some(a.max(b)),
(Some(a), None) | (None, Some(a)) => Some(a),
(None, None) => None,
};
if bloat_ms.is_none() && cv_worst.is_none() {
return None;
}
let (bufferbloat_grade_str, bufferbloat_ms_field) = match bloat_ms {
Some(ms) => (bufferbloat_grade(ms).to_string(), Some(ms)),
None => (GRADE_UNAVAILABLE.to_string(), None),
};
let (stability_grade_str, stability_cv_field) = match cv_worst {
Some(cv) => (stability_grade(cv).to_string(), Some(cv)),
None => (GRADE_UNAVAILABLE.to_string(), None),
};
Some(ConnectionQuality {
bufferbloat_grade: bufferbloat_grade_str,
bufferbloat_ms: bufferbloat_ms_field,
stability_grade: stability_grade_str,
stability_cv_pct: stability_cv_field,
stability_cv_download_pct: cv_dl,
stability_cv_upload_pct: cv_ul,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{empty_run_result, LatencySummary, RunResult};
fn make_result(idle_med: Option<f64>, dl_med: Option<f64>, ul_med: Option<f64>) -> RunResult {
let mut r = empty_run_result();
r.idle_latency = LatencySummary { median_ms: idle_med, ..Default::default() };
r.loaded_latency_download = LatencySummary { median_ms: dl_med, ..Default::default() };
r.loaded_latency_upload = LatencySummary { median_ms: ul_med, ..Default::default() };
r
}
fn pts(mbps: &[f64]) -> Vec<(f64, f64)> {
mbps.iter().enumerate().map(|(i, m)| (i as f64, *m)).collect()
}
#[test]
fn cv_percent_handles_steady_signal() {
let cv = cv_percent(&[100.0, 100.0, 100.0]);
assert!(cv.is_some());
assert!(cv.unwrap() < 0.0001);
}
#[test]
fn cv_percent_handles_variation() {
let cv = cv_percent(&[90.0, 100.0, 110.0]);
assert!(cv.is_some());
let v = cv.unwrap();
assert!((v - 10.0).abs() < 0.5, "got cv={v}");
}
#[test]
fn cv_percent_too_few_samples() {
assert!(cv_percent(&[]).is_none());
assert!(cv_percent(&[100.0]).is_none());
assert!(cv_percent(&[100.0, 100.0]).is_none());
}
#[test]
fn cv_percent_zero_mean() {
assert!(cv_percent(&[0.0, 0.0, 0.0]).is_none());
}
#[test]
fn compute_full_grades_for_normal_run() {
let r = make_result(Some(20.0), Some(25.0), Some(50.0));
let cq = compute(&r, &pts(&[100.0; 5]), &pts(&[90.0, 100.0, 110.0, 100.0, 90.0])).unwrap();
assert_eq!(cq.bufferbloat_grade, "A");
assert!((cq.bufferbloat_ms.unwrap() - 30.0).abs() < 0.01);
assert!(cq.stability_cv_download_pct.unwrap() < 0.01);
assert!(cq.stability_cv_upload_pct.unwrap() > 5.0);
assert!((cq.stability_cv_pct.unwrap() - cq.stability_cv_upload_pct.unwrap()).abs() < 0.01);
assert_eq!(cq.stability_grade, "B");
}
#[test]
fn compute_returns_none_when_nothing_computable() {
let r = make_result(None, None, None);
assert!(compute(&r, &[], &[]).is_none());
}
#[test]
fn compute_bufferbloat_only_when_throughput_missing() {
let r = make_result(Some(20.0), Some(80.0), Some(50.0));
let cq = compute(&r, &[], &[]).unwrap();
assert_eq!(cq.bufferbloat_grade, "B");
assert!((cq.bufferbloat_ms.unwrap() - 60.0).abs() < 0.01);
assert_eq!(cq.stability_grade, crate::constants::GRADE_UNAVAILABLE);
assert!(cq.stability_cv_pct.is_none());
assert!(cq.stability_cv_download_pct.is_none());
assert!(cq.stability_cv_upload_pct.is_none());
}
#[test]
fn compute_stability_only_when_bufferbloat_missing() {
let r = make_result(None, Some(100.0), Some(100.0));
let cq = compute(&r, &pts(&[100.0; 5]), &pts(&[100.0; 5])).unwrap();
assert_eq!(cq.bufferbloat_grade, crate::constants::GRADE_UNAVAILABLE);
assert!(cq.bufferbloat_ms.is_none());
assert_eq!(cq.stability_grade, "A");
}
#[test]
fn compute_single_direction_loaded_latency() {
let r = make_result(Some(20.0), Some(220.0), None);
let cq = compute(&r, &[], &[]).unwrap();
assert_eq!(cq.bufferbloat_grade, "C");
assert_eq!(cq.stability_grade, crate::constants::GRADE_UNAVAILABLE);
}
#[test]
fn bufferbloat_boundary_aplus() {
assert_eq!(bufferbloat_grade(0.0), "A+");
assert_eq!(bufferbloat_grade(5.0), "A+");
}
#[test]
fn bufferbloat_boundary_a() {
assert_eq!(bufferbloat_grade(5.0001), "A");
assert_eq!(bufferbloat_grade(30.0), "A");
}
#[test]
fn bufferbloat_boundary_b() {
assert_eq!(bufferbloat_grade(30.0001), "B");
assert_eq!(bufferbloat_grade(60.0), "B");
}
#[test]
fn bufferbloat_boundary_c() {
assert_eq!(bufferbloat_grade(60.0001), "C");
assert_eq!(bufferbloat_grade(200.0), "C");
}
#[test]
fn bufferbloat_boundary_d() {
assert_eq!(bufferbloat_grade(200.0001), "D");
assert_eq!(bufferbloat_grade(400.0), "D");
}
#[test]
fn bufferbloat_boundary_f() {
assert_eq!(bufferbloat_grade(400.0001), "F");
assert_eq!(bufferbloat_grade(10_000.0), "F");
}
#[test]
fn bufferbloat_negative_clamps_to_aplus() {
assert_eq!(bufferbloat_grade(-5.0), "A+");
assert_eq!(bufferbloat_grade(-1000.0), "A+");
}
#[test]
fn stability_boundaries() {
assert_eq!(stability_grade(0.0), "A");
assert_eq!(stability_grade(5.0), "A");
assert_eq!(stability_grade(5.0001), "B");
assert_eq!(stability_grade(10.0), "B");
assert_eq!(stability_grade(10.0001), "C");
assert_eq!(stability_grade(20.0), "C");
assert_eq!(stability_grade(20.0001), "D");
assert_eq!(stability_grade(35.0), "D");
assert_eq!(stability_grade(35.0001), "F");
assert_eq!(stability_grade(1_000.0), "F");
}
}