Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion lib/compute/onehz_pipeline.dart
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
159 changes: 159 additions & 0 deletions lib/compute/sleep_pipeline_hmm.dart
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();

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<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);
}

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;
}
}
9 changes: 9 additions & 0 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 '
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -13285,3 +13289,8 @@ List<String?> _reencodeBatch(List<String> payloads) {
}
return out;
}

Future<void> _ensureSleepBaselineColumns(Database db) async {
await LocalDb._addColumnIfMissing(db, 'day_result', 'hr_min_night', 'REAL');
await LocalDb._addColumnIfMissing(db, 'day_result', 'rmssd_mean_night', 'REAL');
}
4 changes: 4 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Loading
Loading