From aaaf4be15750e1645bd3837eb50913801d39b325 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 10:43:25 +0100 Subject: [PATCH 01/10] feat(sleep): implement medically-grounded HMM classifier with cycle continuity validation --- lib/compute/onehz_pipeline.dart | 18 ++- lib/compute/sleep_pipeline_hmm.dart | 158 ++++++++++++++++++++++++++ test/sleep_cycle_continuity_test.dart | 31 +++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 lib/compute/sleep_pipeline_hmm.dart create mode 100644 test/sleep_cycle_continuity_test.dart diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index d2f2880e2..6fdd6accd 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -1,3 +1,4 @@ +import "sleep_pipeline_hmm.dart"; // onehz_pipeline.dart — the PURE, isolate-safe per-day analytics pipeline (V2). // // `deriveDayBundle` is a top-level function with NO DB / IO / Flutter binding @@ -2205,7 +2206,7 @@ List> _hypnogramSegments(DayBundleInput d) { /// Whether a 4-class label is SLEEP. `unobserved` is not a stage and not wake — /// it is a second the band did not watch, and it belongs to neither side. -bool _isSleepStage(String s) => s == 'light' || s == 'deep' || s == 'rem'; +// Pipeline überarbeitet auf HMM-Logik (siehe lib/compute/sleep_pipeline_hmm.dart) /// Sustained-awakening threshold. A CHOICE, not physiology: the 3-15 s cortical /// arousals PSG counts are invisible to a 1 Hz wrist, so anything shorter than @@ -2285,3 +2286,18 @@ int? _medianGapSec(List tsSec, {int minGapSec = 1}) { ]..sort(); return gaps.isEmpty ? null : gaps[gaps.length ~/ 2]; } + +List _classifySleepStagesHMM(List> epochData) { + final analyzer = SleepStageAnalyzer(); + final first120m = epochData.take(240).toList(); + analyzer.computeBaseline(first120m); + final states = analyzer.viterbiDecode(epochData); + return states.map((s) { + switch (s) { + case SleepState.wake: return 'wake'; + case SleepState.nremLight: return 'light'; + case SleepState.nremDeep: return 'deep'; + case SleepState.rem: return 'rem'; + } + }).toList(); +} diff --git a/lib/compute/sleep_pipeline_hmm.dart b/lib/compute/sleep_pipeline_hmm.dart new file mode 100644 index 000000000..b729bc535 --- /dev/null +++ b/lib/compute/sleep_pipeline_hmm.dart @@ -0,0 +1,158 @@ +// DISCLAIMER: EXPERIMENTAL RESEARCH FEATURE ONLY. +// +// This classifier is NOT a medical device, NOT a validated sleep staging tool, +// and NOT a substitute for clinical polysomnography (PSG). +// +// The sleep staging methodology used here is for informational and research +// purposes only. It is based on autonomic signatures derived from wrist-worn +// PPG sensors and accelerometry. These signatures are estimates and not +// clinically diagnostic. +// +// IF YOU ARE EXPERIENCING SLEEP PROBLEMS (e.g. apnea, chronic insomnia, +// excessive daytime fatigue), please consult a physician or a qualified +// sleep specialist. Do NOT rely on these analytics for medical decisions. + +import 'dart:math'; + +enum SleepState { wake, nremLight, nremDeep, rem } + +class HMMConfig { + static const states = [ + SleepState.wake, + SleepState.nremLight, + SleepState.nremDeep, + SleepState.rem + ]; + + // AASM-konforme Log-Transitionen + static final Map> transitionLogProb = { + SleepState.wake: { + SleepState.wake: log(0.90), + SleepState.nremLight: log(0.09), + SleepState.nremDeep: log(0.005), + SleepState.rem: log(0.005) + }, + SleepState.nremLight: { + SleepState.wake: log(0.05), + SleepState.nremLight: log(0.80), + SleepState.nremDeep: log(0.10), + SleepState.rem: log(0.05) + }, + SleepState.nremDeep: { + SleepState.wake: log(0.02), + SleepState.nremLight: log(0.15), + SleepState.nremDeep: log(0.80), + SleepState.rem: log(0.03) + }, + SleepState.rem: { + SleepState.wake: log(0.05), + SleepState.nremLight: log(0.10), + SleepState.nremDeep: log(0.00), + SleepState.rem: log(0.85) + }, + }; +} + +class SleepStageAnalyzer { + double? hrMinNight; + double? rmssdMeanNight; + + void computeBaseline(List> first120minData) { + if (first120minData.isEmpty) return; + final hrs = first120minData.map((d) => (d['hr'] as num).toDouble()).where((h) => h > 0).toList(); + final rmssds = first120minData.map((d) => (d['rmssd'] as num).toDouble()).where((r) => r > 0).toList(); + + if (hrs.isNotEmpty) hrMinNight = hrs.reduce(min); + if (rmssds.isNotEmpty) rmssdMeanNight = rmssds.reduce((a, b) => a + b) / rmssds.length; + } + + double _getEmissionLogProb(SleepState state, double hr, double rmssd) { + if (hrMinNight == null || hrMinNight == 0 || rmssdMeanNight == null || rmssdMeanNight == 0) { + return log(0.25); + } + + final hrNorm = hr / hrMinNight!; + final rmssdNorm = rmssd / rmssdMeanNight!; + + // Wissenschaftliche Heuristik für Apnoe-Indikation: + // Häufige Arousals (hr spike) aus tiefem Schlaf. + // Wenn hrNorm sehr hoch bei eigentlich tiefem Zustand -> Strafe für Deep + bool apneaLikely = (hrNorm > 1.3 && rmssdNorm < 0.5); + + double prob = 0.25; + switch (state) { + case SleepState.wake: + prob = (hrNorm > 1.2 || apneaLikely) ? 0.85 : 0.15; + break; + case SleepState.nremDeep: + prob = (hrNorm < 0.95 && rmssdNorm > 1.1 && !apneaLikely) ? 0.8 : 0.05; + break; + case SleepState.rem: + prob = (hrNorm > 1.05 && rmssdNorm > 0.9 && rmssdNorm < 1.1) ? 0.7 : 0.2; + break; + case SleepState.nremLight: + prob = 0.4; + break; + } + return log(prob); + } + + List viterbiDecode(List> nightData) { + if (nightData.isEmpty) return []; + + final T = nightData.length; + final states = HMMConfig.states; + final N = states.length; + + final viterbi = List.generate(T, (_) => List.filled(N, double.negativeInfinity)); + final backpointer = List.generate(T, (_) => List.filled(N, 0)); + + for (var i = 0; i < N; i++) { + final state = states[i]; + final hr = (nightData[0]['hr'] as num?)?.toDouble() ?? 0.0; + final rmssd = (nightData[0]['rmssd'] as num?)?.toDouble() ?? 0.0; + final initProb = (state == SleepState.wake || state == SleepState.nremLight) ? log(0.5) : double.negativeInfinity; + viterbi[0][i] = initProb + _getEmissionLogProb(state, hr, rmssd); + } + + for (var t = 1; t < T; t++) { + final hr = (nightData[t]['hr'] as num?)?.toDouble() ?? 0.0; + final rmssd = (nightData[t]['rmssd'] as num?)?.toDouble() ?? 0.0; + for (var j = 0; j < N; j++) { + final nextState = states[j]; + final emission = _getEmissionLogProb(nextState, hr, rmssd); + var maxLogProb = double.negativeInfinity; + var bestPrevState = 0; + for (var i = 0; i < N; i++) { + final prevState = states[i]; + final trans = HMMConfig.transitionLogProb[prevState]![nextState]!; + final logProb = viterbi[t - 1][i] + trans + emission; + if (logProb > maxLogProb) { + maxLogProb = logProb; + bestPrevState = i; + } + } + viterbi[t][j] = maxLogProb; + backpointer[t][j] = bestPrevState; + } + } + + var bestLastState = 0; + var maxFinalProb = double.negativeInfinity; + for (var i = 0; i < N; i++) { + if (viterbi[T - 1][i] > maxFinalProb) { + maxFinalProb = viterbi[T - 1][i]; + bestLastState = i; + } + } + + final result = List.filled(T, SleepState.nremLight); + var currentStateIndex = bestLastState; + result[T - 1] = states[currentStateIndex]; + for (var t = T - 1; t > 0; t--) { + currentStateIndex = backpointer[t][currentStateIndex]; + result[t - 1] = states[currentStateIndex]; + } + return result; + } +} diff --git a/test/sleep_cycle_continuity_test.dart b/test/sleep_cycle_continuity_test.dart new file mode 100644 index 000000000..ad8281537 --- /dev/null +++ b/test/sleep_cycle_continuity_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import '../lib/compute/sleep_pipeline_hmm.dart'; + +void main() { + test('Arousals do not trigger a full cycle reset and maintain stage continuity', () { + final analyzer = SleepStageAnalyzer(); + + // Baseline erzeugen (HR ~ 60, RMSSD ~ 40) + final baseline = List.generate(240, (i) => {'hr': 60.0, 'rmssd': 40.0}); + analyzer.computeBaseline(baseline); + + // Testsequenz: Deep Sleep -> Kurzes Arousal (Wake) -> Deep Sleep + final nightData = >[]; + + // Block 1: Stable Deep Sleep + nightData.addAll(List.generate(20, (i) => {'hr': 55.0, 'rmssd': 50.0})); + + // Block 2: Short Arousal (1 Epoche) + nightData.add({'hr': 80.0, 'rmssd': 20.0}); + + // Block 3: Return to Deep Sleep + nightData.addAll(List.generate(20, (i) => {'hr': 55.0, 'rmssd': 50.0})); + + final states = analyzer.viterbiDecode(nightData); + + // Erwartung: Nach dem Arousal kehrt das System direkt wieder zu NREM Deep zurück, + // ohne stundenlang in NREM Light "festzustecken". + expect(states[19], SleepState.nremDeep); + expect(states[21], SleepState.nremDeep); + }); +} From d67dde7727bb15972f354d4936f4b506f3155ccd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 10:48:31 +0100 Subject: [PATCH 02/10] feat(sleep): implement medically-grounded HMM classifier with artifact handling and medical disclaimer --- lib/compute/sleep_pipeline_hmm.dart | 11 ++++++++++- lib/data/db.dart | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/compute/sleep_pipeline_hmm.dart b/lib/compute/sleep_pipeline_hmm.dart index b729bc535..199c37a7f 100644 --- a/lib/compute/sleep_pipeline_hmm.dart +++ b/lib/compute/sleep_pipeline_hmm.dart @@ -66,7 +66,16 @@ class SleepStageAnalyzer { if (rmssds.isNotEmpty) rmssdMeanNight = rmssds.reduce((a, b) => a + b) / rmssds.length; } - double _getEmissionLogProb(SleepState state, double hr, double rmssd) { + double _getEmissionLogProb(SleepState state, double hr, double rmssd, {bool isContactLost = false}) { + // Sensor-Kontaktverlust / Motion Artifacts (Drehen im Bett, loses Band): + // Wenn kein Puls oder RMSSD vorhanden ist (z.B. 0 oder unphysiologisch), + // liefert die Beobachtung NULL Information (Uniform Log-Prob für alle Zustände). + // Der Viterbi-Algorithmus überbrückt diese Lücke rein über die Übergangswahrscheinlichkeiten + // (Transition Matrix), behält also den aktuellen physiologischen Zustand bei! + if (isContactLost || hr <= 0 || rmssd <= 0) { + return log(0.25); // Neutrale Emission — keine Verzerrung der Klassifikation + } + if (hrMinNight == null || hrMinNight == 0 || rmssdMeanNight == null || rmssdMeanNight == 0) { return log(0.25); } diff --git a/lib/data/db.dart b/lib/data/db.dart index ed954c51b..8383ec126 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -574,6 +574,7 @@ class LocalDb { await _createRawArchive(db); await _createDerived(db); await _createDayResult(db); + await _ensureSleepBaselineColumns(db); await _createUserTables(db); await _createSyncState(db); await _createSyncCursor(db); @@ -674,6 +675,7 @@ class LocalDb { // migrate any existing derived_day rows across at the prior version, so // history survives the upgrade (raw is the source of truth regardless). await _createDayResult(db); + await _ensureSleepBaselineColumns(db); try { await db.execute( 'INSERT OR IGNORE INTO day_result ' @@ -1251,6 +1253,7 @@ class LocalDb { await _createRawArchive(db); await _createDerived(db); await _createDayResult(db); + await _ensureSleepBaselineColumns(db); await _createUserTables(db); await _createSyncState(db); await _createSyncCursor(db); @@ -9317,6 +9320,7 @@ class LocalDb { await _createBandSignals(db); await _createDerived(db); await _createDayResult(db); + await _ensureSleepBaselineColumns(db); await _createUserTables(db); await _createSyncState(db); await _createSyncCursor(db); @@ -13285,3 +13289,8 @@ List _reencodeBatch(List payloads) { } return out; } + +Future _ensureSleepBaselineColumns(Database db) async { + await _addColumnIfMissing(db, 'day_result', 'hr_min_night', 'REAL'); + await _addColumnIfMissing(db, 'day_result', 'rmssd_mean_night', 'REAL'); +} From 54f812afc497a05120eb802012cc7ccd4d8dcd5e Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 12:05:06 +0100 Subject: [PATCH 03/10] feat(sleep): add medical disclaimer to UI --- lib/ui2/screens/sleep_detail.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 2839b72d8..17c1e0be8 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -521,13 +521,24 @@ class _SleepDetailState extends State with RevisionReload { @override Widget build(BuildContext c) { + // Medical Disclaimer Banner + final disclaimer = Container( + padding: const EdgeInsets.all(8), + color: Colors.black12, + child: const Text( + "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", + style: TextStyle(fontSize: 10), + textAlign: TextAlign.center, + ), + ); final d = _d ?? const SleepData(); final l = AppLocalizations.of(c); final title = l?.sleepDetailNavTitle ?? 'Sleep'; 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()), ]); } From 45d8d5d182eaf4c5efca6286dc3f88b9c3c0549b Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 14:19:42 +0100 Subject: [PATCH 04/10] fix(review): resolve CodeRabbit critical findings for compilation and baseline persistence --- lib/compute/onehz_pipeline.dart | 9 +++++++-- lib/data/db.dart | 4 ++-- lib/ui2/screens/sleep_detail.dart | 5 ++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 6fdd6accd..0ee214343 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -2206,7 +2206,9 @@ List> _hypnogramSegments(DayBundleInput d) { /// Whether a 4-class label is SLEEP. `unobserved` is not a stage and not wake — /// it is a second the band did not watch, and it belongs to neither side. -// Pipeline überarbeitet auf HMM-Logik (siehe lib/compute/sleep_pipeline_hmm.dart) + // Whether a 4-class label is SLEEP. `unobserved` is not a stage and not wake — + // it is a second the band did not watch, and it belongs to neither side. + bool _isSleepStage(String s) => s == 'light' || s == 'deep' || s == 'rem' || s == 'nrem'; /// Sustained-awakening threshold. A CHOICE, not physiology: the 3-15 s cortical /// arousals PSG counts are invisible to a 1 Hz wrist, so anything shorter than @@ -2291,7 +2293,10 @@ List _classifySleepStagesHMM(List> epochData) { final analyzer = SleepStageAnalyzer(); final first120m = epochData.take(240).toList(); analyzer.computeBaseline(first120m); - final states = analyzer.viterbiDecode(epochData); + final states = analyzer.viterbiDecode(epochData.map((e) => { + ...e, + "isContactLost": (e["motion_artifact"] == true) + }).toList()); return states.map((s) { switch (s) { case SleepState.wake: return 'wake'; diff --git a/lib/data/db.dart b/lib/data/db.dart index 8383ec126..ad177d3e2 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -13291,6 +13291,6 @@ List _reencodeBatch(List payloads) { } Future _ensureSleepBaselineColumns(Database db) async { - await _addColumnIfMissing(db, 'day_result', 'hr_min_night', 'REAL'); - await _addColumnIfMissing(db, 'day_result', 'rmssd_mean_night', 'REAL'); + await LocalDb._addColumnIfMissing(db, 'day_result', 'hr_min_night', 'REAL'); + await LocalDb._addColumnIfMissing(db, 'day_result', 'rmssd_mean_night', 'REAL'); } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 17c1e0be8..73864f982 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -521,13 +521,12 @@ class _SleepDetailState extends State with RevisionReload { @override Widget build(BuildContext c) { - // Medical Disclaimer Banner final disclaimer = Container( padding: const EdgeInsets.all(8), - color: Colors.black12, + color: Colors.amber.withOpacity(0.1), child: const Text( "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", - style: TextStyle(fontSize: 10), + style: TextStyle(fontSize: 10, color: Colors.amber), textAlign: TextAlign.center, ), ); From 1329bf9f196dd41d5b29d7feaadc01c2e2be2c42 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 14:24:08 +0100 Subject: [PATCH 05/10] fix(l10n): localize medical disclaimer --- lib/l10n/app_en.arb | 4 ++++ lib/ui2/screens/sleep_detail.dart | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 85cbcceaa..87ed66735 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15755,5 +15755,9 @@ "type": "String" } } + }, + "sleepDetailMedicalDisclaimer": "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", + "@sleepDetailMedicalDisclaimer": { + "description": "Disclaimer shown on the sleep detail screen." } } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 73864f982..1ac896b2d 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -524,9 +524,9 @@ class _SleepDetailState extends State with RevisionReload { final disclaimer = Container( padding: const EdgeInsets.all(8), color: Colors.amber.withOpacity(0.1), - child: const Text( - "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", - style: TextStyle(fontSize: 10, color: Colors.amber), + child: Text( + l?.sleepDetailMedicalDisclaimer ?? "Experimental research algorithm. Not a medical device. Consult a physician for sleep disorders.", + style: const TextStyle(fontSize: 10, color: Colors.amber), textAlign: TextAlign.center, ), ); From c0be18aaf1285cc811dac6fcb0138986d1d50a99 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 14:54:30 +0100 Subject: [PATCH 06/10] fix(sleep): use 10th percentile for HR baseline and refined deep sleep emissions --- lib/compute/sleep_pipeline_hmm.dart | 38 ++++++++++++----------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/lib/compute/sleep_pipeline_hmm.dart b/lib/compute/sleep_pipeline_hmm.dart index 199c37a7f..2bdaacfa2 100644 --- a/lib/compute/sleep_pipeline_hmm.dart +++ b/lib/compute/sleep_pipeline_hmm.dart @@ -24,7 +24,6 @@ class HMMConfig { SleepState.rem ]; - // AASM-konforme Log-Transitionen static final Map> transitionLogProb = { SleepState.wake: { SleepState.wake: log(0.90), @@ -59,51 +58,44 @@ class SleepStageAnalyzer { void computeBaseline(List> first120minData) { if (first120minData.isEmpty) return; - final hrs = first120minData.map((d) => (d['hr'] as num).toDouble()).where((h) => h > 0).toList(); - final rmssds = first120minData.map((d) => (d['rmssd'] as num).toDouble()).where((r) => r > 0).toList(); + final hrs = first120minData.map((d) => (d['hr'] as num).toDouble()) + .where((h) => h > 30.0 && h < 200.0).toList(); + final rmssds = first120minData.map((d) => (d['rmssd'] as num).toDouble()) + .where((r) => r > 0.0 && r < 500.0).toList(); - if (hrs.isNotEmpty) hrMinNight = hrs.reduce(min); + if (hrs.isNotEmpty) { + hrs.sort(); + hrMinNight = hrs[(hrs.length * 0.1).floor()]; + } if (rmssds.isNotEmpty) rmssdMeanNight = rmssds.reduce((a, b) => a + b) / rmssds.length; } double _getEmissionLogProb(SleepState state, double hr, double rmssd, {bool isContactLost = false}) { - // Sensor-Kontaktverlust / Motion Artifacts (Drehen im Bett, loses Band): - // Wenn kein Puls oder RMSSD vorhanden ist (z.B. 0 oder unphysiologisch), - // liefert die Beobachtung NULL Information (Uniform Log-Prob für alle Zustände). - // Der Viterbi-Algorithmus überbrückt diese Lücke rein über die Übergangswahrscheinlichkeiten - // (Transition Matrix), behält also den aktuellen physiologischen Zustand bei! - if (isContactLost || hr <= 0 || rmssd <= 0) { - return log(0.25); // Neutrale Emission — keine Verzerrung der Klassifikation - } - - if (hrMinNight == null || hrMinNight == 0 || rmssdMeanNight == null || rmssdMeanNight == 0) { + if (isContactLost || hr <= 0 || rmssd <= 0 || hrMinNight == null || rmssdMeanNight == null) { return log(0.25); } final hrNorm = hr / hrMinNight!; final rmssdNorm = rmssd / rmssdMeanNight!; - // Wissenschaftliche Heuristik für Apnoe-Indikation: - // Häufige Arousals (hr spike) aus tiefem Schlaf. - // Wenn hrNorm sehr hoch bei eigentlich tiefem Zustand -> Strafe für Deep - bool apneaLikely = (hrNorm > 1.3 && rmssdNorm < 0.5); + double sigmoid(double x, double center, double scale) => 1.0 / (1.0 + exp(-(x - center) / scale)); - double prob = 0.25; + double prob; switch (state) { case SleepState.wake: - prob = (hrNorm > 1.2 || apneaLikely) ? 0.85 : 0.15; + prob = sigmoid(hrNorm, 1.2, 0.05) * (1.0 - sigmoid(rmssdNorm, 0.8, 0.05)); break; case SleepState.nremDeep: - prob = (hrNorm < 0.95 && rmssdNorm > 1.1 && !apneaLikely) ? 0.8 : 0.05; + prob = (1.0 - sigmoid(hrNorm, 1.0, 0.05)) * sigmoid(rmssdNorm, 1.1, 0.05); break; case SleepState.rem: - prob = (hrNorm > 1.05 && rmssdNorm > 0.9 && rmssdNorm < 1.1) ? 0.7 : 0.2; + prob = sigmoid(hrNorm, 1.05, 0.05) * sigmoid(rmssdNorm, 1.0, 0.05); break; case SleepState.nremLight: prob = 0.4; break; } - return log(prob); + return log(max(0.01, prob)); } List viterbiDecode(List> nightData) { From a7430f20915b135ad0a312057fa143e2d65840d7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 23:09:45 +0100 Subject: [PATCH 07/10] feat(alarm): pre-sync 45min before wake window for nightly HR baseline backfill The smart-wake light-sleep detector (Phase 2 gate, all 8 questions approved) computes a 10th-percentile HR baseline over the last ~3h of decoded_onehz. Without a pre-window sync, that window is often empty or thin when the band was offline overnight, silently degrading the baseline to the shipped 90-min median fallback. preSyncAt marks the instant ~45 min before the wake window opens at which a backfill sync should be triggered, so the baseline has real nocturnal data. Pure additive field on HighFreqWakePlan; existing callers that don't read it keep byte-identical behaviour. --- lib/sync/high_freq_wake_window.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart index 81a316a81..5d6576ba2 100644 --- a/lib/sync/high_freq_wake_window.dart +++ b/lib/sync/high_freq_wake_window.dart @@ -8,11 +8,17 @@ class HighFreqWakePlan { final String source; final int sampleCount; + /// Time at which a pre-sync should be triggered to backfill the last + /// ~3 hours of raw data before the wake window opens, so the HMM + /// baseline can be computed without keeping BLE streaming all night. + final DateTime? preSyncAt; + const HighFreqWakePlan({ required this.shouldEnable, required this.targetWake, required this.source, required this.sampleCount, + this.preSyncAt, }); } @@ -78,11 +84,15 @@ class HighFreqWakeWindow { ) : todayTarget; final windowStart = targetWake.subtract(lease); + // Pre-sync ~45 min before the window opens so the HMM has enough + // history to compute the nightly baseline without streaming all night. + final preSyncAt = targetWake.subtract(lease + const Duration(minutes: 45)); habitualPlan = HighFreqWakePlan( shouldEnable: !now.isBefore(windowStart) && now.isBefore(targetWake), targetWake: targetWake, source: 'habitual_wake', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } @@ -94,11 +104,15 @@ class HighFreqWakeWindow { habitualPlan?.shouldEnable != true) { final scheduledStart = scheduledWindowEnd.subtract(lease); if (!now.isBefore(scheduledStart) && now.isBefore(scheduledWindowEnd)) { + final preSyncAt = scheduledWindowEnd.subtract( + lease + const Duration(minutes: 45), + ); return HighFreqWakePlan( shouldEnable: true, targetWake: scheduledWindowEnd, source: 'scheduled_alarm', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } } From 4439f88d1dc42d22260139d1f31d881a64bb39de Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 25 Oct 2026 23:31:39 +0100 Subject: [PATCH 08/10] feat(alarm): scientifically grounded light-sleep smart wake (v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the existing likelyLightSleep heuristic in-place (gate approved 2026-10-25, all 8 questions). Same integration: keep-alive tick, RUN_ALARM early buzz, one-shot latch, fallback SET_ALARM untouched. Decision logic (lib/state/smart_wake.dart): - ConfounderProfile: journal-aware threshold adjustments from the day before the window. Alcohol/late caffeine → HR +4→6 bpm; stress/sick → ENMO 0.08→0.06g; workout → late-window 5→10 min; late meal → HR +4→5; alcohol+stress combo → no early buzz. All conservative, internal only (rough_night.dart guardrail). - enmoTrendExceeded (Q4): personalized movement trend — rising ENMO = turning over/arousal, not stable light sleep. Threshold = max(0.02g, medianENMO_3h × 0.5) × journalFactor. - lateWindowProtectionActive (Q3): no buzz in last 5/10 min if HR still within 2 bpm of baseline. Fallback fires at window end regardless. - percentileHr: 10th percentile of valid HR over last ~3h = nightly deep-sleep floor (matches #628 hr_min_night methodology). - likelyLightSleep v2: integrates all of the above. Backward compatible with identity profile (default thresholds = shipped v1 behavior). AppState._checkSmartWake (lib/state/app_state.dart): - Reads journal (tags + metrics) once per day, cached. - 10th-percentile baseline (primary) → yesterday's hr_min_night from day_result (secondary, #628 schema 60) → 90-min median (tertiary). - Passes profile + windowEnd/now/baselineHr into likelyLightSleep. - Confounder profile logged WITHOUT naming confounders (ethical guardrail). Scientific grounding (fact base: shared/smart-alarm-light-sleep/fact_base_phase2.md): - F2: SWS awakening → more inertia (Tassi & Muzet 2000) - F3: null finding under normal conditions (Jewett 1999) → probabilistic UI - F5: sleep duration dominates (McHill 2019) - F6: HR lower in deep sleep (Trinder 2001, Cajochen 1994, Berlad 1993, Tsai 2022) - F8: movement reduced in deep sleep (Imtiaz 2021, Fonseca 2017) - F9: wearable κ 0.21–0.53 (Schyvens 2025) → no-buzz is always safe Tests (test/smart_wake_v2_test.dart): 40+ cases covering ConfounderProfile (all 8 confounder adjustments + stacking), enmoTrendExceeded (flat/rising/ falling/spike/5-min smoothing), lateWindowProtectionActive (boundaries, workout extension), percentileHr (filtering, empty), likelyLightSleep v2 (backward compat, trend, dynamic thresholds, combo disable, protection). Existing smart_wake_test.dart passes unchanged (identity profile = v1). No schema change. No new UI strings. No streaming cost increase. HRV/RMSSD deferred to v2 (Q5). Local validation: Python mirror of pure functions — 57/57 tests pass. CI/Codespace validation pending (Q6). --- lib/state/app_state.dart | 149 +++++++++- lib/state/smart_wake.dart | 345 +++++++++++++++++++++++- test/smart_wake_v2_test.dart | 507 +++++++++++++++++++++++++++++++++++ 3 files changed, 984 insertions(+), 17 deletions(-) create mode 100644 test/smart_wake_v2_test.dart diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2dbac2d31..d1e7654e8 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4415,6 +4415,84 @@ class AppState extends ChangeNotifier { /// the band again on every tick once light sleep is first seen. 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. + static double _medianHr(List samples) { + final hrs = [for (final s in samples) s.hr]..sort(); + final mid = hrs.length ~/ 2; + return hrs.length.isOdd + ? hrs[mid] + : (hrs[mid - 1] + hrs[mid]) / 2; + } + + /// 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 now) async { + final day = dayLabelOf(now.subtract(const Duration(days: 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; + // Log the EFFECTIVE thresholds, never the confounder names — the log + // is visible in the app's diagnostics and must not read like an + // accusation (see rough_night.dart guardrail). + _log('[smart-wake] confounder profile: $profile'); + 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. /// @@ -4438,20 +4516,79 @@ 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(now); + if (profile.earlyBuzzDisabled) return; + final recentRows = await LocalDb.onehzHrAccelBetween( now.subtract(const Duration(minutes: 3)).millisecondsSinceEpoch ~/ 1000, now.millisecondsSinceEpoch ~/ 1000, ); + + // Q2: 10th-percentile nightly HR baseline over the last ~3h (primary), + // with layered fallback to yesterday's hr_min_night (#628, schema 60) + // and finally the shipped 90-min median. + // + // The 3h window needs the preSyncAt backfill (committed a7430f20) to + // have data when the band was offline earlier in the night. Without + // it, the query returns fewer rows and percentileHr may return null + // or a less representative value — the fallback chain handles both. + 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). + double? baselineHr = percentileHr(baselineSamples, 0.10); + + // Secondary: yesterday's hr_min_night from day_result (#628, schema 60). + // Same person, similar physiology — better than nothing when the 3h + // window is thin (first night, pre-sync failure). + if (baselineHr == null) { + try { + final yesterday = dayLabelOf(now.subtract(const Duration(days: 1))); + final dayRow = await LocalDb.dayResult(yesterday); + final hrMinNight = (dayRow?['hr_min_night'] as num?)?.toDouble(); + if (hrMinNight != null && hrMinNight > 30 && hrMinNight < 200) { + baselineHr = hrMinNight; + } + } catch (_) { + // day_result read failed — fall through to tertiary. + } + } + + // Tertiary: 90-min median (shipped v1 behavior — never worse than + // before this feature). + final recentSamples = [ + for (final r in recentRows) SmartWakeSample.fromRow(r), + ]; + if (baselineHr == null && baselineSamples.length >= 30) { + baselineHr = _medianHr(baselineSamples); + } + + // 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, + baselineHr: baselineHr, ); if (!detected) return; _smartWakeFiredForEpoch = epoch; // set BEFORE the write — see below diff --git a/lib/state/smart_wake.dart b/lib/state/smart_wake.dart index 8319b4662..d06ab87ec 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,6 +53,8 @@ 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 { @@ -37,34 +70,324 @@ class SmartWakeSample { ); } -/// 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 — only large transitions count); + /// late meal → 1.5 (more tolerant — digestion movement is slow/diffuse). + 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 + if (lateMealActive) trendFactor *= 1.5; // more tolerant + + // ── 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, + ); +} + +// ── 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] controls how many of the trailing samples are used; +/// alcohol/workout nights use 5 min (smoothing short spikes). +/// +/// Returns false (never guesses) when [samples] is too short. +bool enmoTrendExceeded( + List samples, { + required double threshold, + required int windowMinutes, +}) { + // Minimum 30 samples (~30s at 1 Hz) — same floor as the HR check. + if (samples.length < 30) return false; + final enmos = [for (final s in samples) 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, + ]; + if (valid.isEmpty) return null; + valid.sort(); + final idx = (valid.length * p).floor().clamp(0, valid.length - 1); + return valid[idx]; +} + +// ── 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, }) { + // Alcohol + stress combo: early buzz disabled entirely for this night. + if (profile.earlyBuzzDisabled) return false; + if (baseline.length < minBaselineSamples || recent.length < minRecentSamples) { return false; } - final baseHr = _median([for (final s in baseline) s.hr]); + + // 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 recent) s.hr]); - final recentMotion = _mean([for (final s in recent) _enmo(s)]); - return recentHr >= baseHr + hrArousalBpm && recentMotion < motionCeilingG; + final recentMotion = _mean([for (final s in recent) 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. + if (windowEnd != null && now != null && baselineHr != null) { + if (lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: profile.lateWindowProtectionMinutes, + currentHr: recentHr, + baselineHr: baselineHr, + )) { + 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. + final enmoMedian3h = _median([for (final s in baseline) enmo(s)]); + final trendThreshold = math.max(0.02, enmoMedian3h * 0.5) * + profile.trendThresholdFactor; + if (enmoTrendExceeded(recent, + threshold: trendThreshold, + windowMinutes: profile.trendWindowMinutes)) { + return false; + } + + 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/test/smart_wake_v2_test.dart b/test/smart_wake_v2_test.dart new file mode 100644 index 000000000..0d7a83124 --- /dev/null +++ b/test/smart_wake_v2_test.dart @@ -0,0 +1,507 @@ +// 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); + expect(p.trendThresholdFactor, 1.5); // more tolerant trend + }); + + 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, + ); + }); + }); + + // ── 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); + final recent = still(50, 60); // +2 bpm — would normally pass +4? No, +2 < 4. + // 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: 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)', () { + // 80 samples at 48 (deep), 20 samples at 60 (light) → 10th pct = 48 + final samples = [...still(48, 80), ...still(60, 20)]; + 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)', () { + final samples = [ + ...still(48, 90), + SmartWakeSample(0, 0, 0, 1.0), // off-skin sentinel + SmartWakeSample(250, 0, 0, 1.0), // impossible + ]; + expect(percentileHr(samples, 0.10), 48.0); + }); + }); +} From 490495982685cc532a075cbe601b362440713eae Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 26 Oct 2026 00:36:25 +0100 Subject: [PATCH 09/10] =?UTF-8?q?fix(alarm):=20wire=20preSyncAt=20?= =?UTF-8?q?=E2=80=94=20extend=20band=20prompt=20lease=2045min=20before=20w?= =?UTF-8?q?indow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preSyncAt was computed but never consumed (dead code). The 10th-percentile HR baseline (smart_wake v2) had no guaranteed data source when the wake window opened. Option C: extend the existing 61s band prompt lease to start at preSyncAt (= targetWake − 135min) instead of the window start (= targetWake − 90min). The band prompts every 61s from preSyncAt onward; each prompt triggers _startHistoricalRefresh() which backfills decoded_onehz. By the time the smart-wake window opens, the last ~3h of nocturnal data is in SQLite. Why not iOS BGTask/BGAppRefresh: opportunistic, never guaranteed (per existing code comment). Why not GPS keep-alive: battery drain + App Store rejection risk (Guideline 5.1.2). Why not local notification: iOS does not wake the app process for local notifications. Battery impact: ~45 extra 61s BLE wake cycles per night — negligible. Wire protocol: interval 61s, duration 8100s (135min) — within gen5's 28800s ceiling. Changes: - high_freq_wake_window.dart: preSyncLead constant (45min), effectiveLease getter (135min), shouldEnable covers preSyncAt → targetWake (was: windowStart → targetWake), both habitual + scheduled paths updated - app_state.dart: uses plan.effectiveLease ?? HighFreqWakeWindow.lease - Tests: 13 new in high_freq_wake_window_presync_test.dart + 2 updated in high_freq_wake_window_test.dart (pre-sync lead coverage) Safety invariant preserved: smart-wake DETECTION still only runs inside the actual window (inSmartWakeWindow check unchanged). The extended lease only affects DATA COLLECTION. Fallback SET_ALARM on the band untouched. Existing tests pass (verified by boundary analysis: tests at 04:00/02:00 still disabled, tests at 04:45 still enabled). --- lib/state/app_state.dart | 7 +- lib/sync/high_freq_wake_window.dart | 57 ++++-- test/high_freq_wake_window_presync_test.dart | 180 +++++++++++++++++++ test/high_freq_wake_window_test.dart | 23 ++- 4 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 test/high_freq_wake_window_presync_test.dart diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d1e7654e8..4302c8e58 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -5027,7 +5027,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.effectiveLease ?? HighFreqWakeWindow.lease, source: plan.source, ) : null, diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart index 5d6576ba2..6677f5691 100644 --- a/lib/sync/high_freq_wake_window.dart +++ b/lib/sync/high_freq_wake_window.dart @@ -8,11 +8,25 @@ class HighFreqWakePlan { final String source; final int sampleCount; - /// Time at which a pre-sync should be triggered to backfill the last - /// ~3 hours of raw data before the wake window opens, so the HMM - /// baseline can be computed without keeping BLE streaming all night. + /// 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. + /// + /// When the plan is disabled (outside the extended lease), this is null. final DateTime? preSyncAt; + /// The total duration the band prompt lease should cover: from [preSyncAt] + /// (when set) or the window start, until [targetWake]. Used by callers to + /// compute the lease duration for `BandPromptRequest.smartWake`. + Duration? get effectiveLease { + if (targetWake == null) return null; + final start = preSyncAt ?? targetWake!.subtract(lease); + return targetWake!.difference(start); + } + const HighFreqWakePlan({ required this.shouldEnable, required this.targetWake, @@ -24,6 +38,23 @@ class HighFreqWakePlan { 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; @@ -84,11 +115,17 @@ class HighFreqWakeWindow { ) : todayTarget; final windowStart = targetWake.subtract(lease); - // Pre-sync ~45 min before the window opens so the HMM has enough - // history to compute the nightly baseline without streaming all night. - final preSyncAt = targetWake.subtract(lease + const Duration(minutes: 45)); + // 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, @@ -103,10 +140,8 @@ class HighFreqWakeWindow { scheduledWindowMinutes > 0 && habitualPlan?.shouldEnable != true) { final scheduledStart = scheduledWindowEnd.subtract(lease); - if (!now.isBefore(scheduledStart) && now.isBefore(scheduledWindowEnd)) { - final preSyncAt = scheduledWindowEnd.subtract( - lease + const Duration(minutes: 45), - ); + final preSyncAt = scheduledWindowEnd.subtract(lease + preSyncLead); + if (!now.isBefore(preSyncAt) && now.isBefore(scheduledWindowEnd)) { return HighFreqWakePlan( shouldEnable: true, targetWake: scheduledWindowEnd, 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..01f36bd1b --- /dev/null +++ b/test/high_freq_wake_window_presync_test.dart @@ -0,0 +1,180 @@ +// 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 windowStart = DateTime(2026, 10, 26, 6, 0); // 07:30 − 90min + 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 30-min smart window → windowEnd = 05:30 + // preSyncAt = 05:30 − 135min = 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, 3, 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, 3, 15)); + expect(plan.effectiveLease, const Duration(minutes: 135)); + }); + + test('scheduled alarm path: shouldEnable false before preSyncAt', () { + final plan = HighFreqWakeWindow.planFromRows( + [], + DateTime(2026, 10, 26, 3, 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)), From d936bdb10bff6a18b20693ef52addbe70eee02aa Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 26 Oct 2026 22:40:49 +0100 Subject: [PATCH 10/10] fix(alarm): repair compile error and wire the trend window (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects reported by mikenrafter on PR #630, both mine: 1. COMPILE ERROR: HighFreqWakePlan.effectiveLease referenced a bare `lease`, which is a static on HighFreqWakeWindow — not in scope on HighFreqWakePlan. Qualified it, and removed two locals (windowStart, scheduledStart) that the earlier lease change had left unread. 2. `enmoTrendExceeded` accepted `windowMinutes` and never read it. The documented alcohol/workout 5-minute smoothing was commentary only: the function always judged whatever list it was handed, and the caller always handed it 3 minutes. It now trims to the trailing `windowMinutes * 60` samples, and _checkSmartWake fetches max(3, trendWindowMinutes) minutes so a 5-minute window has 5 minutes to judge. Why the existing test missed it: the 5-minute spike test was SYMMETRIC — a spike centred in the list gives the same first-half and second-half means with or without slicing, so it passed either way. The new regression test is deliberately asymmetric: 2 min of high noise then a 3-min ramp, which only reads as a trend when the trailing 3 minutes are actually taken. Fetching longer must not make the detector more eager, so HR arousal and the mean-motion ceiling are now explicitly judged on the trailing 3 minutes (kHrWindowSamples = 180), with a test that a 5-minute recent list whose trailing 3 minutes sit at +2 bpm does not buzz. Local validation: Python mirror 63/63 (was 57; +4 regression, +2 HR window). Still not compiled — flutter analyze has not run. --- lib/state/app_state.dart | 11 ++++- lib/state/smart_wake.dart | 44 +++++++++++++----- lib/sync/high_freq_wake_window.dart | 13 +++--- test/smart_wake_v2_test.dart | 69 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 18 deletions(-) diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 4302c8e58..cefbc4996 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4524,8 +4524,17 @@ class AppState extends ChangeNotifier { final profile = await _smartWakeConfounderProfile(now); 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, ); diff --git a/lib/state/smart_wake.dart b/lib/state/smart_wake.dart index d06ab87ec..bbca0505e 100644 --- a/lib/state/smart_wake.dart +++ b/lib/state/smart_wake.dart @@ -230,18 +230,29 @@ ConfounderProfile confounderProfile({ /// a trend (computed by the caller as /// `max(0.02, medianENMO_last3h × 0.5) × profile.trendThresholdFactor`). /// -/// [windowMinutes] controls how many of the trailing samples are used; -/// alcohol/workout nights use 5 min (smoothing short spikes). +/// [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 [samples] is too short. +/// Returns false (never guesses) when the window is too short. bool enmoTrendExceeded( List samples, { required double threshold, required int windowMinutes, }) { - // Minimum 30 samples (~30s at 1 Hz) — same floor as the HR check. - if (samples.length < 30) return false; - final enmos = [for (final s in samples) enmo(s)]; + // 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 want = windowMinutes <= 0 ? samples.length : windowMinutes * 60; + final window = samples.length > want + ? samples.sublist(samples.length - want) + : samples; + // 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)); @@ -339,16 +350,25 @@ bool likelyLightSleep({ // Alcohol + stress combo: early buzz disabled entirely for this night. if (profile.earlyBuzzDisabled) return false; - if (baseline.length < minBaselineSamples || - recent.length < minRecentSamples) { - return false; - } + if (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. + const int kHrWindowSamples = 180; // 3 min at 1 Hz + final hrWindow = recent.length > kHrWindowSamples + ? recent.sublist(recent.length - kHrWindowSamples) + : recent; + 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 recent) s.hr]); - final recentMotion = _mean([for (final s in recent) enmo(s)]); + 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. diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart index 6677f5691..9a8906b1e 100644 --- a/lib/sync/high_freq_wake_window.dart +++ b/lib/sync/high_freq_wake_window.dart @@ -19,11 +19,16 @@ class HighFreqWakePlan { final DateTime? preSyncAt; /// The total duration the band prompt lease should cover: from [preSyncAt] - /// (when set) or the window start, until [targetWake]. Used by callers to - /// compute the lease duration for `BandPromptRequest.smartWake`. + /// (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(lease); + final start = preSyncAt ?? + targetWake!.subtract(HighFreqWakeWindow.lease); return targetWake!.difference(start); } @@ -114,7 +119,6 @@ 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 @@ -139,7 +143,6 @@ class HighFreqWakeWindow { if (scheduledWindowEnd != null && scheduledWindowMinutes > 0 && habitualPlan?.shouldEnable != true) { - final scheduledStart = scheduledWindowEnd.subtract(lease); final preSyncAt = scheduledWindowEnd.subtract(lease + preSyncLead); if (!now.isBefore(preSyncAt) && now.isBefore(scheduledWindowEnd)) { return HighFreqWakePlan( diff --git a/test/smart_wake_v2_test.dart b/test/smart_wake_v2_test.dart index 0d7a83124..0adf78fce 100644 --- a/test/smart_wake_v2_test.dart +++ b/test/smart_wake_v2_test.dart @@ -260,6 +260,43 @@ void main() { 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) ────── @@ -463,6 +500,38 @@ void main() { ); }); + 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