use chrono::{Local, TimeZone, Timelike};
use chrono_tz::Tz;
use crate::nightscout::Entry;
pub const BUCKETS: usize = 96;
pub const BUCKET_MIN: i64 = 24 * 60 / BUCKETS as i64;
#[derive(Debug, Clone, Copy)]
pub struct Band {
pub minute: i64,
pub p05: f64,
pub p25: f64,
pub p50: f64,
pub p75: f64,
pub p95: f64,
pub days: usize,
}
#[cfg(test)]
pub fn profile(entries: &[Entry]) -> Vec<Band> {
profile_in(entries, None)
}
pub fn profile_in(entries: &[Entry], timezone: Option<Tz>) -> Vec<Band> {
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); BUCKETS];
let mut dates: Vec<std::collections::BTreeSet<String>> =
vec![std::collections::BTreeSet::new(); BUCKETS];
for e in entries {
let parts = match timezone {
Some(tz) => tz.timestamp_millis_opt(e.date).single().map(|dt| {
(
dt.hour() as i64 * 60 + dt.minute() as i64,
dt.date_naive().to_string(),
)
}),
None => Local.timestamp_millis_opt(e.date).single().map(|dt| {
(
dt.hour() as i64 * 60 + dt.minute() as i64,
dt.date_naive().to_string(),
)
}),
};
if let Some((minute, date)) = parts {
let idx = (minute / BUCKET_MIN).clamp(0, BUCKETS as i64 - 1) as usize;
buckets[idx].push(e.sgv);
dates[idx].insert(date);
}
}
let mut out = Vec::new();
for (i, vals) in buckets.into_iter().enumerate() {
if vals.is_empty() {
continue;
}
let days = dates[i].len();
let mut v = vals;
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
out.push(Band {
minute: i as i64 * BUCKET_MIN + BUCKET_MIN / 2,
p05: percentile(&v, 0.05),
p25: percentile(&v, 0.25),
p50: percentile(&v, 0.50),
p75: percentile(&v, 0.75),
p95: percentile(&v, 0.95),
days,
});
}
out
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Insight {
pub kind: Pattern,
pub from_min: i64,
pub to_min: i64,
pub extreme: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pattern {
Lows,
Highs,
}
const MIN_RUN_MIN: i64 = 45;
const MIN_DAYS: usize = 3;
pub fn insights(bands: &[Band], low: f64, high: f64) -> Vec<Insight> {
let mut out = Vec::new();
out.extend(runs(
bands,
MIN_RUN_MIN,
Pattern::Lows,
|b| b.days >= MIN_DAYS && b.p25 < low,
|b| b.p05,
));
out.extend(runs(
bands,
MIN_RUN_MIN,
Pattern::Highs,
|b| b.days >= MIN_DAYS && b.p50 > high,
|b| b.p95,
));
out.sort_by(|a, b| {
(a.kind == Pattern::Highs)
.cmp(&(b.kind == Pattern::Highs))
.then((b.to_min - b.from_min).cmp(&(a.to_min - a.from_min)))
});
out
}
fn runs(
bands: &[Band],
min_len: i64,
kind: Pattern,
hit: impl Fn(&Band) -> bool,
extreme_of: impl Fn(&Band) -> f64,
) -> Vec<Insight> {
let mut out: Vec<Insight> = Vec::new();
let mut run: Option<Insight> = None;
let mut prev_minute: Option<i64> = None;
for b in bands {
let contiguous = prev_minute.is_none_or(|p| b.minute - p <= BUCKET_MIN);
if hit(b) {
match run.as_mut() {
Some(r) if contiguous => {
r.to_min = b.minute;
r.extreme = pick_extreme(kind, r.extreme, extreme_of(b));
}
_ => {
push_if_long_enough(&mut out, run.take(), min_len);
run = Some(Insight {
kind,
from_min: b.minute,
to_min: b.minute,
extreme: extreme_of(b),
});
}
}
} else {
push_if_long_enough(&mut out, run.take(), min_len);
}
prev_minute = Some(b.minute);
}
push_if_long_enough(&mut out, run.take(), min_len);
out
}
fn pick_extreme(kind: Pattern, a: f64, b: f64) -> f64 {
match kind {
Pattern::Lows => a.min(b),
Pattern::Highs => a.max(b),
}
}
fn push_if_long_enough(out: &mut Vec<Insight>, run: Option<Insight>, min_len: i64) {
if let Some(r) = run {
if r.to_min - r.from_min + BUCKET_MIN >= min_len {
out.push(r);
}
}
}
impl Insight {
pub fn window(&self) -> String {
let fmt = |m: i64| format!("{:02}:{:02}", (m / 60) % 24, m % 60);
let start = self.from_min / BUCKET_MIN * BUCKET_MIN;
let end = (self.to_min / BUCKET_MIN + 1) * BUCKET_MIN;
format!("{}–{}", fmt(start), fmt(end))
}
pub fn text(&self, units: crate::units::Units) -> String {
let value = units.format(self.extreme);
let unit = units.label();
match self.kind {
Pattern::Lows => format!("lows {} (down to {value} {unit})", self.window()),
Pattern::Highs => format!("highs {} (up to {value} {unit})", self.window()),
}
}
}
fn percentile(sorted: &[f64], q: f64) -> f64 {
match sorted.len() {
0 => 0.0,
1 => sorted[0],
n => {
let rank = q * (n - 1) as f64;
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo as f64)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(sgv: f64, date: i64) -> Entry {
Entry {
sgv,
date,
direction: None,
}
}
fn at(day: i64, minutes: i64) -> i64 {
let base = Local
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
.single()
.unwrap()
.timestamp_millis();
base + (day * 24 * 60 + minutes) * 60_000
}
#[test]
fn empty_profile_for_no_entries() {
assert!(profile(&[]).is_empty());
}
#[test]
fn folds_days_onto_one_clock() {
let e = [
entry(100.0, at(0, 480)), entry(120.0, at(1, 480)), entry(140.0, at(2, 480)), ];
let bands = profile(&e);
assert_eq!(bands.len(), 1);
let b = bands[0];
assert_eq!(b.minute, 480 + BUCKET_MIN / 2);
assert_eq!(b.p50, 120.0); assert!(b.p05 <= b.p25 && b.p25 <= b.p50 && b.p50 <= b.p75 && b.p75 <= b.p95);
}
#[test]
fn separate_times_make_separate_bands() {
let e = [entry(90.0, at(0, 60)), entry(200.0, at(0, 720))];
let bands = profile(&e);
assert_eq!(bands.len(), 2);
assert!(bands[0].minute < bands[1].minute);
}
#[test]
fn configured_timezone_controls_the_clinical_clock() {
let instant = chrono::Utc
.with_ymd_and_hms(2026, 1, 15, 12, 0, 0)
.single()
.unwrap()
.timestamp_millis();
let amsterdam = profile_in(&[entry(100.0, instant)], Some(chrono_tz::Europe::Amsterdam));
let new_york = profile_in(&[entry(100.0, instant)], Some(chrono_tz::America::New_York));
assert_eq!(amsterdam[0].minute, 13 * 60 + BUCKET_MIN / 2);
assert_eq!(new_york[0].minute, 7 * 60 + BUCKET_MIN / 2);
}
#[test]
fn percentile_interpolates() {
let v = [10.0, 20.0, 30.0, 40.0];
assert_eq!(percentile(&v, 0.0), 10.0);
assert_eq!(percentile(&v, 1.0), 40.0);
assert_eq!(percentile(&v, 0.5), 25.0); }
fn profile_with_low_window(from: i64, to: i64) -> Vec<Band> {
(0..BUCKETS as i64)
.map(|i| {
let minute = i * BUCKET_MIN + BUCKET_MIN / 2;
let low = (from..to).contains(&minute);
Band {
minute,
p05: if low { 50.0 } else { 90.0 },
p25: if low { 62.0 } else { 100.0 },
p50: 110.0,
p75: 130.0,
p95: 150.0,
days: 14,
}
})
.collect()
}
#[test]
fn one_night_is_not_a_pattern() {
let mut bands = profile_with_low_window(120, 300);
for b in &mut bands {
b.days = 1;
}
assert!(
insights(&bands, 70.0, 180.0).is_empty(),
"a single night must not be named as a recurring pattern"
);
for b in &mut bands {
b.days = 2;
}
assert!(insights(&bands, 70.0, 180.0).is_empty());
for b in &mut bands {
b.days = 3;
}
assert!(!insights(&bands, 70.0, 180.0).is_empty());
}
#[test]
fn profile_counts_days_not_readings() {
use chrono::{Duration, TimeZone};
let base = Local.with_ymd_and_hms(2026, 3, 1, 2, 0, 0).unwrap();
let entries: Vec<Entry> = (0..12)
.map(|i| Entry {
sgv: 60.0,
date: (base + Duration::minutes(i * 5)).timestamp_millis(),
direction: None,
})
.collect();
let bands = profile(&entries);
assert!(!bands.is_empty());
assert!(
bands.iter().all(|b| b.days == 1),
"readings from one night are one day, however many there are: {:?}",
bands.iter().map(|b| b.days).collect::<Vec<_>>()
);
let mut spread = entries.clone();
for d in 1..3 {
spread.extend(entries.iter().map(|e| Entry {
date: e.date + d * 86_400_000,
..e.clone()
}));
}
let bands = profile(&spread);
assert!(bands.iter().all(|b| b.days == 3));
}
#[test]
fn finds_an_overnight_low_pattern() {
let bands = profile_with_low_window(120, 300); let found = insights(&bands, 70.0, 180.0);
assert_eq!(found.len(), 1);
let i = &found[0];
assert_eq!(i.kind, Pattern::Lows);
assert_eq!(i.window(), "02:00–05:00");
assert_eq!(i.extreme, 50.0); assert!(i
.text(crate::units::Units::Mgdl)
.starts_with("lows 02:00–05:00"));
}
#[test]
fn a_single_dip_is_not_a_pattern() {
let bands = profile_with_low_window(120, 130);
assert!(insights(&bands, 70.0, 180.0).is_empty());
}
#[test]
fn a_gap_in_the_data_does_not_join_two_runs() {
let bands: Vec<Band> = profile_with_low_window(0, 1440)
.into_iter()
.filter(|b| b.minute < 60 || b.minute > 600)
.collect();
let found = insights(&bands, 70.0, 180.0);
assert_eq!(found.len(), 2, "expected two separate runs, got {found:?}");
assert!(found[0].from_min > 600 || found[1].from_min > 600);
}
#[test]
fn finds_highs_and_ranks_lows_first() {
let mut bands = profile_with_low_window(120, 300);
for b in bands.iter_mut() {
if (1140..1380).contains(&b.minute) {
b.p50 = 220.0;
b.p95 = 260.0;
}
}
let found = insights(&bands, 70.0, 180.0);
assert_eq!(found.len(), 2);
assert_eq!(found[0].kind, Pattern::Lows);
assert_eq!(found[1].kind, Pattern::Highs);
assert_eq!(found[1].extreme, 260.0);
assert!(found[1]
.text(crate::units::Units::Mgdl)
.contains("up to 260"));
}
#[test]
fn a_profile_in_range_has_nothing_to_report() {
let bands = profile_with_low_window(0, 0);
assert!(insights(&bands, 70.0, 180.0).is_empty());
}
}