use crate::alert;
use crate::config::Alerts;
use crate::nightscout::{Entry, Prediction};
use crate::units::Units;
const BG_REF: f64 = 140.0;
const AR: [f64; 2] = [-0.723, 1.716];
const STEP_MS: i64 = 5 * 60_000;
const MIN_STEP_MS: i64 = 3 * 60_000;
const MAX_STEP_MS: i64 = 8 * 60_000;
const STEPS: usize = 6;
pub const HORIZON_MINUTES: i64 = STEPS as i64 * STEP_MS / 60_000;
const BG_MIN: f64 = 36.0;
const BG_MAX: f64 = 400.0;
const SPREAD_PER_STEP: f64 = 4.0;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Outlook {
pub in_min: i64,
pub value: f64,
pub class: &'static str,
#[serde(skip)]
pub alert: alert::Alert,
}
pub fn outlook(recent: &[Entry], alerts: &Alerts, units: Units) -> Option<Outlook> {
let steps = ar2(recent);
let last = steps.last()?;
let midpoint = (last.low + last.high) / 2.0;
let value = units.from_mgdl(midpoint);
Some(Outlook {
in_min: HORIZON_MINUTES,
value: match units {
Units::Mmol => (value * 10.0).round() / 10.0,
Units::Mgdl => value.round(),
},
class: alert::from_value(midpoint, alerts).class(),
alert: alert::from_value(midpoint, alerts),
})
}
pub fn ar2(entries: &[Entry]) -> Vec<Prediction> {
let (latest, prev) = match (entries.first(), entries.get(1)) {
(Some(a), Some(b)) => (a, b),
_ => return Vec::new(),
};
let gap = latest.date - prev.date;
if !(MIN_STEP_MS..=MAX_STEP_MS).contains(&gap) {
return Vec::new();
}
let mut y0 = (prev.sgv / BG_REF).ln();
let mut y1 = (latest.sgv / BG_REF).ln();
let mut out = Vec::with_capacity(STEPS);
for i in 1..=STEPS as i64 {
let y_next = AR[0] * y0 + AR[1] * y1;
y0 = y1;
y1 = y_next;
let center = BG_REF * y_next.exp();
let spread = SPREAD_PER_STEP * i as f64;
out.push(Prediction {
at_ms: latest.date + i * STEP_MS,
low: (center - spread).clamp(BG_MIN, BG_MAX),
high: (center + spread).clamp(BG_MIN, BG_MAX),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(sgv: f64, date: i64) -> Entry {
Entry {
sgv,
date,
direction: None,
}
}
#[test]
fn empty_without_two_readings() {
assert!(ar2(&[]).is_empty());
assert!(ar2(&[entry(100.0, 0)]).is_empty());
}
#[test]
fn projects_six_widening_bands_five_min_apart() {
let now = 1_000_000_000_000;
let out = ar2(&[entry(120.0, now), entry(115.0, now - STEP_MS)]);
assert_eq!(out.len(), STEPS);
assert_eq!(out[0].at_ms, now + STEP_MS);
assert_eq!(out[5].at_ms, now + 6 * STEP_MS);
let w0 = out[0].high - out[0].low;
let w5 = out[5].high - out[5].low;
assert!(w5 > w0);
let flat = ar2(&[entry(100.0, now), entry(100.0, now - STEP_MS)]);
let mid = (flat[0].low + flat[0].high) / 2.0;
assert!((mid - 100.0).abs() < 1.0);
}
#[test]
fn stays_within_physiological_clamp() {
let now = 0;
let out = ar2(&[entry(390.0, now), entry(300.0, now - STEP_MS)]);
assert!(out
.iter()
.all(|p| (BG_MIN..=BG_MAX).contains(&p.low) && (BG_MIN..=BG_MAX).contains(&p.high)));
}
#[test]
fn a_sensor_gap_produces_no_forecast_rather_than_a_wrong_one() {
let now = 1_700_000_000_000;
let close = ar2(&[entry(90.0, now), entry(110.0, now - 5 * STEP_MS / 5)]);
assert_eq!(close.len(), STEPS);
let gapped = ar2(&[entry(90.0, now), entry(110.0, now - 40 * 60_000)]);
assert!(gapped.is_empty(), "forecast built across a 40-minute gap");
assert!(ar2(&[entry(90.0, now), entry(110.0, now - 30_000)]).is_empty());
assert_eq!(
ar2(&[entry(90.0, now), entry(95.0, now - 6 * 60_000)]).len(),
STEPS
);
}
}