diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index d2f2880e2..0ee214343 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,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. -bool _isSleepStage(String s) => s == 'light' || s == 'deep' || s == 'rem'; + // 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 @@ -2285,3 +2288,21 @@ 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.map((e) => { + ...e, + "isContactLost": (e["motion_artifact"] == true) + }).toList()); + 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..2bdaacfa2 --- /dev/null +++ b/lib/compute/sleep_pipeline_hmm.dart @@ -0,0 +1,159 @@ +// 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 + ]; + + 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 > 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) { + 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}) { + if (isContactLost || hr <= 0 || rmssd <= 0 || hrMinNight == null || rmssdMeanNight == null) { + return log(0.25); + } + + final hrNorm = hr / hrMinNight!; + final rmssdNorm = rmssd / rmssdMeanNight!; + + double sigmoid(double x, double center, double scale) => 1.0 / (1.0 + exp(-(x - center) / scale)); + + double prob; + switch (state) { + case SleepState.wake: + prob = sigmoid(hrNorm, 1.2, 0.05) * (1.0 - sigmoid(rmssdNorm, 0.8, 0.05)); + break; + case SleepState.nremDeep: + prob = (1.0 - sigmoid(hrNorm, 1.0, 0.05)) * sigmoid(rmssdNorm, 1.1, 0.05); + break; + case SleepState.rem: + prob = sigmoid(hrNorm, 1.05, 0.05) * sigmoid(rmssdNorm, 1.0, 0.05); + break; + case SleepState.nremLight: + prob = 0.4; + break; + } + return log(max(0.01, 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/lib/data/db.dart b/lib/data/db.dart index ed954c51b..ad177d3e2 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 LocalDb._addColumnIfMissing(db, 'day_result', 'hr_min_night', 'REAL'); + await LocalDb._addColumnIfMissing(db, 'day_result', 'rmssd_mean_night', 'REAL'); +} 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/state/app_state.dart b/lib/state/app_state.dart index 2dbac2d31..cefbc4996 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,88 @@ 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; + + // The recent window is as long as the journal's trend window asks for + // (5 min on alcohol/workout nights, 3 min otherwise) so the movement + // trend has something to smooth over. `likelyLightSleep` re-slices the + // trailing 3 minutes for HR arousal and the mean-motion ceiling, so a + // longer fetch cannot move those two checks. + final recentWindowMinutes = profile.trendWindowMinutes > 3 + ? profile.trendWindowMinutes + : 3; final recentRows = await LocalDb.onehzHrAccelBetween( - now.subtract(const Duration(minutes: 3)).millisecondsSinceEpoch ~/ + now.subtract(Duration(minutes: recentWindowMinutes)) + .millisecondsSinceEpoch ~/ 1000, now.millisecondsSinceEpoch ~/ 1000, ); + + // Q2: 10th-percentile nightly HR baseline over the last ~3h (primary), + // 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 @@ -4890,7 +5036,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/state/smart_wake.dart b/lib/state/smart_wake.dart index 8319b4662..bbca0505e 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,344 @@ 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] is the trailing window the trend is judged on, and it is +/// genuinely applied: samples are 1 Hz, so the last `windowMinutes * 60` of +/// them are used. Alcohol/workout nights pass 5 (smoothing short rebound or +/// post-exercise spikes that a 3-minute window would read as a trend). A list +/// already shorter than the requested window is used whole — never padded. +/// +/// Returns false (never guesses) when the window is too short. +bool enmoTrendExceeded( + List samples, { + required double threshold, + required int windowMinutes, +}) { + // 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)); + 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, }) { - if (baseline.length < minBaselineSamples || - recent.length < minRecentSamples) { + // Alcohol + stress combo: early buzz disabled entirely for this night. + if (profile.earlyBuzzDisabled) 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 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. + 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; } - final baseHr = _median([for (final s in baseline) s.hr]); - final recentHr = _mean([for (final s in recent) s.hr]); - final recentMotion = _mean([for (final s in recent) _enmo(s)]); - return recentHr >= baseHr + hrArousalBpm && recentMotion < motionCeilingG; + + return true; } -double _enmo(SmartWakeSample s) => +// ── helpers ────────────────────────────────────────────────────────────────── + +/// ENMO — Euclidean Norm Minus One, in g. The magnitude of deviation from +/// 1g gravity: ~0 when still, rising with movement. +double enmo(SmartWakeSample s) => (math.sqrt(s.ax * s.ax + s.ay * s.ay + s.az * s.az) - 1.0).abs(); double _mean(List xs) => xs.reduce((a, b) => a + b) / xs.length; diff --git a/lib/sync/high_freq_wake_window.dart b/lib/sync/high_freq_wake_window.dart index 81a316a81..9a8906b1e 100644 --- a/lib/sync/high_freq_wake_window.dart +++ b/lib/sync/high_freq_wake_window.dart @@ -8,16 +8,58 @@ class HighFreqWakePlan { final String source; final int sampleCount; + /// Time at which the band prompt lease should start — [preSyncLead] before + /// the smart-wake window opens — so the app wakes periodically and backfills + /// `decoded_onehz` with the last ~3h of nocturnal data before the window + /// opens. The smart-wake DETECTION still only runs inside the actual window + /// (checked by `inSmartWakeWindow` in `_checkSmartWake`); this only extends + /// the DATA COLLECTION lease, not the buzz window. + /// + /// 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 shipped window start, until [targetWake]. Used by + /// callers to compute the lease duration for `BandPromptRequest.smartWake`. + /// + /// [HighFreqWakeWindow.lease] is qualified on purpose: this getter lives on + /// [HighFreqWakePlan], which does NOT inherit it — an unqualified `lease` + /// here does not resolve (it is a static on the other class). + Duration? get effectiveLease { + if (targetWake == null) return null; + final start = preSyncAt ?? + targetWake!.subtract(HighFreqWakeWindow.lease); + return targetWake!.difference(start); + } + const HighFreqWakePlan({ required this.shouldEnable, required this.targetWake, required this.source, required this.sampleCount, + this.preSyncAt, }); } class HighFreqWakeWindow { static const Duration lease = Duration(minutes: 90); + + /// Extra time the band prompt lease extends BEFORE the smart-wake window + /// opens, so the app wakes periodically and backfills `decoded_onehz` with + /// the last ~3h of nocturnal data. Without this, the 10th-percentile HR + /// baseline (smart_wake v2, Q2) has no data to compute from when the window + /// opens — the band was offline or the app was suspended all night. + /// + /// The band prompt is the ONLY reliable cross-platform wake mechanism at a + /// specific wall-clock time (iOS BGTask/BGAppRefresh are opportunistic and + /// never guaranteed; Android has no periodic background scheduler since the + /// WorkManager tombstone). Extending the existing 61s prompt lease 45 min + /// earlier costs ~45 extra BLE wake cycles per night — negligible battery + /// impact, and it reuses infrastructure that already works. + /// + /// preSyncAt = targetWake − (lease + preSyncLead). + static const Duration preSyncLead = Duration(minutes: 45); + static const int historyDays = 14; static const int minSamples = 3; @@ -77,12 +119,21 @@ class HighFreqWakeWindow { habitualWakeMinute % 60, ) : todayTarget; - final windowStart = targetWake.subtract(lease); + // Pre-sync lead: extend the band prompt lease 45 min before the window + // opens so the app wakes periodically and backfills decoded_onehz with + // the last ~3h of nocturnal data. The smart-wake DETECTION still only + // runs inside the actual window (inSmartWakeWindow check in + // _checkSmartWake); this only extends the DATA COLLECTION lease. + final preSyncAt = targetWake.subtract(lease + preSyncLead); + // shouldEnable covers the FULL extended lease (preSyncAt → targetWake), + // not just the smart-wake window. The band prompts continuously; each + // prompt triggers _startHistoricalRefresh() which fills decoded_onehz. habitualPlan = HighFreqWakePlan( - shouldEnable: !now.isBefore(windowStart) && now.isBefore(targetWake), + shouldEnable: !now.isBefore(preSyncAt) && now.isBefore(targetWake), targetWake: targetWake, source: 'habitual_wake', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } @@ -92,13 +143,14 @@ class HighFreqWakeWindow { if (scheduledWindowEnd != null && scheduledWindowMinutes > 0 && habitualPlan?.shouldEnable != true) { - final scheduledStart = scheduledWindowEnd.subtract(lease); - if (!now.isBefore(scheduledStart) && now.isBefore(scheduledWindowEnd)) { + final preSyncAt = scheduledWindowEnd.subtract(lease + preSyncLead); + if (!now.isBefore(preSyncAt) && now.isBefore(scheduledWindowEnd)) { return HighFreqWakePlan( shouldEnable: true, targetWake: scheduledWindowEnd, source: 'scheduled_alarm', sampleCount: wakeMinutes.length, + preSyncAt: preSyncAt, ); } } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index 2839b72d8..1ac896b2d 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -521,13 +521,23 @@ class _SleepDetailState extends State with RevisionReload { @override Widget build(BuildContext c) { + final disclaimer = Container( + padding: const EdgeInsets.all(8), + color: Colors.amber.withOpacity(0.1), + 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, + ), + ); 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()), ]); } 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)), 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); + }); +} diff --git a/test/smart_wake_v2_test.dart b/test/smart_wake_v2_test.dart new file mode 100644 index 000000000..0adf78fce --- /dev/null +++ b/test/smart_wake_v2_test.dart @@ -0,0 +1,576 @@ +// 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, + ); + }); + + // REGRESSION (review finding, mikenrafter on PR #630): windowMinutes used + // to be accepted and never read — the alcohol/workout 5-minute smoothing + // was documentation only. The symmetric spike test above could not catch + // that (it passes either way), so this one is deliberately ASYMMETRIC: + // 2 min of high noise followed by a 3-min ramp. Judged over the whole + // 5 minutes the ramp is invisible (the noisy prefix dominates the first + // half and the rise goes negative); judged over the trailing 3 minutes it + // is a clear trend. Only a genuinely applied window can tell them apart. + test('windowMinutes is actually applied (regression: dead parameter)', () { + final samples = [ + ...withEnmo(55, 0.10, 120), // 2 min flat, high + for (var i = 0; i < 180; i++) // 3 min ramp 0.02 → 0.08 + sample(55, 0.02 + (0.08 - 0.02) * i / 179), + ]; + expect(samples.length, 300); // 5 minutes at 1 Hz + // Trailing 3 minutes: a clear rise → exceeded. + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 3), + isTrue, + ); + // Full 5 minutes: the noisy prefix flattens it out → not exceeded. + expect( + enmoTrendExceeded(samples, threshold: 0.02, windowMinutes: 5), + isFalse, + ); + }); + + test('a list shorter than the requested window is used whole', () { + // 2 minutes of samples, 5-minute window requested → use all 120. + final short = withEnmo(55, 0.03, 60) + withEnmo(55, 0.09, 60); + // Judged whole: first half 0.03, second half 0.09 → rise 0.06. + expect( + enmoTrendExceeded(short, threshold: 0.02, windowMinutes: 5), + isTrue, + ); + }); + }); + + // ── lateWindowProtection (Q3 — deep-sleep protection near window end) ────── + + group('lateWindowProtection (Q3)', () { + final windowEnd = DateTime(2026, 10, 26, 6, 30); + + test('not in last 5 min → protection inactive', () { + final now = DateTime(2026, 10, 26, 6, 20); // 10 min before end + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 48, + baselineHr: 48, + ), + isFalse, + ); + }); + + test('in last 5 min AND HR near baseline → protection ACTIVE (no buzz)', () { + final now = DateTime(2026, 10, 26, 6, 28); // 2 min before end + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 49, // +1 bpm from baseline + baselineHr: 48, + ), + isTrue, + ); + }); + + test('in last 5 min but HR clearly elevated → protection inactive (buzz ok)', () { + final now = DateTime(2026, 10, 26, 6, 28); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: now, + protectionMinutes: 5, + currentHr: 56, // +8 bpm — clearly light sleep + baselineHr: 48, + ), + isFalse, + ); + }); + + test('HR within 2 bpm of baseline → protected; above 2 bpm → not', () { + final now = DateTime(2026, 10, 26, 6, 27); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 50, baselineHr: 48, // +2 bpm — boundary + ), + isTrue, // ≤2 bpm → protected + ); + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 51, baselineHr: 48, // +3 bpm + ), + isFalse, + ); + }); + + test('workout day (10-min protection) extends the zone', () { + final now = DateTime(2026, 10, 26, 6, 23); // 7 min before end + // 5-min protection: inactive (7 > 5) + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 5, + currentHr: 48, baselineHr: 48, + ), + isFalse, + ); + // 10-min protection: active (7 < 10) + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, now: now, protectionMinutes: 10, + currentHr: 48, baselineHr: 48, + ), + isTrue, + ); + }); + + test('exactly at window end → inactive (window check handles this)', () { + expect( + lateWindowProtectionActive( + windowEnd: windowEnd, + now: windowEnd, + protectionMinutes: 5, + currentHr: 48, + baselineHr: 48, + ), + isFalse, + ); + }); + }); + + // ── likelyLightSleep v2 (Q1+Q2+Q3+Q4 integrated) ────────────────────────── + + group('likelyLightSleep v2 (upgraded, with ConfounderProfile)', () { + test('backward compat: default profile = shipped behavior (HR+4, ENMO<0.08)', () { + final baseline = still(52, 90); + final recent = still(58, 60); // +6 bpm, no movement + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isTrue, + ); + }); + + test('backward compat: thin windows still → false', () { + expect( + likelyLightSleep(baseline: still(55, 5), recent: still(65, 60)), + isFalse, + ); + expect( + likelyLightSleep(baseline: still(55, 90), recent: still(65, 5)), + isFalse, + ); + }); + + test('v2: rising ENMO trend → false even if HR elevated and mean ENMO low', () { + final baseline = still(52, 90); + // Mean ENMO is low (~0.045) but rising sharply — person is turning over + final recent = rampEnmo(58, 0.02, 0.07, 60); + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isFalse, // trend exceeded → no buzz + ); + }); + + test('v2: with alcohol profile, +4 bpm is NOT enough (needs +6)', () { + final baseline = still(52, 90); + final recent = still(57, 60); // +5 bpm + final alcoholProfile = confounderProfile( + tags: {'alcohol'}, + metrics: {'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60)}, + ); + // Default: +5 ≥ 4 → would be true + expect(likelyLightSleep(baseline: baseline, recent: recent), isTrue); + // Alcohol profile: +5 < 6 → false + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: alcoholProfile), + isFalse, + ); + }); + + test('v2: alcohol+stress combo → always false (early buzz disabled)', () { + final baseline = still(52, 90); + final recent = still(65, 60); // +13 bpm, very elevated + final comboProfile = confounderProfile( + tags: {'alcohol', 'stress'}, + metrics: {'alcohol_units': JournalMetricValue(2, atMinuteOfDay: 22 * 60)}, + ); + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: comboProfile), + isFalse, // combo → no buzz regardless + ); + }); + + test('v2: late-window protection suppresses buzz when HR near baseline', () { + final baseline = still(48, 90); + 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: a 5-min recent fetch does not move the 3-min HR judgment', () { + // The caller now fetches up to the trend window (5 min on alcohol or + // workout nights). HR arousal must still be judged on the trailing 3 + // minutes — otherwise a longer fetch would silently make the detector + // more eager. + final baseline = still(52, 90); + final recent = [ + ...withEnmo(70, 0.03, 120), // first 2 min: elevated (would inflate a 5-min mean) + ...withEnmo(54, 0.03, 180), // last 3 min: only +2 bpm — below the 4 bpm bar + ]; + expect(recent.length, 300); + // Judged on the trailing 3 minutes → +2 bpm → no buzz. + expect( + likelyLightSleep(baseline: baseline, recent: recent), + isFalse, + ); + // Sanity: the same data judged as a 3-minute list is unchanged. + expect( + likelyLightSleep(baseline: baseline, recent: withEnmo(54, 0.03, 180)), + isFalse, + ); + // And the trailing 3 minutes WOULD buzz if they were elevated. + final recentHot = [ + ...withEnmo(50, 0.03, 120), + ...withEnmo(58, 0.03, 180), // +6 bpm in the trailing 3 min + ]; + expect( + likelyLightSleep(baseline: baseline, recent: recentHot), + isTrue, + ); + }); + + test('v2: stress profile tightens ENMO ceiling (0.07 passes default, fails stress)', () { + final baseline = still(52, 90); + // Mean ENMO 0.07 — passes default 0.08, fails stress 0.06 + final recent = withEnmo(58, 0.07, 60); + final stressProfile = confounderProfile(tags: {'stress'}, metrics: {}); + + expect(likelyLightSleep(baseline: baseline, recent: recent), isTrue); + expect( + likelyLightSleep(baseline: baseline, recent: recent, profile: stressProfile), + isFalse, + ); + }); + }); + + // ── 10th-percentile baseline helper ──────────────────────────────────────── + + group('percentileHr (baseline computation)', () { + test('10th percentile of flat HR = that HR', () { + final samples = still(52, 100); + expect(percentileHr(samples, 0.10), 52.0); + }); + + test('10th percentile picks the low end (deep-sleep HR)', () { + // 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); + }); + }); +}