sugarrush 2026.8.1

A terminal UI for viewing Nightscout CGM (blood glucose sensor) data
//! Summary statistics over a set of readings: time-in-range, mean, GMI, and
//! glycaemic variability.

use crate::nightscout::Entry;

/// Share of readings in each clinical band, as percentages summing to 100.
///
/// Five bands rather than three: the international consensus on time in range
/// reports "very low" and "very high" separately, because 20% high and 20%
/// *very* high are different clinical pictures. The band edges follow the
/// configured thresholds, so what the stats panel counts as urgent is what the
/// alarm treats as urgent.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tir {
    pub very_low: f64,
    pub low: f64,
    pub in_range: f64,
    pub high: f64,
    pub very_high: f64,
}

impl Tir {
    /// Everything below the target range (very low + low).
    pub fn below(&self) -> f64 {
        self.very_low + self.low
    }
}

/// Time-in-range over `entries`. Bounds are mg/dL and match [`crate::alert`]'s
/// classification: at or below `urgent_low` is very low, below `low` is low,
/// `[low, high]` is in range, above `high` is high, at or above `urgent_high`
/// is very high. `None` when there are no readings.
pub fn tir(
    entries: &[Entry],
    urgent_low: f64,
    low: f64,
    high: f64,
    urgent_high: f64,
) -> Option<Tir> {
    if entries.is_empty() {
        return None;
    }
    let total = entries.len() as f64;
    let (mut vlo, mut lo, mut hi, mut vhi) = (0.0, 0.0, 0.0, 0.0);
    for e in entries {
        if e.sgv <= urgent_low {
            vlo += 1.0;
        } else if e.sgv < low {
            lo += 1.0;
        } else if e.sgv >= urgent_high {
            vhi += 1.0;
        } else if e.sgv > high {
            hi += 1.0;
        }
    }
    let pct = |n: f64| n / total * 100.0;
    let (very_low, low_pct, high_pct, very_high) = (pct(vlo), pct(lo), pct(hi), pct(vhi));
    Some(Tir {
        very_low,
        low: low_pct,
        high: high_pct,
        very_high,
        in_range: 100.0 - very_low - low_pct - high_pct - very_high,
    })
}

/// Mean sensor glucose in mg/dL, or `None` when there are no readings.
pub fn mean_mgdl(entries: &[Entry]) -> Option<f64> {
    if entries.is_empty() {
        return None;
    }
    let sum: f64 = entries.iter().map(|e| e.sgv).sum();
    Some(sum / entries.len() as f64)
}

/// Coefficient of variation (%) — the standard deviation as a share of the
/// mean, the standard measure of glycaemic variability. Consensus target is
/// ≤ 36%; above that, hypos become much more likely at the same average.
/// `None` with fewer than two readings (no sample variance to speak of).
pub fn cv_pct(entries: &[Entry]) -> Option<f64> {
    if entries.len() < 2 {
        return None;
    }
    let mean = mean_mgdl(entries)?;
    if mean <= 0.0 {
        return None;
    }
    let n = entries.len() as f64;
    // Sample standard deviation (n-1): these readings are a sample of the
    // period, not the whole population of possible readings.
    let var = entries.iter().map(|e| (e.sgv - mean).powi(2)).sum::<f64>() / (n - 1.0);
    Some(var.sqrt() / mean * 100.0)
}

/// Glucose Management Indicator (estimated A1c, %) from mean mg/dL.
/// GMI(%) = 3.31 + 0.02392 × mean. (Bergenstal et al. 2018.)
pub fn gmi(mean_mgdl: f64) -> f64 {
    3.31 + 0.02392 * mean_mgdl
}

#[cfg(test)]
mod tests {
    use super::*;

    fn e(sgv: f64) -> Entry {
        Entry {
            sgv,
            date: 0,
            direction: None,
        }
    }

    /// Defaults: 55 / 70 / 180 / 250.
    fn tir_default(entries: &[Entry]) -> Option<Tir> {
        tir(entries, 55.0, 70.0, 180.0, 250.0)
    }

    #[test]
    fn tir_none_when_empty() {
        assert!(tir_default(&[]).is_none());
    }

    #[test]
    fn tir_splits_five_bands() {
        // 1 very low, 1 low, 2 in range, 1 high, 1 very high = 6 readings.
        let entries = [e(45.0), e(60.0), e(100.0), e(120.0), e(200.0), e(300.0)];
        let t = tir_default(&entries).unwrap();
        assert!((t.very_low - 100.0 / 6.0).abs() < 1e-9);
        assert!((t.low - 100.0 / 6.0).abs() < 1e-9);
        assert!((t.in_range - 200.0 / 6.0).abs() < 1e-9);
        assert!((t.high - 100.0 / 6.0).abs() < 1e-9);
        assert!((t.very_high - 100.0 / 6.0).abs() < 1e-9);
        assert!((t.below() - 200.0 / 6.0).abs() < 1e-9);
        let total = t.very_low + t.low + t.in_range + t.high + t.very_high;
        assert!((total - 100.0).abs() < 1e-9);
    }

    #[test]
    fn tir_bands_match_the_alert_thresholds() {
        // Exactly on a threshold lands where alert::from_value puts it.
        assert_eq!(tir_default(&[e(55.0)]).unwrap().very_low, 100.0);
        assert_eq!(tir_default(&[e(69.0)]).unwrap().low, 100.0);
        assert_eq!(tir_default(&[e(70.0)]).unwrap().in_range, 100.0);
        assert_eq!(tir_default(&[e(180.0)]).unwrap().in_range, 100.0);
        assert_eq!(tir_default(&[e(181.0)]).unwrap().high, 100.0);
        assert_eq!(tir_default(&[e(250.0)]).unwrap().very_high, 100.0);
    }

    #[test]
    fn mean_and_gmi() {
        assert_eq!(mean_mgdl(&[e(100.0), e(200.0)]).unwrap(), 150.0);
        // GMI at mean 150 ≈ 6.9%.
        assert!((gmi(150.0) - 6.898).abs() < 0.01);
    }

    #[test]
    fn cv_needs_two_readings_and_scales_with_spread() {
        assert!(cv_pct(&[]).is_none());
        assert!(cv_pct(&[e(100.0)]).is_none());
        // Perfectly flat glucose has no variability.
        assert_eq!(cv_pct(&[e(100.0), e(100.0)]).unwrap(), 0.0);
        // 90/110 around a mean of 100: sample SD is 14.14 → CV 14.1%.
        let cv = cv_pct(&[e(90.0), e(110.0)]).unwrap();
        assert!((cv - 14.14).abs() < 0.01, "cv was {cv}");
        // Wider spread, same mean → higher CV.
        assert!(cv_pct(&[e(50.0), e(150.0)]).unwrap() > cv);
    }
}