Repository navigation
Fix/nap masking main sleep #639
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
aaaf4be
d67dde7
54f812a
45d8d5d
1329bf9
c0be18a
a7430f2
4439f88
4904959
d936bdb
0d00421
6d38739
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5725,9 +5725,29 @@ class DerivationEngine { | |
| await LocalDb.setSessionHrr(w.$1, w.$2); | ||
| } | ||
| final activityStore = ActivityStore(await LocalDb.instance); | ||
|
|
||
| // Integrate HMM-based sleep masking | ||
| final mainSleep = blocks.wake['sleep_periods']?['periods'] | ||
| ?.firstWhere((p) => p['is_main'] == true, orElse: () => null); | ||
|
|
||
| List<Map<String, dynamic>> reconciledNaps = blocks.napSuggestions; | ||
| if (mainSleep != null) { | ||
| final onset = (mainSleep['onset_ts'] as num).toInt(); | ||
| final offset = (mainSleep['wake_ts'] as num).toInt(); | ||
| reconciledNaps = napsWithoutMainSleep( | ||
| blocks.napSuggestions, | ||
| onset, | ||
| offset, | ||
| ) ?? blocks.napSuggestions; | ||
|
|
||
| if (reconciledNaps.length < blocks.napSuggestions.length) { | ||
| _log('masked ${blocks.napSuggestions.length - reconciledNaps.length} nap(s) overlapping main sleep [$onset, $offset)'); | ||
| } | ||
| } | ||
|
|
||
| final newSuggestions = [ | ||
| ...await activityStore.reconcile(ActivityKind.workout, blocks.suggestionsToPersist), | ||
| ...await activityStore.reconcile(ActivityKind.nap, blocks.napSuggestions), | ||
| ...await activityStore.reconcile(ActivityKind.nap, reconciledNaps), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium · Old false nap suggestions remain visible When an overlapping nap suggestion was persisted before main-sleep masking is applied, Remove or supersede previously stored nap suggestions that are omitted by the new filtered candidate list. Also at Prompt for AI agentsIn `lib/compute/derivation_engine.dart` at line 5744:
**Old false nap suggestions remain visible**
When an overlapping nap suggestion was persisted before main-sleep masking is applied, `ActivityStore.reconcile` processes only supplied candidates and does not remove omitted suggestions, so the previously stored false nap remains pending and visible.
Remove or supersede previously stored nap suggestions that are omitted by the new filtered candidate list.
Also at `lib/compute/derivation_engine.dart:5735-5739`.
Comment on lines
+5728
to
+5750
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ]; | ||
| for (final suggestion in newSuggestions) { | ||
| // The rows are already banked; a failed prompt must not mark the day partial. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Map<String, dynamic>> _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<int> tsSec, {int minGapSec = 1}) { | |
| ]..sort(); | ||
| return gaps.isEmpty ? null : gaps[gaps.length ~/ 2]; | ||
| } | ||
|
|
||
| List<String> _classifySleepStagesHMM(List<Map<String, dynamic>> 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(); | ||
| } | ||
|
Comment on lines
+2292
to
+2308
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<SleepState, Map<SleepState, double>> 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<Map<String, dynamic>> 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(); | ||||||||||||||||||||||||||
|
Comment on lines
+61
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Use the nullable form and skip the epoch. 🐛 Proposed fix
Committable suggestion
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+73
to
+76
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A missing baseline labels the whole night as When The caller cannot tell this apart from a real result. Total sleep time then collapses to zero without any indication. Expose whether a baseline exists, for example
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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<SleepState> viterbiDecode(List<Map<String, dynamic>> nightData) { | ||||||||||||||||||||||||||
| if (nightData.isEmpty) return []; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| final T = nightData.length; | ||||||||||||||||||||||||||
| final states = HMMConfig.states; | ||||||||||||||||||||||||||
| final N = states.length; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| final viterbi = List.generate(T, (_) => List<double>.filled(N, double.negativeInfinity)); | ||||||||||||||||||||||||||
| final backpointer = List.generate(T, (_) => List<int>.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); | ||||||||||||||||||||||||||
|
Comment on lines
+113
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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<SleepState>.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; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Read the main sleep period from
blocks.bundlePatch._computeDayBlockswritessleep_periodstobundlePatch. Thewakemap contains activity features, not sleep periods. This lookup therefore yields null, and every nap suggestion reaches reconciliation without the new mask. Readblocks.bundlePatch['sleep_periods']instead. (raw.githubusercontent.com)