diff --git a/lib/data/backup_extras.dart b/lib/data/backup_extras.dart index 8b508ef6c..e57cd292f 100644 --- a/lib/data/backup_extras.dart +++ b/lib/data/backup_extras.dart @@ -49,6 +49,8 @@ const Map kPrefsNotBackedUp = { 'alarm_latch_alert': 'pending latch alert for the old band arm', 'alarm_probe_pending': 'legacy pending hardware probe; never resume on another band', 'alarm_fired_at': 'last fire event from the old band, not the chosen schedule', + 'smart_wake_fired_epoch': 'records that the early buzz fired for one arm on the old band; ' + 'a restored latch would suppress the buzz on the next night\'s arm', 'health_upload_last_ts': 'upload cursor for this installation', 'health_weight_perm_asked': 'WEIGHT write access is asked per install; ' 'the new install has not asked yet', diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index bb0aeda51..ff24886d5 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3509,6 +3509,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "Experimenteller Forschungsalgorithmus. Kein Medizinprodukt. Bei Schlafstörungen einen Arzt konsultieren.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "pairBandNotSensor": "Dieses Gerät ist ein {label}, also ein Band und kein einfacher Sensor. Es wurde nichts gespeichert.", "welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.", "welcomeSettingsSkipped": "{n, plural, =1{1 Einstellung in der Sicherung war nicht lesbar und wurde übersprungen} other{{n} Einstellungen in der Sicherung waren nicht lesbar und wurden übersprungen}}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7dc473837..b2e4885a1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16528,6 +16528,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "pairBandNotSensor": "That device is a {label}, a band, not a plain sensor. Nothing was saved.", "@pairBandNotSensor": { "description": "Shown when a device picked as a plain sensor turns out to be a band. Nothing was saved. {label} is the band’s name.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index bd23b7c57..8d8c21950 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2927,6 +2927,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "Algoritmo experimental de investigación. No es un dispositivo médico. Consulta a un médico si tienes trastornos del sueño.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "welcomeCoachKeyReentry": "Tu clave de API del asistente permanece en el almacenamiento seguro y no se incluyó en la copia. Vuelve a introducirla en los ajustes de IA para usar el asistente en este teléfono.", "welcomeSettingsSkipped": "{n, plural, =1{1 ajuste de la copia no se pudo leer y se omitió} other{{n} ajustes de la copia no se pudieron leer y se omitieron}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 274104dc5..57cbfb8dc 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2898,6 +2898,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "Algorithme expérimental de recherche. Ce n'est pas un dispositif médical. Consultez un médecin en cas de troubles du sommeil.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "welcomeCoachKeyReentry": "Votre clé API du coach reste dans le stockage sécurisé et n’a pas été sauvegardée. Saisissez-la à nouveau dans les réglages de l’IA pour utiliser le coach sur ce téléphone.", "welcomeSettingsSkipped": "{n, plural, =1{1 réglage de la sauvegarde était illisible et a été ignoré} other{{n} réglages de la sauvegarde étaient illisibles et ont été ignorés}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 00420e3b6..5f33c4065 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2898,6 +2898,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "प्रायोगिक शोध एल्गोरिदम। यह चिकित्सा उपकरण नहीं है। नींद से जुड़ी समस्याओं के लिए किसी चिकित्सक से परामर्श करें।", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "welcomeCoachKeyReentry": "आपके कोच की API कुंजी सुरक्षित स्टोरेज में रहती है और बैकअप में शामिल नहीं की गई। इस फ़ोन पर कोच इस्तेमाल करने के लिए इसे AI सेटिंग में फिर से दर्ज करें।", "welcomeSettingsSkipped": "{n, plural, =1{बैकअप की 1 सेटिंग पढ़ी नहीं जा सकी और छोड़ दी गई} other{बैकअप की {n} सेटिंग्स पढ़ी नहीं जा सकीं और छोड़ दी गईं}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e8c327518..4a1246c13 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7991,6 +7991,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "Экспериментальный исследовательский алгоритм. Это не медицинское устройство. При нарушениях сна обратитесь к врачу.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "welcomeCoachKeyReentry": "API-ключ тренера остаётся в защищённом хранилище и не включён в резервную копию. Введите его снова в настройках ИИ, чтобы пользоваться тренером на этом телефоне.", "welcomeSettingsSkipped": "{n, plural, one{{n} настройку из резервной копии не удалось прочитать, она пропущена} few{{n} настройки из резервной копии не удалось прочитать, они пропущены} many{{n} настроек из резервной копии не удалось прочитать, они пропущены} other{{n} настройки из резервной копии не удалось прочитать, они пропущены}}", "@welcomeSettingsSkipped": { diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 98cdfc54f..a27d18be8 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16391,6 +16391,7 @@ "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้", + "sleepDetailMedicalDisclaimer": "อัลกอริทึมวิจัยเชิงทดลอง ไม่ใช่อุปกรณ์ทางการแพทย์ หากมีปัญหาการนอนหลับ โปรดปรึกษาแพทย์", "napAutoLogTitle": "บันทึกการงีบที่ชัดเจนโดยอัตโนมัติ", "napAutoLogSub": "การงีบที่ตรวจพบและมีหลักฐานชัดเจนจะถูกเพิ่มโดยไม่ถาม คุณยังแก้ไขหรือลบได้", "napAutoLogSaveFailed": "บันทึกการตั้งค่าไม่สำเร็จ ลองอีกครั้ง", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 9490b7bfe..2568be9c3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2898,6 +2898,10 @@ } } }, + "sleepDetailMedicalDisclaimer": "实验性研究算法。非医疗设备。如有睡眠障碍,请咨询医生。", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." + }, "welcomeCoachKeyReentry": "教练的 API 密钥保留在安全存储中,未包含在备份内。请在 AI 设置中重新输入,以便在此手机上使用教练。", "welcomeSettingsSkipped": "{n, plural, other{备份中有 {n} 项设置无法读取,已跳过}}", "@welcomeSettingsSkipped": { diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index ac698fb49..927901646 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3136,6 +3136,7 @@ class AppState extends ChangeNotifier { if (firedSec != null) { _alarmFiredAt = DateTime.fromMillisecondsSinceEpoch(firedSec * 1000); } + _smartWakeFiredForEpoch = alarmPrefs.getInt('smart_wake_fired_epoch'); // Seed the confirmation machine from what the last session (foreground OR // headless — background_sync.dart writes the same two keys) actually // learned, so a relaunch doesn't forget a confirmed headless arm and @@ -5060,11 +5061,100 @@ class AppState extends ChangeNotifier { /// The armed epoch (unix sec) a Smart Wake Window early-fire already ran /// for, so a re-arm of the SAME occurrence on every 30 s tick does not buzz /// the band again on every tick once light sleep is first seen. + /// Persisted as `smart_wake_fired_epoch` (loaded in [_init], cleared in + /// [_clearArmedAlarmState]) so a process relaunch mid-window — iOS kills + /// the app between BG tasks — cannot re-buzz an occurrence that already + /// fired. int? _smartWakeFiredForEpoch; + /// The journal-derived confounder profile for the current wake window, + /// computed once when the window opens and cached for its duration. + /// Re-reading the journal on every 30 s tick would be wasteful — the + /// user logs the previous day, which does not change mid-window. + /// + /// SAFETY/ETHICS: this profile is internal only. It is never surfaced to + /// the user (no UI, no notification naming the confounder), per the + /// guardrail in lib/ui2/screens/rough_night.dart: "a wrong 'looks like a + /// drinking night' is an accusation on a phone somebody else can see." + ConfounderProfile? _smartWakeProfile; + + /// The day label the cached [_smartWakeProfile] was computed for, so a + /// new calendar day (DST change, midnight rollover mid-window) triggers + /// a re-read instead of serving a stale profile. + String? _smartWakeProfileDay; + + /// Median HR over [samples] — the tertiary baseline fallback (shipped + /// v1 behavior). Defined here rather than exported from smart_wake.dart + /// because it operates on the same SmartWakeSample list but is only + /// needed on this fallback path. + /// Build (or return the cached) confounder profile for [now]'s wake + /// window. Reads the journal for the calendar day BEFORE the window — + /// alcohol, caffeine, stress and workout are consumed during the day + /// and affect the night that follows. + /// + /// On any failure (DB error, missing tables) this returns the identity + /// profile — no adjustment, never a penalty. A failed journal read must + /// not disable smart wake; it only means the detector runs with default + /// thresholds, exactly as v1 did. + Future _smartWakeConfounderProfile( + DateTime windowEnd) async { + // The local calendar day before the window's own wake day, by calendar + // arithmetic: subtracting 24 elapsed hours lands on the wrong date around + // a DST change, and a window that crosses midnight belongs to the day it + // ends on, not the day the tick happens to fall on. + final day = dayLabelOf( + DateTime(windowEnd.year, windowEnd.month, windowEnd.day - 1)); + if (_smartWakeProfile != null && _smartWakeProfileDay == day) { + return _smartWakeProfile!; + } + try { + final journalRows = await LocalDb.journalRows(sinceDaysEpoch: day); + final tags = {}; + for (final r in journalRows) { + if (r['date'] == day) { + final raw = r['tags_json']; + if (raw is String) { + // tags_json is a JSON-encoded list of strings. + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + for (final t in decoded) { + if (t is String && t.isNotEmpty) tags.add(t); + } + } + } catch (_) { + // Malformed tags_json — skip, never crash the check. + } + } + } + } + final metrics = await LocalDb.journalMetricsForDay(day); + final profile = confounderProfile(tags: tags, metrics: metrics); + _smartWakeProfile = profile; + _smartWakeProfileDay = day; + // Nothing derived from the journal goes in the log: thresholds would + // let a reader of a shared report work the tags back out. + _log('[smart-wake] journal profile read'); + return profile; + } catch (e) { + _log('[smart-wake] confounder profile read failed (defaults): $e'); + return ConfounderProfile.identity; + } + } + /// Smart Wake Window's periodic check, run from [BleEngine.onKeepAlive] — /// the engine's existing 30 s keep-alive tick, not a new timer. /// + /// DATA PATH for the 3h baseline: while the smart-wake plan is enabled, the + /// band is programmed (applyHighFreqWakeWindow, extended lease from + /// preSyncAt = targetWake − 135 min) to send HIGH_FREQ_SYNC_PROMPT every + /// 61 s; each prompt event triggers a one-shot historical offload + /// (EventId.highFreqSyncPrompt → _startHistoricalRefresh, floored at 90 s + /// by BackfillPolicy so the 61 s prompts coalesce with the 15-min periodic + /// backfill instead of doubling up). That backfill is what fills + /// decoded_onehz with the last ~3h of nocturnal data the baseline query + /// reads. Detection itself still only runs inside the actual window. + /// /// SAFETY: this method can only ever cause an EARLY extra buzz /// (`engine.runAlarm()`, RUN_ALARM — haptics only, it does not touch /// SET_ALARM). It never calls `setAlarm`, `disableAlarm`, or anything else @@ -5085,27 +5175,101 @@ class AppState extends ChangeNotifier { windowEnd: armed.windowEnd, minutes: armed.minutes, now: now)) { return; } + + // Q2: journal-aware confounder profile (cached per day). If the + // alcohol+stress combo is active, early buzz is disabled entirely + // for this night — the fallback SET_ALARM at window end fires as + // usual. This is an early return that never touches the fallback. + final profile = await _smartWakeConfounderProfile(armed.windowEnd); + if (profile.earlyBuzzDisabled) return; + + // The recent window is as long as the journal's trend window asks for + // (5 min on alcohol/workout nights, 3 min otherwise) so the movement + // trend has something to smooth over. `likelyLightSleep` re-slices the + // trailing 3 minutes for HR arousal and the mean-motion ceiling, so a + // longer fetch cannot move those two checks. + final recentWindowMinutes = profile.trendWindowMinutes > 3 + ? profile.trendWindowMinutes + : 3; final recentRows = await LocalDb.onehzHrAccelBetween( - now.subtract(const Duration(minutes: 3)).millisecondsSinceEpoch ~/ + now.subtract(Duration(minutes: recentWindowMinutes)) + .millisecondsSinceEpoch ~/ 1000, now.millisecondsSinceEpoch ~/ 1000, ); + + // Q2: 10th-percentile nightly HR baseline over the last ~3h (primary), + // falling back to the shipped 90-min median. + // + // The 3h window needs the preSyncAt backfill to have data when the band + // was offline earlier in the night. Without it, the query returns fewer + // rows and percentileHr returns null — the median fallback handles that. + final baselineWindowStart = now.subtract(const Duration(hours: 3)); + final baselineWindowEnd = now.subtract(const Duration(minutes: 3)); final baselineRows = await LocalDb.onehzHrAccelBetween( - now.subtract(const Duration(minutes: 93)).millisecondsSinceEpoch ~/ - 1000, - now.subtract(const Duration(minutes: 3)).millisecondsSinceEpoch ~/ - 1000, + baselineWindowStart.millisecondsSinceEpoch ~/ 1000, + baselineWindowEnd.millisecondsSinceEpoch ~/ 1000, ); + final baselineSamples = [ + for (final r in baselineRows) SmartWakeSample.fromRow(r), + ]; + + // Primary: 10th percentile of last 3h (the person's own deep-sleep + // HR floor — F6: HR is lowest in deep sleep). Relies on the preSyncAt + // backfill (band prompts every 61 s from 45 min before the window, each + // prompt triggers a historical offload — see applyHighFreqWakeWindow / + // EventId.highFreqSyncPrompt in ble_engine.dart). + double? baselineHr = percentileHr(baselineSamples, 0.10); + + // Fallback: 90-min median (shipped v1 behavior — never worse than + // before this feature). Deliberately NO stored hr_min_night read here: + // nothing writes that column (PR #628 dropped it), so reading it would + // always yield null — a dead fallback that only pretends to exist. + final recentSamples = [ + for (final r in recentRows) SmartWakeSample.fromRow(r), + ]; + if (baselineHr == null) { + // The shipped fallback is the median of the 90 minutes before the + // recent window, not of the whole 3 h the percentile just refused. + final fallbackRows = await LocalDb.onehzHrAccelBetween( + now.subtract(const Duration(minutes: 90)).millisecondsSinceEpoch ~/ + 1000, + baselineWindowEnd.millisecondsSinceEpoch ~/ 1000, + ); + baselineHr = medianValidHr([ + for (final r in fallbackRows) SmartWakeSample.fromRow(r), + ]); + } + + // If we still have no baseline, we cannot make a confident call — + // no early buzz. The fallback fires at window end regardless. + if (baselineHr == null) return; + final detected = likelyLightSleep( - baseline: [for (final r in baselineRows) SmartWakeSample.fromRow(r)], - recent: [for (final r in recentRows) SmartWakeSample.fromRow(r)], + baseline: baselineSamples, + recent: recentSamples, + profile: profile, + baselineHrOverride: baselineHr, + windowEnd: armed.windowEnd, + now: now, ); if (!detected) return; _smartWakeFiredForEpoch = epoch; // set BEFORE the write — see below // Marked fired before the write goes out on purpose: a write that // throws must not retry every 30 s for the rest of the window (that // would just be repeated buzzing), and the untouched fallback arm - // still covers a write that genuinely failed. + // still covers a write that genuinely failed. Persisted too, so a + // relaunch mid-window does not re-buzz (PR #630 review). + // The write completes BEFORE the buzz goes out, so a process that dies + // right after the band buzzes still finds the latch on relaunch. A + // failed write is logged and the buzz still goes (the in-memory latch + // holds for this process). + try { + final p = await SharedPreferences.getInstance(); + await p.setInt('smart_wake_fired_epoch', epoch); + } catch (e) { + _log('[smart-wake] persisting the fired latch failed: $e'); + } await engine.runAlarm(); _log('[smart-wake] light sleep detected inside the window — early buzz.'); } catch (e) { @@ -5461,6 +5625,7 @@ class AppState extends ChangeNotifier { throw Exception("Couldn't reach your strap, so the alarm is still set"); } _savedAlarm = null; + _smartWakeFiredForEpoch = null; final gen = ++_armGen; device.alarmEpoch = null; _alarm.disable(); @@ -5469,6 +5634,7 @@ class AppState extends ChangeNotifier { // An arm that landed during the await owns the persisted epoch now. if (_armGen == gen) await prefs.remove('alarm_epoch'); if (_armGen == gen) await prefs.remove('alarm_epoch_confirmed'); + if (_armGen == gen) await prefs.remove('smart_wake_fired_epoch'); // 'alarm_schedule_cleared' tells an import that an empty schedule means // "cancelled", not "never set". Marked BEFORE the rows go: a crash // between the two leaves a marker over rows (harmless), never an empty @@ -5576,6 +5742,9 @@ class AppState extends ChangeNotifier { /// dedupeKey still resolves after this runs. void _clearArmedAlarmState() { _savedAlarm = null; + // A re-arm of the same occurrence in this process must be able to buzz + // again; the persisted latch goes below, the in-memory one goes now. + _smartWakeFiredForEpoch = null; final gen = ++_armGen; device.alarmEpoch = null; _alarm.disable(); @@ -5588,6 +5757,10 @@ class AppState extends ChangeNotifier { await prefs.remove('alarm_epoch'); if (_armGen != gen) return; await prefs.remove('alarm_epoch_confirmed'); + // A newer arm may have latched its own early buzz while the removes + // above were in flight; its latch is not ours to clear. + if (_armGen != gen) return; + await prefs.remove('smart_wake_fired_epoch'); } catch (e) { _log('[alarm] clearing the persisted epoch failed: $e'); } @@ -5683,7 +5856,12 @@ class AppState extends ChangeNotifier { smartWake: plan.shouldEnable && target != null ? BandPromptRequest.smartWake( target: target, - lease: HighFreqWakeWindow.lease, + // Use the extended lease (preSyncAt → targetWake) so the band + // prompts from preSyncAt onward, backfilling decoded_onehz + // with the last ~3h of nocturnal data before the smart-wake + // window opens. Falls back to the shipped 90-min lease when + // preSyncAt is null (no plan / outside extended window). + lease: plan.leaseFrom(DateTime.now()) ?? HighFreqWakeWindow.lease, source: plan.source, ) : null, diff --git a/lib/state/smart_wake.dart b/lib/state/smart_wake.dart index 8319b4662..f391958cf 100644 --- a/lib/state/smart_wake.dart +++ b/lib/state/smart_wake.dart @@ -6,8 +6,39 @@ // build its own baselines and only runs offline, after the fact. This runs // live, every keep-alive tick, on whatever `decoded_onehz` already has. // -// ponytail: a single-window HR-arousal + no-big-movement heuristic. Ceiling: -// no REM/deep distinction, no wake rejection beyond the motion cap, and it +// v2 upgrade (feat/smart-alarm-light-sleep, gate approved 2026-10-25): +// - 10th-percentile nightly HR baseline (was: 90-min median) +// - Journal-aware confounder boost (alcohol, caffeine, stress, workout, +// late meal, sick) — adjusts thresholds conservatively +// - Personalized movement-trend condition (ENMO must not be rising) +// - Late-window deep-sleep protection (no buzz in last 5/10 min if HR +// still near baseline) +// +// Scientific grounding: every threshold is either data-derived or carries a +// full citation in the fact base +// (shared/smart-alarm-light-sleep/fact_base_phase2.md). Key sources: +// - F2: Waking from SWS produces more sleep inertia than from N1/N2 +// (Tassi & Muzet 2000, Sleep Med Rev, doi:10.1053/smrv.2000.0098) +// - F3: Under normal conditions, stage at awakening does NOT predict +// inertia (Jewett et al. 1999, J Sleep Res, doi:10.1111/j.1365-2869.1999.00128.x) +// → UI copy must be probabilistic ("may reduce grogginess") +// - F5: Sleep duration dominates morning alertness +// (McHill et al. 2019, Sleep, doi:10.1093/sleep/zsz032) +// - F6: HR is lower in deep sleep, higher in light/REM +// (Trinder et al. 2001, J Sleep Res, doi:10.1046/j.1365-2869.2001.00263.x; +// Cajochen et al. 1994, Physiol Behav, doi:10.1016/0031-9384(94)90058-2; +// Berlad et al. 1993, J Sleep Res, doi:10.1111/j.1365-2869.1993.tb00067.x; +// Tsai et al. 2022, Psychosomatic Medicine, doi:10.1097/PSY.0000000000001079) +// - F8: Movement is reduced in deep sleep, more in light sleep +// (Imtiaz 2021, Sensors, doi:10.3390/s21051562; +// Fonseca et al. 2017, Sleep, doi:10.1093/sleep/zsx097) +// - F9: Wearable stage detection κ = 0.21–0.53 vs PSG +// (Schyvens et al. 2025, Sleep Adv, doi:10.1093/sleepadvances/zpaf021) +// → live decision is probabilistic; no-buzz is always safe +// +// ponytail: v1 was a single-window HR-arousal + no-big-movement heuristic. +// v2 adds: nightly baseline, journal confounder boost, movement trend, +// late-window protection. Ceiling unchanged: no REM/deep distinction, and it // says nothing at all (returns false) when either window is thin. Upgrade // path: once there is a live per-epoch feed, run cardioStager's actual // classifier here instead of this heuristic. @@ -22,49 +53,415 @@ import 'dart:math' as math; +import '../data/journal_fields.dart'; + /// One second of the signal this reads: HR (bpm) + the raw accel vector /// `decoded_onehz` already stores (roughly unit-magnitude at rest, in g). class SmartWakeSample { final double hr; final double ax, ay, az; - const SmartWakeSample(this.hr, this.ax, this.ay, this.az); + + /// The row's `rec_ts` (unix seconds) when it came from the store. Windows + /// are cut by this, not by row count: a sync gap leaves fewer rows than + /// seconds, so "the last 180 rows" can reach back many minutes. + final int? tsSec; + const SmartWakeSample(this.hr, this.ax, this.ay, this.az, {this.tsSec}); factory SmartWakeSample.fromRow(Map row) => SmartWakeSample( (row['hr'] as num).toDouble(), (row['ax'] as num).toDouble(), (row['ay'] as num).toDouble(), (row['az'] as num).toDouble(), + tsSec: (row['rec_ts'] as num?)?.toInt(), ); } -/// True when the last few minutes show a textbook lightening-sleep signature -/// — heart rate elevated above tonight's own resting baseline — WITHOUT the -/// hard movement that would mean the person is already awake and moving. +// ── ConfounderProfile (Q2 — journal-aware threshold adjustments) ───────────── + +/// Effective thresholds for one night, derived from the user's journal +/// (tags + numeric metrics) for the day BEFORE the sleep window. +/// +/// Every adjustment is CONSERVATIVE — it can only make the detector LESS +/// likely to buzz early, never more. Missing journal data means no +/// adjustment (the defaults), never a penalty. +/// +/// Ethical guardrail (from lib/ui2/screens/rough_night.dart): this profile +/// is internal only. It is never surfaced to the user — no "alcohol +/// detected" message, no UI change, no notification. The user may observe +/// "smart wake didn't buzz early tonight", which is indistinguishable from +/// any other no-buzz night. +class ConfounderProfile { + /// HR arousal threshold in bpm above baseline. Default 4; alcohol or + /// late caffeine → 6; late meal or sick → 5. Stacks as MAX. + final double hrArousalBpm; + + /// Mean ENMO ceiling in g. Default 0.08; stress or sick → 0.06. + /// Stacks as MIN. + final double motionCeilingG; + + /// Late-window deep-sleep protection duration in minutes. Default 5; + /// workout day (no rest day) → 10. Stacks as MAX. + final int lateWindowProtectionMinutes; + + /// When true, the early buzz is disabled entirely for this night — + /// the fallback SET_ALARM at window end fires as usual. Triggered by + /// the alcohol + stress combination (both elevate nocturnal HR and + /// movement independently; together the live signal is too noisy for + /// a confident light-sleep call). + final bool earlyBuzzDisabled; + + /// Movement-trend window in minutes. Default 3; alcohol or workout → 5 + /// (smooths short spikes from rebound/fragmentation or post-exercise + /// muscle twitches). + final int trendWindowMinutes; + + /// Multiplier on the personalized trend threshold. Default 1.0; + /// stress or sick → 0.5 (stricter — a smaller rise already counts as a + /// trend). Never above 1.0: no journal tag relaxes the movement veto. + final double trendThresholdFactor; + + const ConfounderProfile({ + this.hrArousalBpm = 4.0, + this.motionCeilingG = 0.08, + this.lateWindowProtectionMinutes = 5, + this.earlyBuzzDisabled = false, + this.trendWindowMinutes = 3, + this.trendThresholdFactor = 1.0, + }); + + /// The identity profile: no journal data → no adjustment, never a penalty. + static const identity = ConfounderProfile(); + + @override + String toString() => 'ConfounderProfile(hr: $hrArousalBpm, ' + 'enmo: $motionCeilingG, late: ${lateWindowProtectionMinutes}min, ' + 'disabled: $earlyBuzzDisabled, trend: ${trendWindowMinutes}min ' + '×$trendThresholdFactor)'; +} + +/// Build a [ConfounderProfile] from one day's journal tags and numeric +/// metrics. Pure — no DB, no I/O, fully unit-testable. +/// +/// [tags] is the set of tag strings for the day (e.g. `{'alcohol', +/// 'stress'}`). [metrics] maps field keys to their [JournalMetricValue] +/// (e.g. `{'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 1320)}`). +/// +/// The day passed here is the calendar day BEFORE the sleep window — +/// alcohol, caffeine, stress and workout are consumed during the day and +/// affect the night that follows. +ConfounderProfile confounderProfile({ + required Set tags, + required Map metrics, +}) { + // ── Alcohol: units > 0 AND last drink at/after 20:00 local ────────────── + // Alcohol causes HR rebound in the second half of the night. A higher + // HR threshold prevents buzzing during alcohol-elevated HR that is NOT + // a stage transition. (Engineering bound — F4-adjacent; documented in + // fact base 2.6.2b.) + final alcoholUnits = metrics['alcohol_units']; + final alcoholLastMin = alcoholUnits?.atMinuteOfDay; + final alcoholActive = alcoholUnits != null && + alcoholUnits.value > 0 && + alcoholLastMin != null && + alcoholLastMin >= 20 * 60; // 20:00 + + // ── Late caffeine: ≥200mg AND last at/after 16:00 local ────────────────── + final caffeine = metrics['caffeine_mg']; + final caffeineLastMin = caffeine?.atMinuteOfDay; + final caffeineActive = caffeine != null && + caffeine.value >= 200 && + caffeineLastMin != null && + caffeineLastMin >= 16 * 60; // 16:00 + + // ── Stress: tag OR rating ≥ 4 ──────────────────────────────────────────── + final stressRating = metrics['stress']?.value; + final stressActive = tags.contains('stress') || (stressRating != null && stressRating >= 4); + + // ── Workout: tag present AND 'rest day' NOT present ────────────────────── + final workoutActive = tags.contains('workout') && !tags.contains('rest day'); + + // ── Late meal: tag ─────────────────────────────────────────────────────── + final lateMealActive = tags.contains('late meal'); + + // ── Sick: tag ──────────────────────────────────────────────────────────── + final sickActive = tags.contains('sick'); + + // ── HR arousal threshold: MAX of adjustments ───────────────────────────── + double hrThreshold = 4.0; + if (alcoholActive || caffeineActive) hrThreshold = math.max(hrThreshold, 6.0); + if (lateMealActive || sickActive) hrThreshold = math.max(hrThreshold, 5.0); + + // ── ENMO ceiling: MIN of adjustments ───────────────────────────────────── + double enmoCeiling = 0.08; + if (stressActive || sickActive) enmoCeiling = math.min(enmoCeiling, 0.06); + + // ── Late-window protection: MAX of durations ───────────────────────────── + int lateWindow = 5; + if (workoutActive) lateWindow = math.max(lateWindow, 10); + + // ── Trend window: extended for alcohol/workout (smoothing) ─────────────── + int trendWindow = 3; + if (alcoholActive || workoutActive) trendWindow = 5; + + // ── Trend threshold factor ─────────────────────────────────────────────── + double trendFactor = 1.0; + if (stressActive || sickActive) trendFactor *= 0.5; // stricter + // A late meal never loosens the movement veto: a rising trend means the + // person is stirring, and an early buzz that lands on that is worse than a + // missed one (the fixed alarm still fires at the window end). + + // ── Alcohol + Stress combo: disable early buzz entirely ────────────────── + // Both elevate nocturnal HR and movement independently; together the + // live signal is too noisy for a confident light-sleep call. The honest + // outcome is: don't buzz early (F9). Fallback fires at window end. + final comboDisabled = alcoholActive && stressActive; + + return ConfounderProfile( + hrArousalBpm: hrThreshold, + motionCeilingG: enmoCeiling, + lateWindowProtectionMinutes: lateWindow, + earlyBuzzDisabled: comboDisabled, + trendWindowMinutes: trendWindow, + trendThresholdFactor: trendFactor, + ); +} + +/// The samples of the trailing [seconds], cut by time when the rows carry a +/// timestamp (relative to [now] when given, else to the newest row) and by +/// count, at 1 Hz, when they do not. A window with a hole in it comes back +/// short, which every caller already treats as "not enough to judge". +List trailingSamples( + List samples, + int seconds, { + DateTime? now, +}) { + if (samples.isEmpty) return samples; + if (samples.every((s) => s.tsSec != null)) { + final ref = now != null + ? now.millisecondsSinceEpoch ~/ 1000 + : samples.map((s) => s.tsSec!).reduce(math.max); + final cutoff = ref - seconds; + return [for (final s in samples) if (s.tsSec! > cutoff) s]; + } + return samples.length > seconds + ? samples.sublist(samples.length - seconds) + : samples; +} + +// ── enmoTrendExceeded (Q4 — personalized movement trend) ──────────────────── + +/// True when ENMO is INCREASING over the recent window — the person is +/// turning over, stirring, or beginning to wake. A rising movement trend +/// means "transitioning", not "stable light sleep", so the caller should +/// NOT buzz. +/// +/// The check compares mean ENMO of the FIRST HALF vs the SECOND HALF of +/// [samples]. [threshold] is the personalized minimum rise that counts as +/// a trend (computed by the caller as +/// `max(0.02, medianENMO_last3h × 0.5) × profile.trendThresholdFactor`). +/// +/// [windowMinutes] is the trailing window the trend is judged on, and it is +/// genuinely applied: samples are 1 Hz, so the last `windowMinutes * 60` of +/// them are used. Alcohol/workout nights pass 5 (smoothing short rebound or +/// post-exercise spikes that a 3-minute window would read as a trend). A list +/// already shorter than the requested window is used whole — never padded. +/// +/// Returns false (never guesses) when the window is too short. +bool enmoTrendExceeded( + List samples, { + required double threshold, + required int windowMinutes, +}) { + if (samples.isEmpty) return false; + // 1 Hz samples: trim to the trailing window. Trimming HERE rather than in + // the caller is what makes the alcohol/workout 5-minute smoothing real — + // the caller hands over everything it fetched, and this decides how much + // of the tail the trend is judged on. + final window = windowMinutes <= 0 + ? samples + : trailingSamples(samples, windowMinutes * 60); + // Minimum 30 samples (~30 s at 1 Hz) — same floor as the HR check. + if (window.length < 30) return false; + final enmos = [for (final s in window) enmo(s)]; + final half = enmos.length ~/ 2; + final firstHalf = _mean(enmos.sublist(0, half)); + final secondHalf = _mean(enmos.sublist(half)); + return (secondHalf - firstHalf) > threshold; +} + +// ── lateWindowProtectionActive (Q3 — deep-sleep protection near window end) ── + +/// True when the late-window deep-sleep protection is active: we are within +/// [protectionMinutes] of [windowEnd] AND the current HR is still within +/// 2 bpm of [baselineHr] (i.e., still clearly in deep sleep). +/// +/// When active, the caller must NOT buzz early — the fallback SET_ALARM at +/// [windowEnd] fires regardless (Q3: "der letzte Wecker muss funktionieren"). +/// +/// Rationale: F2 (deep sleep = worst inertia; Tassi & Muzet 2000) + F5 +/// (sleep duration dominates morning alertness; McHill 2019). Losing the +/// last few minutes of deep sleep for a marginal "light sleep" call is +/// net-negative. +/// +/// Pure — no I/O, fully unit-testable. +bool lateWindowProtectionActive({ + required DateTime windowEnd, + required DateTime now, + required int protectionMinutes, + required double currentHr, + required double baselineHr, +}) { + if (protectionMinutes <= 0) return false; + final msBeforeEnd = windowEnd.difference(now).inMilliseconds; + if (msBeforeEnd <= 0) return false; // at/past window end — window check handles + if (msBeforeEnd > protectionMinutes * 60 * 1000) return false; // not yet in zone + // Within the protection zone: active only if HR is still near baseline + // (within 2 bpm — still clearly deep sleep). + return (currentHr - baselineHr) <= 2.0; +} + +// ── percentileHr (10th-percentile nightly HR baseline) ─────────────────────── + +/// The [p]-quantile (0 samples, double p) { + final valid = [ + for (final s in samples) + if (s.hr > 30.0 && s.hr < 200.0) s.hr, + ]; + // A percentile from a handful of values is one artifact away from a wrong + // nightly floor — require a minimum count of VALID HR values (not rows) so a + // single corrupted low reading cannot set the baseline (CodeRabbit, #630). + if (valid.length < 30) return null; + valid.sort(); + final idx = (valid.length * p).floor().clamp(0, valid.length - 1); + return valid[idx]; +} + +/// The median of the valid HR readings in [samples], or null unless at least +/// 30 valid ones (30 < hr < 200; the off-skin sentinel 0 is not one) are +/// there. The same floor [percentileHr] holds, so rows that function refused +/// cannot come back through the fallback as a median of zeros. +double? medianValidHr(List samples) { + final hrs = [ + for (final s in samples) + if (s.hr > 30.0 && s.hr < 200.0) s.hr, + ]; + if (hrs.length < 30) return null; + return _median(hrs); +} + +// ── likelyLightSleep v2 (Q1+Q2+Q3+Q4 integrated) ───────────────────────────── + +/// True when the last few minutes show a lightening-sleep signature — +/// heart rate elevated above tonight's own resting baseline — WITHOUT the +/// hard movement that would mean the person is already awake and moving, +/// WITHOUT a rising movement trend (turning over / arousal), and NOT in +/// the late-window deep-sleep protection zone. +/// +/// v2 upgrades over v1 (all gate-approved 2026-10-25): +/// - [profile] adjusts thresholds from the journal (Q2) +/// - Movement-trend check (Q4): rising ENMO → false +/// - [windowEnd]/[now]/[baselineHr] enable late-window protection (Q3) /// /// Never guesses on a thin window: returns false whenever [baseline] or /// [recent] is shorter than its minimum sample count, which is the same as /// "no light sleep detected" — the caller's fallback path (fire at window end /// regardless) is what actually protects the user, not this function /// returning true. +/// +/// Backward compatible: with the default [ConfounderProfile.identity] and +/// no [windowEnd], the behavior is the shipped v1 heuristic plus the +/// movement-trend check (which v1 lacked). bool likelyLightSleep({ required List baseline, required List recent, - double hrArousalBpm = 4, - double motionCeilingG = 0.08, + ConfounderProfile profile = ConfounderProfile.identity, + double? baselineHrOverride, + DateTime? windowEnd, + DateTime? now, + double? baselineHr, int minBaselineSamples = 30, int minRecentSamples = 30, }) { - if (baseline.length < minBaselineSamples || - recent.length < minRecentSamples) { + // Alcohol + stress combo: early buzz disabled entirely for this night. + if (profile.earlyBuzzDisabled) return false; + + // The caller may pass a baseline computed from a wider window via + // [baselineHrOverride]; the in-pipeline median still needs the full list. + if (baselineHrOverride == null && baseline.length < minBaselineSamples) { + return false; + } + + // `recent` may be LONGER than 3 minutes: the caller fetches up to the + // journal's trend window (5 min on alcohol/workout nights) so the trend + // check below has something to smooth over. HR arousal and the mean-motion + // ceiling are separate questions and are always judged on the trailing + // 3 minutes, exactly as they were before the trend window existed — a + // longer fetch must not quietly move them. + final hrWindow = trailingSamples(recent, 180, now: now); + if (hrWindow.length < minRecentSamples) return false; + + // Baseline HR: caller-supplied (10th percentile from a wider window) or + // computed here as median (v1 behavior). + final baseHr = baselineHrOverride ?? _median([for (final s in baseline) s.hr]); + final recentHr = _mean([for (final s in hrWindow) s.hr]); + final recentMotion = _mean([for (final s in hrWindow) enmo(s)]); + + // Q3: late-window deep-sleep protection — if we're in the last few minutes + // AND HR is still near baseline, don't buzz. Fallback fires at window end. + // The veto compares against the NIGHTLY baseline (baseHr), not the + // caller-passed [baselineHr] — with the live caller those used to be the same + // value, which made protection unable to veto a positive detection. + if (windowEnd != null && now != null) { + if (lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: profile.lateWindowProtectionMinutes, + currentHr: recentHr, + baselineHr: baseHr, + )) { + return false; + } + } + + // Q1+Q2: HR arousal with dynamic threshold. + if (recentHr < baseHr + profile.hrArousalBpm) return false; + + // Q1+Q2: low movement with dynamic ceiling. + if (recentMotion >= profile.motionCeilingG) return false; + + // Q4: personalized movement trend — rising ENMO = turning over / arousal. + // With a stored nightly baseline and no live baseline rows there is no + // 3h ENMO median; fall back to the fixed floor so the check still runs. + final enmoMedian3h = baseline.isNotEmpty + ? _median([for (final s in baseline) enmo(s)]) + : 0.0; + final trendThreshold = math.max(0.02, enmoMedian3h * 0.5) * + profile.trendThresholdFactor; + if (enmoTrendExceeded(recent, + threshold: trendThreshold, + windowMinutes: profile.trendWindowMinutes)) { return false; } - final baseHr = _median([for (final s in baseline) s.hr]); - final recentHr = _mean([for (final s in recent) s.hr]); - final recentMotion = _mean([for (final s in recent) _enmo(s)]); - return recentHr >= baseHr + hrArousalBpm && recentMotion < motionCeilingG; + + return true; } -double _enmo(SmartWakeSample s) => +// ── helpers ────────────────────────────────────────────────────────────────── + +/// ENMO — Euclidean Norm Minus One, in g. The magnitude of deviation from +/// 1g gravity: ~0 when still, rising with movement. +double enmo(SmartWakeSample s) => (math.sqrt(s.ax * s.ax + s.ay * s.ay + s.az * s.az) - 1.0).abs(); double _mean(List xs) => xs.reduce((a, b) => a + b) / xs.length; diff --git a/lib/sync/background_sync.dart b/lib/sync/background_sync.dart index a076eb3ff..b58f82388 100644 --- a/lib/sync/background_sync.dart +++ b/lib/sync/background_sync.dart @@ -256,7 +256,9 @@ Future prepareHeadlessLink(BleEngine engine, PairedDevice paired) async { await engine.applyHighFreqWakeWindow( enabled: plan.shouldEnable, targetWake: plan.targetWake, - duration: HighFreqWakeWindow.lease, + // Extended lease (preSyncAt → targetWake) so the pre-window backfill has + // data when the smart-wake window opens; falls back to the shipped 90 min. + duration: plan.leaseFrom(DateTime.now()) ?? HighFreqWakeWindow.lease, intervalSeconds: 61, // gen5 rejects <= 60 reason: plan.source, diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart index 81a316a81..5d7d92d9b 100644 --- a/lib/sync/high_freq_wake_window.dart +++ b/lib/sync/high_freq_wake_window.dart @@ -8,16 +8,74 @@ class HighFreqWakePlan { final String source; final int sampleCount; + /// Time at which the band prompt lease should start — [preSyncLead] before + /// the smart-wake window opens — so the app wakes periodically and backfills + /// `decoded_onehz` with the last ~3h of nocturnal data before the window + /// opens. The smart-wake DETECTION still only runs inside the actual window + /// (checked by `inSmartWakeWindow` in `_checkSmartWake`); this only extends + /// the DATA COLLECTION lease, not the buzz window. + /// + /// Set whenever the plan has a target (habitual or scheduled), including + /// before it opens and after it closed, so a caller can say when the lease + /// will start. Null only when there is no plan at all (too little sleep + /// history); [shouldEnable] says whether the lease applies right now. + final DateTime? preSyncAt; + + /// The total duration the band prompt lease should cover: from [preSyncAt] + /// (when set) or the shipped window start, until [targetWake]. Used by + /// callers to compute the lease duration for `BandPromptRequest.smartWake`. + /// + /// [HighFreqWakeWindow.lease] is qualified on purpose: this getter lives on + /// [HighFreqWakePlan], which does NOT inherit it — an unqualified `lease` + /// here does not resolve (it is a static on the other class). + Duration? get effectiveLease { + if (targetWake == null) return null; + final start = preSyncAt ?? + targetWake!.subtract(HighFreqWakeWindow.lease); + return targetWake!.difference(start); + } + + /// The lease to hand the band when it is programmed at [now]: what is left + /// until [targetWake], never more than [effectiveLease]. Programming the + /// full [effectiveLease] on a late connect or refresh (after [preSyncAt]) + /// would keep the band prompting past the wake time. + Duration? leaseFrom(DateTime now) { + final full = effectiveLease; + final target = targetWake; + if (full == null || target == null) return null; + final remaining = target.difference(now); + if (remaining.isNegative) return Duration.zero; + return remaining < full ? remaining : full; + } + const HighFreqWakePlan({ required this.shouldEnable, required this.targetWake, required this.source, required this.sampleCount, + this.preSyncAt, }); } class HighFreqWakeWindow { static const Duration lease = Duration(minutes: 90); + + /// Extra time the band prompt lease extends BEFORE the smart-wake window + /// opens, so the app wakes periodically and backfills `decoded_onehz` with + /// the last ~3h of nocturnal data. Without this, the 10th-percentile HR + /// baseline (smart_wake v2, Q2) has no data to compute from when the window + /// opens — the band was offline or the app was suspended all night. + /// + /// The band prompt is the ONLY reliable cross-platform wake mechanism at a + /// specific wall-clock time (iOS BGTask/BGAppRefresh are opportunistic and + /// never guaranteed; Android has no periodic background scheduler since the + /// WorkManager tombstone). Extending the existing 61s prompt lease 45 min + /// earlier costs ~45 extra BLE wake cycles per night — negligible battery + /// impact, and it reuses infrastructure that already works. + /// + /// preSyncAt = targetWake − (lease + preSyncLead). + static const Duration preSyncLead = Duration(minutes: 45); + static const int historyDays = 14; static const int minSamples = 3; @@ -77,12 +135,21 @@ class HighFreqWakeWindow { habitualWakeMinute % 60, ) : todayTarget; - final windowStart = targetWake.subtract(lease); + // Pre-sync lead: extend the band prompt lease 45 min before the window + // opens so the app wakes periodically and backfills decoded_onehz with + // the last ~3h of nocturnal data. The smart-wake DETECTION still only + // runs inside the actual window (inSmartWakeWindow check in + // _checkSmartWake); this only extends the DATA COLLECTION lease. + final preSyncAt = targetWake.subtract(lease + preSyncLead); + // shouldEnable covers the FULL extended lease (preSyncAt → targetWake), + // not just the smart-wake window. The band prompts continuously; each + // prompt triggers _startHistoricalRefresh() which fills decoded_onehz. habitualPlan = HighFreqWakePlan( - shouldEnable: !now.isBefore(windowStart) && now.isBefore(targetWake), + shouldEnable: !now.isBefore(preSyncAt) && now.isBefore(targetWake), targetWake: targetWake, source: 'habitual_wake', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } @@ -92,13 +159,18 @@ class HighFreqWakeWindow { if (scheduledWindowEnd != null && scheduledWindowMinutes > 0 && habitualPlan?.shouldEnable != true) { - final scheduledStart = scheduledWindowEnd.subtract(lease); - if (!now.isBefore(scheduledStart) && now.isBefore(scheduledWindowEnd)) { + // The armed window's own length, not the 90-minute default: a longer + // window would otherwise open with less than [preSyncLead] of backfill, + // a shorter one would start prompting too early. + final preSyncAt = scheduledWindowEnd + .subtract(Duration(minutes: scheduledWindowMinutes) + preSyncLead); + if (!now.isBefore(preSyncAt) && now.isBefore(scheduledWindowEnd)) { return HighFreqWakePlan( shouldEnable: true, targetWake: scheduledWindowEnd, source: 'scheduled_alarm', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 2eadcbb8f..95be310b2 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -699,11 +699,28 @@ class _SleepDetailState extends State with RevisionReload { Widget build(BuildContext c) { final d = _d ?? const SleepData(); final l = AppLocalizations.of(c); + final p = P.of(c); final title = l?.sleepDetailNavTitle ?? 'Sleep'; + // Shown in every state of this screen (loading, failed, no night, loaded), + // not only once data arrives. It sits at the top of the content, so it + // scrolls with it. + // Theme tokens (wash/on + caption) for contrast — raw amber-on-white + // measured ~1.7-2.5:1. + final disclaimer = Container( + padding: const EdgeInsets.all(8), + color: p.wash(C.orange), + child: Text( + l?.sleepDetailMedicalDisclaimer ?? + 'Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.', + style: F.cap.copyWith(color: p.on(C.orange)), + textAlign: TextAlign.center, + ), + ); if (_loading && _d == null) { - return _frame(c, title, const [ - SizedBox(height: S.x8), + return _frame(c, title, [ + disclaimer, + const SizedBox(height: S.x8), Center(child: CircularProgressIndicator()), ]); } @@ -716,6 +733,7 @@ class _SleepDetailState extends State with RevisionReload { final nav = _d ?? _lastGood ?? d; final shown = _day ?? nav.latestDay ?? nav.day; return _frame(c, title, [ + disclaimer, ...dayNavRow(shown, nav.days, _goDay), const SizedBox(height: S.x2), StatusCard( @@ -746,6 +764,7 @@ class _SleepDetailState extends State with RevisionReload { final rejected = (d.night['sleep_source'] as String?) == 'rejected' && rejectedDay != null; return _frame(c, title, [ + disclaimer, ...dayNavRow(_day ?? d.day, d.days, _goDay), const SizedBox(height: S.x2), // A day CAN be in `availableDays` and still hold no night — the band @@ -832,7 +851,6 @@ class _SleepDetailState extends State with RevisionReload { ]); } - final p = P.of(c); final n = d.night; final unusual = _unusual(c, p, d, n); @@ -842,6 +860,7 @@ class _SleepDetailState extends State with RevisionReload { // would rather than printing the raw yyyy-MM-dd key. return _frame(c, title, sub: d.days.length < 2 ? dayNavLabel(d.day, l).toUpperCase() : '', [ + disclaimer, ...dayNavRow(_day ?? d.day, d.days, _goDay), // ── 1 · THE ANSWER ── diff --git a/test/backup/settings_audit_guard_test.dart b/test/backup/settings_audit_guard_test.dart index 387b39dbb..f319a9483 100644 --- a/test/backup/settings_audit_guard_test.dart +++ b/test/backup/settings_audit_guard_test.dart @@ -71,6 +71,7 @@ const _mustStay = { 'backup.folder', 'backup.last_run_ms', 'backup.last_error', 'health_upload_last_ts', 'alarm_schedule_cleared', 'alarm_epoch', 'alarm_epoch_confirmed', 'alarm_set_at_ms', 'alarm_pending_until', 'alarm_latch_alert', 'alarm_probe_pending', 'alarm_fired_at', + 'smart_wake_fired_epoch', 'live.session_draft', 'live_session_checkpoint', 'telemetry_outbox', // Permission and grant are per install. 'health_weight_perm_asked', 'health_weight_grant_seen', diff --git a/test/graphs_scroll_first_scrub_test.dart b/test/graphs_scroll_first_scrub_test.dart index d9b298ff9..8fe6b3f89 100644 --- a/test/graphs_scroll_first_scrub_test.dart +++ b/test/graphs_scroll_first_scrub_test.dart @@ -111,6 +111,10 @@ Finder get _verticalScroll => find.byWidgetPredicate( Future _mount(WidgetTester t, _Rig r, double scale) async { await r.mount(t, scale); final plot = r.plot(); + if (plot.evaluate().isEmpty) { + await t.scrollUntilVisible(plot, 120); + await t.pumpAndSettle(); + } expect(plot, findsOneWidget, reason: '${r.name}: the chart is on screen'); await t.ensureVisible(plot); await t.pumpAndSettle(); diff --git a/test/high_freq_wake_window_presync_test.dart b/test/high_freq_wake_window_presync_test.dart new file mode 100644 index 000000000..7f3cf8cf4 --- /dev/null +++ b/test/high_freq_wake_window_presync_test.dart @@ -0,0 +1,212 @@ +// Tests for the preSyncAt lease extension (Option C — wired preSyncAt). +// +// The band prompt lease now starts at preSyncAt (= targetWake − 135min) +// instead of windowStart (= targetWake − 90min), so the app wakes every 61s +// from preSyncAt onward and backfills decoded_onehz with the last ~3h of +// nocturnal data before the smart-wake window opens. +// +// These tests verify: +// - preSyncAt is computed correctly (targetWake − 135min) +// - shouldEnable covers the full extended lease (preSyncAt → targetWake) +// - effectiveLease returns the full duration (135min) +// - The smart-wake DETECTION window is unchanged (still only inside +// the actual window, checked by inSmartWakeWindow in _checkSmartWake) +// - Scheduled alarm path also gets the extended lease +// - No plan / insufficient history → shouldEnable false, preSyncAt null + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/sync/high_freq_wake_window.dart'; + +/// Build a day_result row with a wake time at [wakeHour]:[wakeMinute]. +Map dayRow(int wakeHour, int wakeMinute) { + final offsetMs = DateTime(2026, 10, 25, wakeHour, wakeMinute) + .millisecondsSinceEpoch; + return { + 'window_json': '{"value":{"offset_ms":$offsetMs}}', + 'payload_json': '{"sleep":{"window":{"value":{"offset_ms":$offsetMs}}}}', + }; +} + +void main() { + group('HighFreqWakeWindow preSyncAt lease extension (Option C)', () { + // 07:30 habitual wake → preSyncAt = 07:30 − 135min = 05:15 + final targetWake = DateTime(2026, 10, 26, 7, 30); + final preSyncAt = DateTime(2026, 10, 26, 5, 15); + final rows = [for (var i = 0; i < 5; i++) dayRow(7, 30)]; + + test('preSyncAt = targetWake − (lease + preSyncLead) = 05:15', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 3, 0), // well before preSyncAt + ); + expect(plan.targetWake, targetWake); + expect(plan.preSyncAt, preSyncAt); + expect(plan.shouldEnable, isFalse); // 03:00 is before preSyncAt + }); + + test('shouldEnable becomes true at preSyncAt (05:15), not windowStart', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 5, 15), // exactly at preSyncAt + ); + expect(plan.shouldEnable, isTrue); + expect(plan.preSyncAt, preSyncAt); + }); + + test('shouldEnable is true between preSyncAt and windowStart (05:15–06:00)', () { + // This is the NEW behavior: the band prompt is active during the + // pre-sync lead, backfilling data. The smart-wake DETECTION still + // only runs inside the actual window (06:00–06:30), checked by + // inSmartWakeWindow in _checkSmartWake. + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 5, 45), // 30 min into the pre-sync lead + ); + expect(plan.shouldEnable, isTrue); + }); + + test('shouldEnable is true inside the actual window (06:00–06:30)', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 6, 15), + ); + expect(plan.shouldEnable, isTrue); + }); + + test('shouldEnable is false at/after targetWake', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 7, 30), // exactly at targetWake + ); + expect(plan.shouldEnable, isFalse); + }); + + test('shouldEnable is false before preSyncAt (04:00)', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 4, 0), + ); + expect(plan.shouldEnable, isFalse); + }); + + test('effectiveLease = targetWake − preSyncAt = 135 min', () { + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 3, 0), + ); + expect(plan.effectiveLease, const Duration(minutes: 135)); + }); + + test('effectiveLease falls back to 90 min when preSyncAt is null', () { + // No plan (insufficient history) → effectiveLease is null, caller + // falls back to HighFreqWakeWindow.lease (90 min). + const plan = HighFreqWakePlan( + shouldEnable: false, + targetWake: null, + source: 'insufficient_sleep_history', + sampleCount: 0, + ); + expect(plan.effectiveLease, isNull); + }); + + test('scheduled alarm path also gets preSyncAt and extended lease', () { + // 05:30 alarm with a 30-min smart window → windowEnd = 05:30. + // preSyncAt = windowEnd − (the window's OWN 30 min + 45 min lead) = + // 04:15, not the 90-minute default's 03:15. + final scheduledEnd = DateTime(2026, 10, 26, 5, 30); + final plan = HighFreqWakeWindow.planFromRows( + [], // no habitual history — scheduled path takes over + DateTime(2026, 10, 26, 4, 15), // exactly at preSyncAt + scheduledWindowEnd: scheduledEnd, + scheduledWindowMinutes: 30, + ); + expect(plan.shouldEnable, isTrue); + expect(plan.source, 'scheduled_alarm'); + expect(plan.targetWake, scheduledEnd); + expect(plan.preSyncAt, DateTime(2026, 10, 26, 4, 15)); + expect(plan.effectiveLease, const Duration(minutes: 75)); + }); + + test('a longer armed window starts pre-sync earlier, a shorter one later', + () { + final end = DateTime(2026, 10, 26, 7, 0); + HighFreqWakePlan plan(int minutes) => HighFreqWakeWindow.planFromRows( + [], + end.subtract(const Duration(minutes: 1)), + scheduledWindowEnd: end, + scheduledWindowMinutes: minutes, + ); + expect(plan(120).preSyncAt, DateTime(2026, 10, 26, 4, 15)); + expect(plan(45).preSyncAt, DateTime(2026, 10, 26, 5, 30)); + }); + + test('leaseFrom never runs past the wake time', () { + final end = DateTime(2026, 10, 26, 7, 0); + final plan = HighFreqWakeWindow.planFromRows( + [], + DateTime(2026, 10, 26, 6, 0), + scheduledWindowEnd: end, + scheduledWindowMinutes: 90, + ); + // At preSyncAt (04:45) the whole 135 min is still ahead. + expect(plan.leaseFrom(DateTime(2026, 10, 26, 4, 45)), + const Duration(minutes: 135)); + // A late connect at 06:30 only has 30 min left, not 135. + expect(plan.leaseFrom(DateTime(2026, 10, 26, 6, 30)), + const Duration(minutes: 30)); + expect(plan.leaseFrom(end), Duration.zero); + expect(plan.leaseFrom(end.add(const Duration(minutes: 5))), + Duration.zero); + }); + + test('scheduled alarm path: shouldEnable false before preSyncAt', () { + final plan = HighFreqWakeWindow.planFromRows( + [], + DateTime(2026, 10, 26, 4, 0), // 15 min before preSyncAt + scheduledWindowEnd: DateTime(2026, 10, 26, 5, 30), + scheduledWindowMinutes: 30, + ); + expect(plan.shouldEnable, isFalse); + }); + + test('habitual plan takes priority over scheduled when both cover now', () { + // If habitual shouldEnable is true, scheduled is not consulted. + final plan = HighFreqWakeWindow.planFromRows( + rows, + DateTime(2026, 10, 26, 6, 15), // inside habitual window + scheduledWindowEnd: DateTime(2026, 10, 26, 5, 30), + scheduledWindowMinutes: 30, + ); + expect(plan.source, 'habitual_wake'); + }); + + test('insufficient history → no plan, preSyncAt null', () { + final plan = HighFreqWakeWindow.planFromRows( + [dayRow(7, 30)], // only 1 sample, minSamples = 3 + DateTime(2026, 10, 26, 6, 0), + ); + expect(plan.shouldEnable, isFalse); + expect(plan.preSyncAt, isNull); + expect(plan.effectiveLease, isNull); + expect(plan.source, 'insufficient_sleep_history'); + }); + }); + + group('Constants', () { + test('lease = 90 min (unchanged)', () { + expect(HighFreqWakeWindow.lease, const Duration(minutes: 90)); + }); + + test('preSyncLead = 45 min', () { + expect(HighFreqWakeWindow.preSyncLead, const Duration(minutes: 45)); + }); + + test('lease + preSyncLead = 135 min (full extended lease)', () { + expect( + HighFreqWakeWindow.lease + HighFreqWakeWindow.preSyncLead, + const Duration(minutes: 135), + ); + }); + }); +} diff --git a/test/high_freq_wake_window_test.dart b/test/high_freq_wake_window_test.dart index c66016857..1319027c5 100644 --- a/test/high_freq_wake_window_test.dart +++ b/test/high_freq_wake_window_test.dart @@ -19,18 +19,39 @@ void main() { expect(plan.targetWake, DateTime(2026, 6, 29, 7, 30)); }); - test('disables outside the wake window', () { + test('disables outside the extended pre-sync lease (before preSyncAt)', () { final rows = [ rowForWake(DateTime(2026, 6, 28, 7, 30)), rowForWake(DateTime(2026, 6, 27, 7, 29)), rowForWake(DateTime(2026, 6, 26, 7, 31)), ]; + // 04:00 is before preSyncAt (05:15 = 07:30 − 135min) → disabled. final now = DateTime(2026, 6, 29, 4, 0); final plan = HighFreqWakeWindow.planFromRows(rows, now); expect(plan.shouldEnable, isFalse); expect(plan.targetWake, DateTime(2026, 6, 29, 7, 30)); }); + test('enables during the pre-sync lead (between preSyncAt and window start)', + () { + // 05:45 is after preSyncAt (05:15) but before the 90-min window start + // (06:00). The band prompt lease is active (data backfill), but the + // smart-wake DETECTION still only runs inside the actual window. + final rows = [ + rowForWake(DateTime(2026, 6, 28, 7, 30)), + rowForWake(DateTime(2026, 6, 27, 7, 29)), + rowForWake(DateTime(2026, 6, 26, 7, 31)), + ]; + final now = DateTime(2026, 6, 29, 5, 45); + final plan = HighFreqWakeWindow.planFromRows(rows, now); + expect(plan.shouldEnable, isTrue); + expect(plan.source, 'habitual_wake'); + expect(plan.targetWake, DateTime(2026, 6, 29, 7, 30)); + // preSyncAt is set and effectiveLease covers the full 135 min. + expect(plan.preSyncAt, DateTime(2026, 6, 29, 5, 15)); + expect(plan.effectiveLease, const Duration(minutes: 135)); + }); + test('requires enough sleep history', () { final rows = [ rowForWake(DateTime(2026, 6, 28, 7, 30)), diff --git a/test/smart_wake_v2_test.dart b/test/smart_wake_v2_test.dart new file mode 100644 index 000000000..2ae1cd9ae --- /dev/null +++ b/test/smart_wake_v2_test.dart @@ -0,0 +1,678 @@ +// Tests for the upgraded smart-wake light-sleep detector (Phase 5.1 — +// tests written BEFORE the implementation, per the approved gate). +// +// Covers: +// - ConfounderProfile (Q2: journal-aware threshold adjustments) +// - enmoTrendExceeded (Q4: personalized movement trend) +// - lateWindowProtection (Q3: suppress early buzz near window end) +// - likelyLightSleep v2 (Q1+Q2+Q3+Q4: 10th-percentile baseline, +// dynamic thresholds, trend condition, late-window protection) +// - Backward compat: existing likelyLightSleep tests still pass +// (default profile = no adjustment = shipped behavior) +// +// Pure functions only — no DB, no BLE, no Flutter widgets. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/state/smart_wake.dart'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +List still(double hr, int n) => + List.generate(n, (_) => SmartWakeSample(hr, 0, 0, 1.0)); // resting, 1g + +List moving(double hr, int n) => + List.generate(n, (_) => SmartWakeSample(hr, 0.5, 0.4, 1.0)); + +SmartWakeSample sample(double hr, double enmo) { + // ENMO = |‖(ax,ay,az)‖ − 1.0|. For ENMO = e, we need ‖v‖ = 1+e or 1−e. + // Simplest: ax = e, ay = 0, az = 1 → ‖v‖ = √(e²+1) ≈ 1 + e²/2… not exact. + // Exact: put all deviation on one axis: ax = e, ay = 0, az = 1. + // Then ‖v‖ = √(e² + 1), ENMO = |√(e²+1) − 1| ≈ e²/2 for small e. + // Better: az = 1+e, ax=ay=0 → ‖v‖ = 1+e → ENMO = e. Exact. + return SmartWakeSample(hr, 0, 0, 1.0 + enmo); +} + +List withEnmo(double hr, double enmo, int n) => + List.generate(n, (_) => sample(hr, enmo)); + +/// Build a list where ENMO changes linearly from [enmoStart] to [enmoEnd]. +List rampEnmo(double hr, double enmoStart, double enmoEnd, int n) { + return List.generate(n, (i) { + final t = n <= 1 ? 0.0 : i / (n - 1); + final enmo = enmoStart + (enmoEnd - enmoStart) * t; + return sample(hr, enmo); + }); +} + +ConfounderProfile emptyProfile() => const ConfounderProfile(); + +// ── ConfounderProfile (Q2) ─────────────────────────────────────────────────── + +void main() { + group('ConfounderProfile (Q2 — journal-aware boost)', () { + test('empty journal → default thresholds (no penalty)', () { + final p = confounderProfile(tags: {}, metrics: {}); + expect(p.hrArousalBpm, 4.0); + expect(p.motionCeilingG, 0.08); + expect(p.lateWindowProtectionMinutes, 5); + expect(p.earlyBuzzDisabled, isFalse); + expect(p.trendWindowMinutes, 3); + expect(p.trendThresholdFactor, 1.0); + }); + + test('alcohol (units>0, last drink ≥20:00) → HR threshold 4→6', () { + final p = confounderProfile( + tags: {'alcohol'}, + metrics: { + 'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60), // 22:00 + }, + ); + expect(p.hrArousalBpm, 6.0); + expect(p.motionCeilingG, 0.08); // unchanged + expect(p.trendWindowMinutes, 5); // alcohol extends trend window + }); + + test('alcohol but last drink before 20:00 → no boost', () { + final p = confounderProfile( + tags: {'alcohol'}, + metrics: { + 'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 18 * 60), // 18:00 + }, + ); + expect(p.hrArousalBpm, 4.0); // no boost — drank early + }); + + test('late caffeine (≥200mg, last ≥16:00) → HR threshold 4→6', () { + final p = confounderProfile( + tags: {'caffeine'}, + metrics: { + 'caffeine_mg': JournalMetricValue(250, atMinuteOfDay: 17 * 60), // 17:00 + }, + ); + expect(p.hrArousalBpm, 6.0); + }); + + test('caffeine <200mg → no boost', () { + final p = confounderProfile( + tags: {'caffeine'}, + metrics: { + 'caffeine_mg': JournalMetricValue(100, atMinuteOfDay: 17 * 60), + }, + ); + expect(p.hrArousalBpm, 4.0); + }); + + test('stress tag → ENMO ceiling 0.08→0.06', () { + final p = confounderProfile(tags: {'stress'}, metrics: {}); + expect(p.motionCeilingG, 0.06); + expect(p.trendThresholdFactor, 0.5); // stricter trend + }); + + test('stress rating ≥4 → ENMO ceiling 0.08→0.06', () { + final p = confounderProfile( + tags: {}, + metrics: {'stress': JournalMetricValue(4)}, + ); + expect(p.motionCeilingG, 0.06); + expect(p.trendThresholdFactor, 0.5); + }); + + test('stress rating 3 → no boost', () { + final p = confounderProfile( + tags: {}, + metrics: {'stress': JournalMetricValue(3)}, + ); + expect(p.motionCeilingG, 0.08); + expect(p.trendThresholdFactor, 1.0); + }); + + test('workout tag (no rest day) → late-window 5→10 min', () { + final p = confounderProfile(tags: {'workout'}, metrics: {}); + expect(p.lateWindowProtectionMinutes, 10); + expect(p.trendWindowMinutes, 5); // workout extends trend window + }); + + test('workout + rest day → no workout boost', () { + final p = confounderProfile(tags: {'workout', 'rest day'}, metrics: {}); + expect(p.lateWindowProtectionMinutes, 5); // default + }); + + test('late meal tag → HR threshold 4→5', () { + final p = confounderProfile(tags: {'late meal'}, metrics: {}); + expect(p.hrArousalBpm, 5.0); + // The movement veto is not relaxed for a late meal. + expect(p.trendThresholdFactor, 1.0); + }); + + test('sick tag → ENMO 0.06 + HR 5 bpm', () { + final p = confounderProfile(tags: {'sick'}, metrics: {}); + expect(p.motionCeilingG, 0.06); + expect(p.hrArousalBpm, 5.0); + expect(p.trendThresholdFactor, 0.5); + }); + + test('alcohol + stress combo → early buzz DISABLED entirely', () { + final p = confounderProfile( + tags: {'alcohol', 'stress'}, + metrics: { + 'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60), + }, + ); + expect(p.earlyBuzzDisabled, isTrue); + }); + + test('alcohol (early) + stress → no combo disable (alcohol too early)', () { + final p = confounderProfile( + tags: {'alcohol', 'stress'}, + metrics: { + 'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 15 * 60), // 15:00 + }, + ); + expect(p.earlyBuzzDisabled, isFalse); + expect(p.motionCeilingG, 0.06); // stress still applies + }); + + test('stacking: alcohol + late meal → HR = MAX(6,5) = 6', () { + final p = confounderProfile( + tags: {'alcohol', 'late meal'}, + metrics: { + 'alcohol_units': JournalMetricValue(1, atMinuteOfDay: 21 * 60), + }, + ); + expect(p.hrArousalBpm, 6.0); + }); + + test('stacking: stress + sick → ENMO = MIN(0.06, 0.06) = 0.06', () { + final p = confounderProfile(tags: {'stress', 'sick'}, metrics: {}); + expect(p.motionCeilingG, 0.06); + expect(p.hrArousalBpm, 5.0); // sick boost + }); + }); + + // ── enmoTrendExceeded (Q4 — personalized movement trend) ─────────────────── + + group('enmoTrendExceeded (Q4 — personalized movement trend)', () { + test('flat ENMO → no trend exceeded', () { + final samples = withEnmo(55, 0.03, 60); + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isFalse, + ); + }); + + test('rising ENMO above threshold → trend exceeded (no buzz)', () { + // First half ~0.02, second half ~0.08 → rise of ~0.06 > 0.02 threshold. + // (0.03→0.06 gives a rise of only ~0.015 — below the 0.02 threshold.) + final samples = rampEnmo(55, 0.02, 0.08, 60); + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isTrue, + ); + }); + + test('rising ENMO below threshold → no trend exceeded (buzz ok)', () { + // Rise of 0.01 < 0.02 threshold + final samples = rampEnmo(55, 0.03, 0.04, 60); + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isFalse, + ); + }); + + test('falling ENMO → no trend exceeded (settling = good)', () { + final samples = rampEnmo(55, 0.06, 0.03, 60); + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isFalse, + ); + }); + + test('too few samples → false (never guesses)', () { + final samples = rampEnmo(55, 0.03, 0.10, 10); + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isFalse, + ); + }); + + test('longer window (5 min, alcohol/workout) smooths short spikes', () { + // A 30-second spike in a 3-min window looks like a trend. + // In a 5-min window, the same spike is diluted. + final spike3min = [ + ...withEnmo(55, 0.03, 90), // 90s flat + ...withEnmo(55, 0.12, 30), // 30s spike + ...withEnmo(55, 0.03, 60), // 60s flat + ]; + // 3-min window: first half mean ~0.03, second half mean ~0.06 → exceeded + expect( + enmoTrendExceeded(spike3min, threshold: 0.02, windowMinutes: 3), + isTrue, + ); + // 5-min window: spike is 30s of 300s → diluted below threshold + final spike5min = [ + ...withEnmo(55, 0.03, 135), // 135s flat + ...withEnmo(55, 0.12, 30), // 30s spike + ...withEnmo(55, 0.03, 135), // 135s flat + ]; + expect( + enmoTrendExceeded(spike5min, threshold: 0.02, windowMinutes: 5), + isFalse, + ); + }); + + // REGRESSION (review finding, mikenrafter on PR #630): windowMinutes used + // to be accepted and never read — the alcohol/workout 5-minute smoothing + // was documentation only. The symmetric spike test above could not catch + // that (it passes either way), so this one is deliberately ASYMMETRIC: + // 2 min of high noise followed by a 3-min ramp. Judged over the whole + // 5 minutes the ramp is invisible (the noisy prefix dominates the first + // half and the rise goes negative); judged over the trailing 3 minutes it + // is a clear trend. Only a genuinely applied window can tell them apart. + test('windowMinutes is actually applied (regression: dead parameter)', () { + final samples = [ + ...withEnmo(55, 0.10, 120), // 2 min flat, high + for (var i = 0; i < 180; i++) // 3 min ramp 0.02 → 0.08 + sample(55, 0.02 + (0.08 - 0.02) * i / 179), + ]; + expect(samples.length, 300); // 5 minutes at 1 Hz + // Trailing 3 minutes: a clear rise → exceeded. + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isTrue, + ); + // Full 5 minutes: the noisy prefix flattens it out → not exceeded. + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 5), + isFalse, + ); + }); + + test('a list shorter than the requested window is used whole', () { + // 2 minutes of samples, 5-minute window requested → use all 120. + final short = withEnmo(55, 0.03, 60) + withEnmo(55, 0.09, 60); + // Judged whole: first half 0.03, second half 0.09 → rise 0.06. + expect( + enmoTrendExceeded(short, threshold: 0.02, windowMinutes: 5), + isTrue, + ); + }); + }); + + // ── lateWindowProtection (Q3 — deep-sleep protection near window end) ────── + + group('lateWindowProtection (Q3)', () { + final windowEnd = DateTime(2026, 10, 26, 6, 30); + + test('not in last 5 min → protection inactive', () { + final now = DateTime(2026, 10, 26, 6, 20); // 10 min before end + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 48, + baselineHr: 48, + ), + isFalse, + ); + }); + + test('in last 5 min AND HR near baseline → protection ACTIVE (no buzz)', () { + final now = DateTime(2026, 10, 26, 6, 28); // 2 min before end + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 49, // +1 bpm from baseline + baselineHr: 48, + ), + isTrue, + ); + }); + + test('in last 5 min but HR clearly elevated → protection inactive (buzz ok)', () { + final now = DateTime(2026, 10, 26, 6, 28); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 56, // +8 bpm — clearly light sleep + baselineHr: 48, + ), + isFalse, + ); + }); + + test('HR within 2 bpm of baseline → protected; above 2 bpm → not', () { + final now = DateTime(2026, 10, 26, 6, 27); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 50, baselineHr: 48, // +2 bpm — boundary + ), + isTrue, // ≤2 bpm → protected + ); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 51, baselineHr: 48, // +3 bpm + ), + isFalse, + ); + }); + + test('workout day (10-min protection) extends the zone', () { + final now = DateTime(2026, 10, 26, 6, 23); // 7 min before end + // 5-min protection: inactive (7 > 5) + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 48, baselineHr: 48, + ), + isFalse, + ); + // 10-min protection: active (7 < 10) + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 10, + currentHr: 48, baselineHr: 48, + ), + isTrue, + ); + }); + + test('exactly at window end → inactive (window check handles this)', () { + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: windowEnd, + protectionMinutes: 5, + currentHr: 48, + baselineHr: 48, + ), + isFalse, + ); + }); + }); + + // ── likelyLightSleep v2 (Q1+Q2+Q3+Q4 integrated) ────────────────────────── + + group('likelyLightSleep v2 (upgraded, with ConfounderProfile)', () { + test('backward compat: default profile = shipped behavior (HR+4, ENMO<0.08)', () { + final baseline = still(52, 90); + final recent = still(58, 60); // +6 bpm, no movement + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isTrue, + ); + }); + + test('backward compat: thin windows still → false', () { + expect( + likelyLightSleep(baseline: still(55, 5), recent: still(65, 60)), + isFalse, + ); + expect( + likelyLightSleep(baseline: still(55, 90), recent: still(65, 5)), + isFalse, + ); + }); + + test('v2: rising ENMO trend → false even if HR elevated and mean ENMO low', () { + final baseline = still(52, 90); + // Mean ENMO is low (~0.045) but rising sharply — person is turning over + final recent = rampEnmo(58, 0.02, 0.07, 60); + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isFalse, // trend exceeded → no buzz + ); + }); + + test('v2: with alcohol profile, +4 bpm is NOT enough (needs +6)', () { + final baseline = still(52, 90); + final recent = still(57, 60); // +5 bpm + final alcoholProfile = confounderProfile( + tags: {'alcohol'}, + metrics: {'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60)}, + ); + // Default: +5 ≥ 4 → would be true + expect(likelyLightSleep(baseline: baseline, recent: recent), isTrue); + // Alcohol profile: +5 < 6 → false + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: alcoholProfile), + isFalse, + ); + }); + + test('v2: alcohol+stress combo → always false (early buzz disabled)', () { + final baseline = still(52, 90); + final recent = still(65, 60); // +13 bpm, very elevated + final comboProfile = confounderProfile( + tags: {'alcohol', 'stress'}, + metrics: {'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60)}, + ); + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: comboProfile), + isFalse, // combo → no buzz regardless + ); + }); + + test('v2: late-window protection suppresses buzz when HR near baseline', () { + final baseline = still(48, 90); + // Use a case that WOULD pass without protection: +5 bpm but in last 5 min + final recentElevated = still(53, 60); // +5 bpm + final windowEnd = DateTime(2026, 10, 26, 6, 30); + final nowLate = DateTime(2026, 10, 26, 6, 28); // 2 min before end + + // Without late-window context: would be true (+5 ≥ 4, no movement) + expect( + likelyLightSleep(baseline: baseline, recent: recentElevated), + isTrue, + ); + // With late-window protection active (HR 53 vs baseline 48 = +5, but + // protection checks if HR is "near baseline" — within 2 bpm): + // 53 is NOT within 2 bpm of 48 → protection inactive → still true + expect( + likelyLightSleep( + baseline: baseline, + recent: recentElevated, + windowEnd: windowEnd, + now: nowLate, + baselineHr: 48, + ), + isTrue, // HR too elevated for protection to apply + ); + // But if HR is truly near baseline (49 = +1 bpm): + final recentNearBaseline = still(49, 60); + expect( + likelyLightSleep( + baseline: baseline, + recent: recentNearBaseline, + windowEnd: windowEnd, + now: nowLate, + baselineHr: 48, + ), + isFalse, // +1 < 4 anyway, but also protection would catch it + ); + }); + + test('v2: a 5-min recent fetch does not move the 3-min HR judgment', () { + // The caller now fetches up to the trend window (5 min on alcohol or + // workout nights). HR arousal must still be judged on the trailing 3 + // minutes — otherwise a longer fetch would silently make the detector + // more eager. + final baseline = still(52, 90); + final recent = [ + ...withEnmo(70, 0.03, 120), // first 2 min: elevated (would inflate a 5-min mean) + ...withEnmo(54, 0.03, 180), // last 3 min: only +2 bpm — below the 4 bpm bar + ]; + expect(recent.length, 300); + // Judged on the trailing 3 minutes → +2 bpm → no buzz. + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isFalse, + ); + // Sanity: the same data judged as a 3-minute list is unchanged. + expect( + likelyLightSleep(baseline: baseline, recent: withEnmo(54, 0.03, 180)), + isFalse, + ); + // And the trailing 3 minutes WOULD buzz if they were elevated. + final recentHot = [ + ...withEnmo(50, 0.03, 120), + ...withEnmo(58, 0.03, 180), // +6 bpm in the trailing 3 min + ]; + expect( + likelyLightSleep(baseline: baseline, recent: recentHot), + isTrue, + ); + }); + + test('v2: stress profile tightens ENMO ceiling (0.07 passes default, fails stress)', () { + final baseline = still(52, 90); + // Mean ENMO 0.07 — passes default 0.08, fails stress 0.06 + final recent = withEnmo(58, 0.07, 60); + final stressProfile = confounderProfile(tags: {'stress'}, metrics: {}); + + expect(likelyLightSleep(baseline: baseline, recent: recent), isTrue); + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: stressProfile), + isFalse, + ); + }); + }); + + // ── 10th-percentile baseline helper ──────────────────────────────────────── + + group('percentileHr (baseline computation)', () { + test('10th percentile of flat HR = that HR', () { + final samples = still(52, 100); + expect(percentileHr(samples, 0.10), 52.0); + }); + + test('10th percentile picks the low end (deep-sleep HR)', () { + // One reading at 40 (lower than the 10th percentile), 80 at 48 (deep), + // 19 at 60 (light): a minimum-based baseline would say 40, the 10th + // percentile says 48. + final samples = [...still(40, 1), ...still(48, 80), ...still(60, 19)]; + expect(percentileHr(samples, 0.10), 48.0); + }); + + test('empty list → null (caller falls back)', () { + expect(percentileHr([], 0.10), isNull); + }); + + test('filters invalid HR (≤30 or ≥200)', () { + // 30 valid readings at 48 plus 20 invalid ones: with the invalid rows in + // the sort the 10th percentile would be an off-skin 0. + final samples = [ + ...still(48, 30), + for (var i = 0; i < 10; i++) SmartWakeSample(0, 0, 0, 1.0), // off-skin + for (var i = 0; i < 10; i++) SmartWakeSample(250, 0, 0, 1.0), // impossible + ]; + expect(percentileHr(samples, 0.10), 48.0); + }); + + test('fewer than 30 valid HR values → null (no single-artifact baseline)', () { + // 29 valid values: a percentile from this is one artifact away from a + // wrong floor, so it must refuse and let the caller fall back. + expect(percentileHr(still(48, 29), 0.10), isNull); + expect(percentileHr(still(48, 30), 0.10), 48.0); + // 29 valid among 49 rows: counting rows instead of valid readings would + // pass this through. + final padded = [ + ...still(48, 29), + for (var i = 0; i < 20; i++) SmartWakeSample(0, 0, 0, 1.0), + ]; + expect(percentileHr(padded, 0.10), isNull); + }); + }); + + group('medianValidHr (the 90-minute fallback)', () { + test('a median of zeros never comes back', () { + final zeros = [for (var i = 0; i < 30; i++) SmartWakeSample(0, 0, 0, 1.0)]; + expect(medianValidHr(zeros), isNull); + }); + + test('needs 30 VALID readings, not 30 rows', () { + final padded = [ + ...still(52, 29), + for (var i = 0; i < 20; i++) SmartWakeSample(0, 0, 0, 1.0), + ]; + expect(medianValidHr(padded), isNull); + expect(medianValidHr([...still(52, 30), ...still(0, 40)]), 52.0); + }); + }); + + group('windows are cut by time, not by row count', () { + SmartWakeSample at(int ts, double hr) => + SmartWakeSample(hr, 0, 0, 1.0, tsSec: ts); + + test('30 rows from four minutes ago are not "the last 3 minutes"', () { + final now = DateTime.fromMillisecondsSinceEpoch(1000000 * 1000); + final nowS = now.millisecondsSinceEpoch ~/ 1000; + final stale = [for (var i = 0; i < 30; i++) at(nowS - 240 + i, 60)]; + expect( + likelyLightSleep( + baseline: still(50, 90), + recent: stale, + baselineHrOverride: 50, + now: now), + isFalse, + ); + // The same rows, fresh, do buzz. + final fresh = [for (var i = 0; i < 30; i++) at(nowS - 60 + i, 60)]; + expect( + likelyLightSleep( + baseline: still(50, 90), + recent: fresh, + baselineHrOverride: 50, + now: now), + isTrue, + ); + }); + + test('trailingSamples keeps only the trailing seconds', () { + final xs = [for (var i = 0; i < 600; i++) at(i, 50)]; + expect(trailingSamples(xs, 180).length, 180); + expect(trailingSamples(xs, 180).first.tsSec, 420); + }); + }); + + group('late-window protection is applied inside likelyLightSleep', () { + // An arousal bar low enough that +1.5 bpm would buzz, so only the + // late-window veto can hold it back. + const lowBar = ConfounderProfile( + hrArousalBpm: 1.0, + motionCeilingG: 0.08, + lateWindowProtectionMinutes: 5, + earlyBuzzDisabled: false, + trendWindowMinutes: 3, + trendThresholdFactor: 1.0, + ); + + test('buzzes mid-window, is held back in the last minutes', () { + final end = DateTime(2026, 6, 10, 7, 30); + bool run(DateTime now) => likelyLightSleep( + baseline: still(48, 90), + recent: still(49.5, 60), + profile: lowBar, + windowEnd: end, + now: now, + ); + expect(run(end.subtract(const Duration(minutes: 30))), isTrue); + expect(run(end.subtract(const Duration(minutes: 2))), isFalse); + }); + }); + + group('a late meal does not loosen the movement veto', () { + test('trendThresholdFactor stays 1.0', () { + final p = confounderProfile(tags: {'late meal'}, metrics: const {}); + expect(p.trendThresholdFactor, 1.0); + }); + }); +}