diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 2610e7726..49b9248fb 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -35,6 +35,7 @@ import 'readiness_calibration_backfill.dart' import 'package:crypto/crypto.dart' show sha1; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'findings.dart'; import 'nap_overlap_cleanup.dart'; import 'nap_edits.dart'; @@ -3088,10 +3089,27 @@ class CompactResult { int get freedBytes => beforeBytes > afterBytes ? beforeBytes - afterBytes : 0; } +/// The experimental HMM sleep staging switch (Settings), default off. +const kSleepHmmStagesPref = 'sleep_hmm_stages'; + class DerivationEngine { DerivationEngine({this.log, this.background = false, this.offloadActive}); final void Function(String)? log; + /// EXPERIMENTAL, default off: re-stage the sleep window with the HMM + /// classifier inside [deriveDayBundle]. Read from the pref on every pass so + /// the foreground engine and every headless one agree, and a toggle takes + /// effect without a relaunch. Any failure reads as off. + static Future _hmmStagesOn() async { + try { + return (await SharedPreferences.getInstance()) + .getBool(kSleepHmmStagesPref) ?? + false; + } catch (_) { + return false; + } + } + /// True while a band offload is draining. The scheduler never STARTS a /// derive during one, but one can start mid-pass; the raw prune checks this /// before archiving each substrate bucket and stops for the pass, so a drain @@ -3351,6 +3369,7 @@ class DerivationEngine { ..sort(); final head = [ 'v${debugFingerprintAlgoVersion ?? kAlgoVersion}', + if (await _hmmStagesOn()) 'hmm', dataNowSec, // The edges a day is aged against (band and wearable; see // [_agingEdgeFor]), from the pass's own memo. @@ -5926,6 +5945,7 @@ class DerivationEngine { // guess — sleep-onset latency only means what people think it means on a // forced window. sleepSource: day.sleepSource, + useHmmStages: await _hmmStagesOn(), ); final withHistory = _attachHistory(input, history); diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 9f188d117..7fa41bdfe 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 @@ -415,6 +416,13 @@ class DayBundleInput { /// meaningful on a FORCED window — see `sol_sec` below. final String sleepSource; + /// EXPERIMENTAL, default OFF (PR #628 review): when true, the per-second + /// stage labels are re-derived by the HMM/Viterbi classifier + /// (`sleep_pipeline_hmm.dart`) from the sleep-window HR/RR substrate instead + /// of using the coordinator-supplied [hypnoStages]. Off by default so the + /// shipped heuristic stays the source of truth until the HMM is validated. + final bool useHmmStages; + const DayBundleInput({ required this.date, required this.dayTsSec, @@ -446,6 +454,7 @@ class DayBundleInput { this.deviceFamily, this.sleepSource = 'auto', this.stepSpans = const [], + this.useHmmStages = false, }); Map toJson() => { @@ -479,6 +488,7 @@ class DayBundleInput { 'day_flags': dayFlags, 'device_family': deviceFamily, 'sleep_source': sleepSource, + 'use_hmm_stages': useHmmStages, }; static DayBundleInput fromJson(Map m) { @@ -532,12 +542,72 @@ class DayBundleInput { dayFlags: strs('day_flags'), deviceFamily: m['device_family'] as String?, sleepSource: m['sleep_source'] as String? ?? 'auto', + useHmmStages: m['use_hmm_stages'] as bool? ?? false, stepSpans: [ for (final r in (m['step_spans'] as List? ?? const [])) [for (final v in (r as List)) (v as num).toInt()], ], ); } + + /// Copy with replaced per-second stage labels — used by the experimental + /// HMM re-staging path in [deriveDayBundle]. The stage-second totals in + /// [sleepJson] are recomputed from [stages] so the stored hypnogram and the + /// displayed totals always describe the same night. + DayBundleInput withHypnoStages(List stages) { + var light = 0, deep = 0, rem = 0, wake = 0; + for (final s in stages) { + switch (s) { + case 'light': light++; + case 'deep': deep++; + case 'rem': rem++; + case 'wake': wake++; + } + } + final nrem = light + deep; + final tst = nrem + rem; + final json = {...sleepJson}; + if (json.containsKey('tst_sec')) json['tst_sec'] = tst; + if (json.containsKey('waso_sec')) json['waso_sec'] = wake; + if (json.containsKey('nrem_sec')) json['nrem_sec'] = nrem; + if (json.containsKey('light_sec')) json['light_sec'] = light; + if (json.containsKey('deep_sec')) json['deep_sec'] = deep; + if (json.containsKey('rem_sec')) json['rem_sec'] = rem; + if (json.containsKey('wake_sec')) json['wake_sec'] = wake; + return DayBundleInput( + date: date, + dayTsSec: dayTsSec, + dayHr: dayHr, + dayRrTsMs: dayRrTsMs, + dayRrMs: dayRrMs, + sleepTsSec: sleepTsSec, + sleepHr: sleepHr, + sleepRrTsMs: sleepRrTsMs, + sleepRrMs: sleepRrMs, + sleepSkinTemp: sleepSkinTemp, + sleepJson: json, + hypnoStages: stages, + sleepOnsetSec: sleepOnsetSec, + sleepOffsetSec: sleepOffsetSec, + tonightSleepOnsetSec: tonightSleepOnsetSec, + profile: profile, + lnRmssdHistory: lnRmssdHistory, + lnRmssdDates: lnRmssdDates, + rhrHistory: rhrHistory, + respHistory: respHistory, + rmssdHistory: rmssdHistory, + skinTempAdcHistory: skinTempAdcHistory, + quietHrrHistory: quietHrrHistory, + readinessZHistory: readinessZHistory, + observedHrCeilingBpm: observedHrCeilingBpm, + dayConfidence: dayConfidence, + dayFlags: dayFlags, + stepSpans: stepSpans, + deviceFamily: deviceFamily, + sleepSource: sleepSource, + useHmmStages: useHmmStages, + ); + } } /// THE ISOLATE ENTRY POINT. @@ -546,7 +616,54 @@ class DayBundleInput { /// full derived bundle). Call directly + synchronously in tests, or via /// `Isolate.run(() => deriveDayBundle(input))` in production. Map deriveDayBundle(Map inputJson) { - final d = DayBundleInput.fromJson(inputJson); + var d = DayBundleInput.fromJson(inputJson); + + // EXPERIMENTAL HMM re-staging (default OFF — PR #628 review): when the + // input opts in, per-second labels are re-derived from the sleep-window + // HR/RR substrate by the Viterbi classifier instead of the coordinator's + // heuristic labels. RR intervals are windowed into per-second RMSSD via + // the same cleaned-NN helpers the pipeline itself uses; the decode is only + // applied when it covers the full window and has usable observations, + // otherwise the shipped labels stay. + if (d.useHmmStages && + d.hypnoStages.isNotEmpty && + d.sleepTsSec.isNotEmpty && + d.sleepTsSec.length == d.hypnoStages.length) { + final corrected = correctRr(d.sleepRrMs, rrTsMs: d.sleepRrTsMs); + // nnTimesMs is relative to the start of the first input beat; put it back + // on the epoch so it lines up with sleepTsSec. + final rrEpoch0 = d.sleepRrTsMs.isEmpty || d.sleepRrMs.isEmpty + ? 0.0 + : d.sleepRrTsMs.first.toDouble() - d.sleepRrMs.first; + final nnTimes = [for (final t in corrected.nnTimesMs) t + rrEpoch0]; + // Per-second RMSSD: 60 s trailing window of cleaned NN intervals, + // stamped at the window's last beat second. Missing coverage -> 0 + // (scored as neutral/unusable by the classifier). + final rmssdPerSec = _rmssdPerSecond( + corrected.nn, + nnTimes, + d.sleepTsSec, + windowSec: 60, + ); + final epochs = >[ + for (var i = 0; i < d.sleepTsSec.length; i++) + { + 'ts': d.sleepTsSec[i], + 'hr': i < d.sleepHr.length ? d.sleepHr[i] : 0, + 'rmssd': rmssdPerSec[i], + 'motion_artifact': false, + }, + ]; + final hmmStages = _classifySleepStagesHMM(epochs); + if (hmmStages != null && hmmStages.length == d.hypnoStages.length) { + // A second the band did not watch stays unobserved; the decoder only + // relabels seconds that were actually measured. + d = d.withHypnoStages([ + for (var i = 0; i < hmmStages.length; i++) + d.hypnoStages[i] == 'unobserved' ? 'unobserved' : hmmStages[i], + ]); + } + } // ── HR over the DAY (for the curve, strain zones, dip day-side) ─────────── final dayHr = [for (final h in d.dayHr) h.toDouble()]; @@ -2346,3 +2463,103 @@ int? _medianGapSec(List tsSec, {int minGapSec = 1}) { ]..sort(); return gaps.isEmpty ? null : gaps[gaps.length ~/ 2]; } + +/// Per-second RMSSD over the sleep window: for each second in [tsSec], the +/// RMSSD of cleaned NN intervals whose beat-end falls within the trailing +/// [windowSec] seconds. Seconds with no beat coverage get 0.0 (the HMM +/// treats that as an unusable observation, never as a measurement). +List _rmssdPerSecond( + List nn, + List nnTimesMs, + List tsSec, { + required int windowSec, +}) { + final out = List.filled(tsSec.length, 0.0); + if (nn.isEmpty || nnTimesMs.isEmpty || tsSec.isEmpty) return out; + var lo = 0; + var hi = 0; + for (var i = 0; i < tsSec.length; i++) { + final secMs = tsSec[i] * 1000.0; + while (lo < nn.length && nnTimesMs[lo] < secMs - windowSec * 1000) { + lo++; + } + while (hi < nn.length && nnTimesMs[hi] <= secMs) { + hi++; + } + if (hi - lo < 2) continue; + final window = nn.sublist(lo, hi); + var sumSq = 0.0; + for (var k = 1; k < window.length; k++) { + final d = window[k] - window[k - 1]; + sumSq += d * d; + } + out[i] = math.sqrt(sumSq / (window.length - 1)); + } + return out; +} + +/// EXPERIMENTAL HMM/Viterbi re-staging (gated by [DayBundleInput.useHmmStages]). +/// +/// [epochData] is per-second sleep-window records with `ts` (epoch sec), `hr`, +/// and `rmssd` (already windowed/cleaned, see [deriveDayBundle]). Returns +/// per-second stage labels, or null when the night has too few usable +/// observations to stage honestly — the caller then keeps the supplied labels. +List? _classifySleepStagesHMM(List> epochData) { + if (epochData.isEmpty) return null; + final analyzer = SleepStageAnalyzer(); + // Baseline from the first 120 minutes BY ELAPSED TIME, not the first 240 + // rows — per-second rows at 1 Hz would otherwise cover only ~4 minutes. + final t0 = (epochData.first['ts'] as num?)?.toDouble() ?? 0.0; + final first120m = epochData + .where((e) => ((e['ts'] as num?)?.toDouble() ?? 0.0) - t0 <= 120 * 60) + .toList(); + analyzer.computeBaseline(first120m); + // An unmeasured night must not decode as wake: with no usable observation + // every state ties and wake's self-transition wins. Bail out instead. + final usable = epochData.where((e) { + final hr = (e['hr'] as num?)?.toDouble() ?? 0.0; + final rmssd = (e['rmssd'] as num?)?.toDouble() ?? 0.0; + return hr > 30.0 && hr < 200.0 && rmssd > 0.0 && rmssd < 500.0; + }).length; + if (usable < 30) return null; + // The transition table is per 30 s scoring epoch (AASM epoch length), so + // decode 30 s epochs, not 1 Hz rows: per row, wake's 0.90 self-transition + // would mean a ~10 s mean dwell. Each epoch averages its plausible HR and + // RMSSD; an epoch with none gets 0 (the neutral emission). + final epochOf = [ + for (final e in epochData) + ((((e['ts'] as num?)?.toDouble() ?? t0) - t0) ~/ 30).toInt(), + ]; + final epochs = >[]; + final idx = {}; + for (var i = 0; i < epochData.length; i++) { + final k = idx.putIfAbsent(epochOf[i], () { + epochs.add({'hr': 0.0, 'rmssd': 0.0, 'nh': 0, 'nr': 0}); + return epochs.length - 1; + }); + final hr = (epochData[i]['hr'] as num?)?.toDouble() ?? 0.0; + final rmssd = (epochData[i]['rmssd'] as num?)?.toDouble() ?? 0.0; + final ep = epochs[k]; + if (hr > 30.0 && hr < 200.0) { + ep['hr'] = (ep['hr'] as double) + hr; + ep['nh'] = (ep['nh'] as int) + 1; + } + if (rmssd > 0.0 && rmssd < 500.0) { + ep['rmssd'] = (ep['rmssd'] as double) + rmssd; + ep['nr'] = (ep['nr'] as int) + 1; + } + } + for (final ep in epochs) { + final nh = ep['nh'] as int, nr = ep['nr'] as int; + ep['hr'] = nh == 0 ? 0.0 : (ep['hr'] as double) / nh; + ep['rmssd'] = nr == 0 ? 0.0 : (ep['rmssd'] as double) / nr; + } + final states = analyzer.viterbiDecode(epochs); + String label(SleepState s) => switch (s) { + SleepState.wake => 'wake', + SleepState.nremLight => 'light', + SleepState.nremDeep => 'deep', + SleepState.rem => 'rem', + }; + return [for (var i = 0; i < epochData.length; i++) label(states[idx[epochOf[i]]!])]; +} diff --git a/lib/compute/sleep_pipeline_hmm.dart b/lib/compute/sleep_pipeline_hmm.dart new file mode 100644 index 000000000..c7f526fca --- /dev/null +++ b/lib/compute/sleep_pipeline_hmm.dart @@ -0,0 +1,182 @@ +// 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 } + +// The transition table and the emission sigmoid centres/scales below are +// hand-set ESTIMATES (per 30 s epoch), not fitted to or validated against PSG. +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; + + // The same plausibility window decides which samples form the baseline and + // which observations get scored. A value outside it (a 250 bpm spike, a NaN + // from a dropped beat) is a measurement fault, not physiology, and scoring it + // would push the decoder towards wake. + static bool _plausibleHr(double? h) => h != null && h.isFinite && h > 30.0 && h < 200.0; + static bool _plausibleRmssd(double? r) => r != null && r.isFinite && r > 0.0 && r < 500.0; + + void computeBaseline(List> first120minData) { + if (first120minData.isEmpty) return; + final hrs = first120minData + .map((d) => (d['hr'] as num?)?.toDouble()) + .where(_plausibleHr) + .cast() + .toList(); + final rmssds = first120minData + .map((d) => (d['rmssd'] as num?)?.toDouble()) + .where(_plausibleRmssd) + .cast() + .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 || + !_plausibleHr(hr) || + !_plausibleRmssd(rmssd) || + 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, + isContactLost: nightData[0]['isContactLost'] == true); + } + + 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; + final contactLost = nightData[t]['isContactLost'] == true; + for (var j = 0; j < N; j++) { + final nextState = states[j]; + final emission = + _getEmissionLogProb(nextState, hr, rmssd, isContactLost: contactLost); + 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/l10n/app_de.arb b/lib/l10n/app_de.arb index 44243e8b5..7eae9f084 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -3470,5 +3470,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "Experimentelles Schlaf-Staging (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "Forschungsalgorithmus, kein Medizinprodukt. Standard aus; gilt ab dem nächsten Sync", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 318ea72f4..186bd4903 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -16323,5 +16323,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "Experimental sleep staging (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "Research algorithm, not a medical device. Off by default; applies from the next sync", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 33d240dc7..1597044de 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2890,5 +2890,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "Clasificación del sueño experimental (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "Algoritmo de investigación, no un dispositivo médico. Desactivado por defecto; se aplica desde la próxima sincronización", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0480f1839..ebfa49d1e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "Stadification du sommeil expérimentale (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "Algorithme de recherche, pas un dispositif médical. Désactivé par défaut ; s'applique dès la prochaine synchronisation", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 72d1c60b2..a74cbf5c6 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "प्रायोगिक नींद चरण विश्लेषण (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "यह एक शोध एल्गोरिदम है, चिकित्सा उपकरण नहीं। यह डिफ़ॉल्ट रूप से बंद है और अगली सिंक से लागू होगा।", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8e9e0e79e..032f0adaa 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7902,5 +7902,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "Экспериментальное определение стадий сна (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "Исследовательский алгоритм, не медицинское устройство. Выключено по умолчанию; применяется со следующей синхронизации", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 798460b54..112909c44 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16323,5 +16323,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "การแบ่งระยะการนอนแบบทดลอง (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "อัลกอริทึมเพื่อการวิจัย ไม่ใช่อุปกรณ์การแพทย์ ปิดไว้โดยค่าเริ่มต้น มีผลตั้งแต่การซิงค์ครั้งถัดไป", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 87bce6945..53d58013e 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,13 @@ "type": "int" } } + }, + "settingsSleepHmmRowTitle": "实验性睡眠分期 (HMM)", + "@settingsSleepHmmRowTitle": { + "description": "Title of the experimental HMM sleep staging toggle in Settings." + }, + "settingsSleepHmmRowSub": "研究算法,非医疗设备。默认关闭;下次同步后生效", + "@settingsSleepHmmRowSub": { + "description": "Subtitle of the experimental HMM sleep staging toggle in Settings." } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index b581d5318..85822f2b9 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -569,6 +569,7 @@ class AppState extends ChangeNotifier { // truth for every network call — announcements, OTA, telemetry, import). healthSyncEnabled = prefs.getBool(_kHealthSync) ?? false; phoneStepsEnabled = prefs.getBool(_kPhoneSteps) ?? false; + sleepHmmStagesEnabled = prefs.getBool(kSleepHmmStagesPref) ?? false; // Steps only exist if a real pedometer measured them, so kick the phone // pull early. This is BEST-EFFORT and establishes no ordering: it is // unawaited, so a derive pass can read `live_coverage` while the sync is @@ -791,6 +792,18 @@ class AppState extends ChangeNotifier { // switch from callers that never see this class. static const String _kHealthSync = kHealthSyncPref; + /// EXPERIMENTAL, default off: the derivation engine re-stages the sleep + /// window with the HMM classifier (it reads the pref itself on every pass). + bool sleepHmmStagesEnabled = false; + + /// Toggle the experimental HMM sleep staging; applies from the next derive. + Future setSleepHmmStages(bool on) async { + sleepHmmStagesEnabled = on; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(kSleepHmmStagesPref, on); + notifyListeners(); + } + /// "Apple Health" (iOS) or "Health Connect" (Android). String get healthStoreName => HealthExporter.storeName; bool get healthIsApple => HealthExporter.isApple; @@ -1410,6 +1423,7 @@ class AppState extends ChangeNotifier { telemetryConsent = false; healthShareConsent = false; consentChosen = false; + sleepHmmStagesEnabled = false; TelemetryService.instance.applyConsent(false); HealthUploader.instance.deviceId = null; // maybeUpload bails without one deviceId = ''; diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 81f088ecf..13e3e5b32 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -215,6 +215,7 @@ class _MoreSettingsState extends State onRequestBackgroundExempt: app.requestIgnoreBatteryOptimizations, onOpenOemAutostart: app.openOemAutostartSettings, phoneSteps: app.phoneStepsEnabled, + sleepHmm: app.sleepHmmStagesEnabled, telemetry: app.telemetryConsent, barcodeLookup: _barcode, // Shown when the build has the feature OR when this install already @@ -253,6 +254,8 @@ class _MoreSettingsState extends State onTogglePhoneSteps: () => app.phoneStepsEnabled ? app.disablePhoneSteps() : app.requestPhoneSteps(), + onToggleSleepHmm: () => + app.setSleepHmmStages(!app.sleepHmmStagesEnabled), onToggleTelemetry: () => app.setTelemetryConsent(!app.telemetryConsent), onToggleBarcodeLookup: () => _toggleBarcode(c), onToggleHealthShare: () => _toggleHealthShare(c, app), @@ -573,6 +576,9 @@ class MoreSettingsView extends StatelessWidget { final ClockFormat clockFormat; final bool phoneSteps, telemetry, barcodeLookup, cycleTracking; + /// EXPERIMENTAL HMM sleep staging (PR #628 review) — off by default. + final bool sleepHmm; + /// The live-workout HR-zone-crossing haptic. Off by default; [zoneAlertZone] /// (1..5) is only meaningful — and only drawn — while this is on. final bool zoneAlertEnabled; @@ -634,6 +640,7 @@ class MoreSettingsView extends StatelessWidget { onCycleAppearance, onCycleClockFormat, onTogglePhoneSteps, + onToggleSleepHmm, onToggleTelemetry, onToggleBarcodeLookup, onToggleCycleTracking, @@ -656,6 +663,7 @@ class MoreSettingsView extends StatelessWidget { this.onRequestBackgroundExempt, this.onOpenOemAutostart, this.phoneSteps = false, + this.sleepHmm = false, this.healthSync = false, this.healthState = HealthLinkState.unknown, this.healthStore = 'Apple Health', @@ -684,6 +692,7 @@ class MoreSettingsView extends StatelessWidget { this.onCycleAppearance, this.onCycleClockFormat, this.onTogglePhoneSteps, + this.onToggleSleepHmm, this.onToggleTelemetry, this.onToggleBarcodeLookup, this.onToggleCycleTracking, @@ -787,6 +796,15 @@ class MoreSettingsView extends StatelessWidget { 'What may interrupt you, quiet hours, and off ' 'switches for all of them', onTap: onNotifications), + SetRow(LucideIcons.moonStar, C.indigo, + l?.settingsSleepHmmRowTitle ?? + 'Experimental sleep staging (HMM)', + sub: l?.settingsSleepHmmRowSub ?? + 'Research algorithm, not a medical device. Off by ' + 'default; applies from the next sync', + value: sleepHmm ? on : off, + chevron: false, + onTap: onToggleSleepHmm), SetRow(LucideIcons.ruler, C.blue, l?.settingsUnitsRowTitle ?? 'Units', value: units, onTap: onCycleUnits), diff --git a/test/sleep_cycle_continuity_test.dart b/test/sleep_cycle_continuity_test.dart new file mode 100644 index 000000000..f715bed6e --- /dev/null +++ b/test/sleep_cycle_continuity_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/compute/sleep_pipeline_hmm.dart'; + +void main() { + hmmPipelineMain(); + 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); + }); + + group('observations the decoder cannot trust', () { + SleepStageAnalyzer baselined() { + final a = SleepStageAnalyzer(); + a.computeBaseline(List.generate(240, (_) => {'hr': 60.0, 'rmssd': 40.0})); + return a; + } + + List> deepAround(List> middle) => [ + ...List.generate(20, (_) => {'hr': 55.0, 'rmssd': 50.0}), + ...middle, + ...List.generate(20, (_) => {'hr': 55.0, 'rmssd': 50.0}), + ]; + + test('a contact-lost stretch is bridged, not scored as wake', () { + final night = deepAround(List.generate( + 10, (_) => {'hr': 95.0, 'rmssd': 12.0, 'isContactLost': true})); + final states = baselined().viterbiDecode(night); + for (var t = 20; t < 30; t++) { + expect(states[t], SleepState.nremDeep, reason: 'epoch $t'); + } + }); + + test('implausible and non-finite HR get the neutral emission', () { + for (final bad in [250.0, double.nan, double.infinity]) { + final night = deepAround( + List.generate(10, (_) => {'hr': bad, 'rmssd': 12.0})); + final states = baselined().viterbiDecode(night); + for (var t = 20; t < 30; t++) { + expect(states[t], SleepState.nremDeep, reason: 'hr=$bad epoch $t'); + } + } + }); + + test('the same values without the flag still read as wake', () { + final night = deepAround( + List.generate(10, (_) => {'hr': 95.0, 'rmssd': 12.0})); + final states = baselined().viterbiDecode(night); + expect(states[25], SleepState.wake); + }); + + test('baseline skips missing and non-finite values instead of throwing', () { + final a = SleepStageAnalyzer(); + a.computeBaseline([ + {'hr': null, 'rmssd': 40.0}, + {'rmssd': 40.0}, + {'hr': double.nan, 'rmssd': double.nan}, + ...List.generate(20, (_) => {'hr': 60.0, 'rmssd': 40.0}), + ]); + expect(a.hrMinNight, 60.0); + expect(a.rmssdMeanNight, 40.0); + }); + }); +} + +/// The opt-in path through [deriveDayBundle]: a quiet first half and an +/// aroused second half must re-stage (the decoder actually ran on epoch-aligned +/// beats), and a second the band did not watch stays unobserved. +void hmmPipelineMain() { + test('opt-in HMM re-stages a measured night and keeps unobserved seconds', () { + const t0 = 1780000000; + const n = 4 * 3600; + final tsSec = [for (var s = 0; s < n; s++) t0 + s]; + final hr = [for (var s = 0; s < n; s++) s < n ~/ 2 ? 55 : 80]; + final rrTs = [], rrMs = []; + var t = t0 * 1000.0; + var i = 0; + while (t < (t0 + n) * 1000.0) { + final aroused = t >= (t0 + n ~/ 2) * 1000.0; + final rr = aroused + ? 750.0 + 2.0 * ((i % 3) - 1) + : 1090.0 + 40.0 * ((i % 5) - 2); + t += rr; + rrTs.add(t); + rrMs.add(rr); + i++; + } + final stages = List.filled(n, 'light'); + for (var s = 100; s < 160; s++) { + stages[s] = 'unobserved'; + } + Map run(bool hmm) => deriveDayBundle(DayBundleInput( + date: '2026-06-01', + dayTsSec: tsSec, + dayHr: hr, + sleepTsSec: tsSec, + sleepHr: hr, + sleepRrTsMs: rrTs, + sleepRrMs: rrMs, + sleepSkinTemp: List.filled(n, 0), + sleepJson: const {}, + hypnoStages: stages, + sleepOnsetSec: t0, + sleepOffsetSec: t0 + n, + profile: const {'age': 30, 'sex': 'm', 'weight': 75, 'height': 178}, + useHmmStages: hmm, + ).toJson()); + List seq(Map b) => [ + for (final s in ((b['series'] as Map)['hypnogram'] as List).cast()) + s['stage'] as String, + ]; + expect(seq(run(false)).toSet(), {'light', 'unobserved'}); + final on = seq(run(true)); + expect(on, contains('unobserved')); + expect(on.toSet().difference({'light', 'unobserved'}), isNotEmpty); + }); +}