use crate::config::Alerts;
const MS_PER_MIN: i64 = 60_000;
const HYSTERESIS_MGDL: f64 = 4.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alert {
UrgentLow,
Low,
InRange,
High,
UrgentHigh,
Stale,
}
impl Alert {
pub fn is_alerting(self) -> bool {
!matches!(self, Alert::InRange)
}
pub fn is_urgent(self) -> bool {
matches!(self, Alert::UrgentLow | Alert::UrgentHigh | Alert::Stale)
}
pub fn label(self) -> &'static str {
match self {
Alert::UrgentLow => "URGENT LOW",
Alert::Low => "LOW",
Alert::InRange => "in range",
Alert::High => "HIGH",
Alert::UrgentHigh => "URGENT HIGH",
Alert::Stale => "SENSOR GAP — no recent readings",
}
}
pub fn class(self) -> &'static str {
match self {
Alert::UrgentLow => "urgent-low",
Alert::Low => "low",
Alert::InRange => "in-range",
Alert::High => "high",
Alert::UrgentHigh => "urgent-high",
Alert::Stale => "stale",
}
}
pub fn severity(self) -> u8 {
match self {
Alert::UrgentLow => 0,
Alert::UrgentHigh => 1,
Alert::Stale => 2,
Alert::Low => 3,
Alert::High => 4,
Alert::InRange => 5,
}
}
pub fn color(self, theme: &crate::theme::Theme) -> ratatui::style::Color {
match self {
Alert::UrgentLow | Alert::UrgentHigh | Alert::Stale => theme.urgent,
Alert::Low => theme.low,
Alert::High => theme.high,
Alert::InRange => theme.in_range,
}
}
pub fn urgency(self) -> &'static str {
match self {
Alert::UrgentLow | Alert::UrgentHigh | Alert::Stale => "critical",
_ => "normal",
}
}
}
pub fn from_value(sgv: f64, a: &Alerts) -> Alert {
if sgv <= a.urgent_low {
Alert::UrgentLow
} else if sgv < a.low {
Alert::Low
} else if sgv >= a.urgent_high {
Alert::UrgentHigh
} else if sgv > a.high {
Alert::High
} else {
Alert::InRange
}
}
pub fn evaluate(sgv: f64, age_ms: i64, a: &Alerts) -> Alert {
evaluate_from(sgv, age_ms, a, Alert::InRange)
}
pub fn evaluate_from(sgv: f64, age_ms: i64, a: &Alerts, prev: Alert) -> Alert {
if age_ms > a.stale_minutes * MS_PER_MIN {
return Alert::Stale;
}
let m = HYSTERESIS_MGDL;
let (urgent_low, low, high, urgent_high) = match prev {
Alert::UrgentLow => (a.urgent_low + m, a.low, a.high, a.urgent_high),
Alert::Low => (a.urgent_low, a.low + m, a.high, a.urgent_high),
Alert::High => (a.urgent_low, a.low, a.high - m, a.urgent_high),
Alert::UrgentHigh => (a.urgent_low, a.low, a.high, a.urgent_high - m),
_ => (a.urgent_low, a.low, a.high, a.urgent_high),
};
if sgv <= urgent_low {
Alert::UrgentLow
} else if sgv < low {
Alert::Low
} else if sgv >= urgent_high {
Alert::UrgentHigh
} else if sgv > high {
Alert::High
} else {
Alert::InRange
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> Alerts {
Alerts::default() }
const FRESH: i64 = 0;
#[test]
fn in_range_midband() {
assert_eq!(evaluate(100.0, FRESH, &cfg()), Alert::InRange);
}
#[test]
fn low_and_urgent_low_boundaries() {
assert_eq!(evaluate(69.0, FRESH, &cfg()), Alert::Low);
assert_eq!(evaluate(55.0, FRESH, &cfg()), Alert::UrgentLow); assert_eq!(evaluate(40.0, FRESH, &cfg()), Alert::UrgentLow);
}
#[test]
fn high_and_urgent_high_boundaries() {
assert_eq!(evaluate(181.0, FRESH, &cfg()), Alert::High);
assert_eq!(evaluate(250.0, FRESH, &cfg()), Alert::UrgentHigh); assert_eq!(evaluate(300.0, FRESH, &cfg()), Alert::UrgentHigh);
}
#[test]
fn stale_overrides_value() {
let sixteen_min = 16 * MS_PER_MIN;
assert_eq!(evaluate(100.0, sixteen_min, &cfg()), Alert::Stale);
}
#[test]
fn entering_an_alert_uses_the_raw_threshold() {
assert_eq!(
evaluate_from(69.0, FRESH, &cfg(), Alert::InRange),
Alert::Low
);
assert_eq!(
evaluate_from(55.0, FRESH, &cfg(), Alert::Low),
Alert::UrgentLow
);
assert_eq!(
evaluate_from(181.0, FRESH, &cfg(), Alert::InRange),
Alert::High
);
}
#[test]
fn leaving_an_alert_needs_the_margin() {
assert_eq!(evaluate_from(71.0, FRESH, &cfg(), Alert::Low), Alert::Low);
assert_eq!(
evaluate_from(75.0, FRESH, &cfg(), Alert::Low),
Alert::InRange
);
assert_eq!(
evaluate_from(57.0, FRESH, &cfg(), Alert::UrgentLow),
Alert::UrgentLow
);
assert_eq!(
evaluate_from(60.0, FRESH, &cfg(), Alert::UrgentLow),
Alert::Low
);
assert_eq!(
evaluate_from(179.0, FRESH, &cfg(), Alert::High),
Alert::High
);
assert_eq!(
evaluate_from(247.0, FRESH, &cfg(), Alert::UrgentHigh),
Alert::UrgentHigh
);
}
#[test]
fn staleness_still_wins_over_hysteresis() {
let sixteen_min = 16 * MS_PER_MIN;
assert_eq!(
evaluate_from(100.0, sixteen_min, &cfg(), Alert::Low),
Alert::Stale
);
}
#[test]
fn is_alerting_only_for_out_of_range() {
assert!(!Alert::InRange.is_alerting());
assert!(Alert::Low.is_alerting());
assert!(Alert::Stale.is_alerting());
}
#[test]
fn severity_puts_the_worst_first_and_ranks_no_data_high() {
let mut all = [
Alert::InRange,
Alert::High,
Alert::Low,
Alert::Stale,
Alert::UrgentHigh,
Alert::UrgentLow,
];
all.sort_by_key(|a| a.severity());
assert_eq!(
all,
[
Alert::UrgentLow,
Alert::UrgentHigh,
Alert::Stale,
Alert::Low,
Alert::High,
Alert::InRange,
]
);
}
#[test]
fn every_state_takes_its_colour_from_the_theme() {
let t = crate::theme::Theme::default();
assert_eq!(Alert::Stale.color(&t), t.urgent);
assert_eq!(Alert::UrgentLow.color(&t), t.urgent);
assert_eq!(Alert::Low.color(&t), t.low);
assert_eq!(Alert::High.color(&t), t.high);
assert_eq!(Alert::InRange.color(&t), t.in_range);
}
}