sugarrush 2026.8.2

A terminal UI for viewing Nightscout CGM (blood glucose sensor) data
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
//! Ambulatory Glucose Profile: fold several days of readings onto a single
//! 24-hour clock and summarise each time-of-day bucket as a percentile
//! envelope (the standard AGP median + 25/75 + 5/95 bands).

use chrono::{Local, TimeZone, Timelike};
use chrono_tz::Tz;

use crate::nightscout::Entry;

/// Number of time-of-day buckets — 15-minute resolution over 24h.
pub const BUCKETS: usize = 96;
/// Minutes per bucket.
pub const BUCKET_MIN: i64 = 24 * 60 / BUCKETS as i64;

/// Percentile envelope for one time-of-day bucket, in mg/dL.
#[derive(Debug, Clone, Copy)]
pub struct Band {
    /// Minutes since local midnight at the bucket centre.
    pub minute: i64,
    pub p05: f64,
    pub p25: f64,
    pub p50: f64,
    pub p75: f64,
    pub p95: f64,
    /// How many distinct local dates contributed readings to this bucket.
    ///
    /// A percentile envelope computed from one night looks exactly like one
    /// computed from a month — with a single day's readings p25 and p50 are
    /// the same number — so without this an insight could call one bad night a
    /// recurring pattern, and print that into a clinician's export.
    pub days: usize,
}

/// Group `entries` by local time-of-day and compute the percentile envelope for
/// each populated bucket, ordered by time of day. Empty buckets are skipped.
#[cfg(test)]
pub fn profile(entries: &[Entry]) -> Vec<Band> {
    profile_in(entries, None)
}

/// Profile in the followed person's configured timezone. `None` retains the
/// historical viewer-local behavior.
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
}

/// A recurring time-of-day pattern worth naming.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Insight {
    pub kind: Pattern,
    /// Minutes since local midnight, inclusive start and end of the run.
    pub from_min: i64,
    pub to_min: i64,
    /// The worst value in the run, mg/dL — how low the lows got, or how high
    /// the highs.
    pub extreme: f64,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pattern {
    /// A quarter of readings or more sit below target at this time of day.
    Lows,
    /// The median sits above target at this time of day.
    Highs,
}

/// Shortest run worth reporting. Below this it's a blip in one bucket or two,
/// not a pattern you could act on.
const MIN_RUN_MIN: i64 = 45;

/// A pattern is something that happened on separate days. Below this, the
/// window is one or two nights and the percentile spread describes those
/// nights, not a habit — so it gets no name.
const MIN_DAYS: usize = 3;

/// Name the recurring patterns in a profile.
///
/// The point of an AGP is that it shows *when* things go wrong, but reading
/// that off a percentile fan is a skill. These are the two findings that change
/// what someone does: a time of day where a quarter of readings are below
/// target (`p25 < low`), and one where the typical reading is above it
/// (`p50 > high`).
///
/// Lows use the 25th percentile rather than the median because a low that
/// happens on a quarter of days is already a pattern; waiting for the median to
/// drop below target would mean it happens on most days before it's mentioned.
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,
    ));
    // Lows first: they're the ones that need acting on tonight.
    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
}

/// Contiguous runs of buckets matching `hit`, as insights.
///
/// Contiguity is in *time*, not in index: `profile` omits empty buckets, so two
/// adjacent entries in the slice can be hours apart, and treating them as one
/// run would invent a pattern across a gap with no data in it.
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 {
        // A run spans from the start of its first bucket to the end of its
        // last, so a single bucket is BUCKET_MIN wide, not zero.
        if r.to_min - r.from_min + BUCKET_MIN >= min_len {
            out.push(r);
        }
    }
}

impl Insight {
    /// `02:00–05:00` — the window this pattern covers.
    ///
    /// Derived from the bucket indices rather than by offsetting the stored
    /// centres: a centre is truncated (bucket 19 of 15 minutes is centred at
    /// 292.5 but stored as 292), so ±half a bucket lands a minute short and
    /// reports 04:59.
    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))
    }

    /// A one-line finding, in display units.
    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()),
        }
    }
}

/// Linear-interpolated percentile of a sorted slice; `q` in `[0, 1]`.
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,
        }
    }

    /// Local midnight + `minutes`, on a fixed reference day, as epoch ms.
    fn at(day: i64, minutes: i64) -> i64 {
        // 2026-01-01 00:00 local + day days + minutes.
        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() {
        // Same time of day across three days lands in one bucket.
        let e = [
            entry(100.0, at(0, 480)), // 08:00 day 0
            entry(120.0, at(1, 480)), // 08:00 day 1
            entry(140.0, at(2, 480)), // 08:00 day 2
        ];
        let bands = profile(&e);
        assert_eq!(bands.len(), 1);
        let b = bands[0];
        // Bucket centre for the 08:00 slot.
        assert_eq!(b.minute, 480 + BUCKET_MIN / 2);
        assert_eq!(b.p50, 120.0); // median of 100/120/140
        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);
        // Ordered by time of day.
        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); // midpoint of 20 and 30
    }

    /// A profile with `p25` below target from `from` to `to` (minutes), and
    /// comfortably in range elsewhere.
    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()
    }

    /// A percentile envelope built from one night is indistinguishable from one
    /// built from a month — with a single day's readings p25 and p50 collapse
    /// onto the same number — so the run guard has to look at how many separate
    /// days fed the bucket, not just how long the run is. Naming one bad night
    /// a "pattern" is bad enough on screen; it also went into the clinician
    /// export.
    #[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"
        );

        // Two nights is still two nights.
        for b in &mut bands {
            b.days = 2;
        }
        assert!(insights(&bands, 70.0, 180.0).is_empty());

        // Three separate days is a habit worth naming.
        for b in &mut bands {
            b.days = 3;
        }
        assert!(!insights(&bands, 70.0, 180.0).is_empty());
    }

    /// `profile` has to count distinct local dates, not readings.
    #[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();
        // Twelve readings, all inside one night.
        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<_>>()
        );

        // The same clock time on three separate days is three days.
        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); // 02:00–05:00
        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); // reports how low it actually got
        assert!(i
            .text(crate::units::Units::Mgdl)
            .starts_with("lows 02:00–05:00"));
    }

    #[test]
    fn a_single_dip_is_not_a_pattern() {
        // One 15-minute bucket below target is noise, not something to act on.
        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() {
        // Two short low windows either side of a stretch with no readings at
        // all. Bridging them would invent a pattern across hours of nothing.
        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);
        // A long post-dinner high: median above target 19:00–23:00.
        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);
        // Lows come first even though the high window is longer.
        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());
    }
}