@@ -908,6 +908,8 @@ async function runValidation(
908908 }
909909
910910 const sleepSessionStart = new Date ( sleepStages [ 0 ] . startTime ) ;
911+ const validationRmssdHistory : number [ ] = [ ] ;
912+ let validationConsecutiveRemSignals = 0 ;
911913
912914 for ( const stageRecord of sleepStages ) {
913915 const stageStart = new Date ( stageRecord . startTime ) ;
@@ -928,26 +930,26 @@ async function runValidation(
928930 recentHRs . shift ( ) ;
929931 }
930932
931- const matchingHrv = hrvSamples . find ( ( hrv ) => {
932- const t = new Date ( hrv . time ) ;
933- return Math . abs ( t . getTime ( ) - hrTime . getTime ( ) ) < 60000 ;
934- } ) ;
933+ const rmssd = computeRmssd ( recentHRs ) ;
934+ validationRmssdHistory . push ( rmssd ) ;
935+ if ( validationRmssdHistory . length > MAX_RMSSD_HISTORY ) {
936+ validationRmssdHistory . shift ( ) ;
937+ }
935938
936- const predictedStage = classifyWithStatsInternal (
937- stageStats ,
938- transitionMatrix ,
939- hr . beatsPerMinute ,
940- matchingHrv ?. heartRateVariabilityMillis ?? null ,
941- prevStage ,
939+ const result = classifyWithStatsInternal (
942940 minutesSinceSleepStart ,
943- [ ...recentHRs ]
941+ validationRmssdHistory ,
942+ validationConsecutiveRemSignals ,
943+ prevStage ,
944+ recentHRs
944945 ) ;
945946
946- confusionMatrix [ actualStage ] [ predictedStage ] ++ ;
947- if ( actualStage === predictedStage ) correct ++ ;
947+ confusionMatrix [ actualStage ] [ result . stage ] ++ ;
948+ if ( actualStage === result . stage ) correct ++ ;
948949 total ++ ;
949950
950- prevStage = predictedStage ;
951+ prevStage = result . stage ;
952+ validationConsecutiveRemSignals = result . consecutiveRemSignals ;
951953 }
952954 }
953955
@@ -1020,83 +1022,49 @@ function getTimeBasedRemProbability(minutesSinceSleepStart: number): number {
10201022}
10211023
10221024function classifyWithStatsInternal (
1023- stageStats : Record < SleepStage3 , Stage3Statistics | null > ,
1024- transitionMatrix : Record < SleepStage3 , Record < SleepStage3 , number > > ,
1025- heartRate : number ,
1026- hrv : number | null ,
1027- prevStage : SleepStage3 ,
10281025 minutesSinceSleepStart : number ,
1026+ rmssdHistoryInput : number [ ] ,
1027+ prevConsecutiveRemSignals : number ,
1028+ prevStage : SleepStage3 ,
10291029 recentHRs : number [ ]
1030- ) : SleepStage3 {
1031- const STAGES : SleepStage3 [ ] = [ 'awake' , 'nrem' , 'rem' ] ;
1032-
1033- const localRmssd = computeRmssd ( recentHRs ) ;
1034- const localHRMean = recentHRs . length >= 3 ? mean ( recentHRs ) : heartRate ;
1035-
1036- const remRmssd = stageStats . rem ?. pseudoRMSSD ?? 3.0 ;
1037- const nremRmssd = stageStats . nrem ?. pseudoRMSSD ?? 4.3 ;
1038- const awakeRmssd = stageStats . awake ?. pseudoRMSSD ?? 8.6 ;
1030+ ) : { stage : SleepStage3 ; consecutiveRemSignals : number } {
1031+ const cv = computeCV ( rmssdHistoryInput ) ;
1032+ const timeRemProb = getTimeBasedRemProbability ( minutesSinceSleepStart ) ;
10391033
1040- const remNremThreshold = ( remRmssd + nremRmssd ) / 2 ;
1041- const nremAwakeThreshold = ( nremRmssd + awakeRmssd ) / 2 ;
1034+ const cvRemSignal = cv < CV_THRESHOLD ? 1.0 : 0.0 ;
1035+ const strongCvSignal = cv < CV_THRESHOLD * 0.7 ;
10421036
1043- let scores : number [ ] = [ 0 , 0 , 0 ] ;
1037+ const remScore = 0.5 * timeRemProb + 0.5 * cvRemSignal * 0.5 + ( strongCvSignal ? 0.15 : 0 ) ;
10441038
1045- for ( let i = 0 ; i < STAGES . length ; i ++ ) {
1046- const stage = STAGES [ i ] ;
1047- let score = 0 ;
1039+ let stage : SleepStage3 ;
1040+ let newConsecutiveRemSignals = prevConsecutiveRemSignals ;
10481041
1049- if ( stage === 'rem' ) {
1050- if ( minutesSinceSleepStart < FIRST_REM_LATENCY_MINUTES ) {
1051- score = 0.05 ;
1052- } else if ( localRmssd < remNremThreshold ) {
1053- const rmssdScore = 1 - localRmssd / remNremThreshold ;
1054- score = 0.4 + rmssdScore * 0.4 ;
1055- if ( prevStage === 'nrem' ) {
1056- score += 0.1 ;
1057- }
1058- } else {
1059- score = 0.15 ;
1060- }
1061- } else if ( stage === 'nrem' ) {
1062- if ( minutesSinceSleepStart < FIRST_REM_LATENCY_MINUTES ) {
1063- score = 0.65 ;
1064- } else if ( localRmssd >= remNremThreshold && localRmssd < nremAwakeThreshold ) {
1065- const rmssdScore =
1066- ( localRmssd - remNremThreshold ) / ( nremAwakeThreshold - remNremThreshold ) ;
1067- score = 0.4 + rmssdScore * 0.3 ;
1068- } else if ( localRmssd >= nremAwakeThreshold ) {
1069- score = 0.25 ;
1070- } else {
1071- score = 0.3 ;
1072- }
1042+ if ( minutesSinceSleepStart < 70 ) {
1043+ stage = 'nrem' ;
1044+ newConsecutiveRemSignals = 0 ;
1045+ } else if ( remScore > 0.25 ) {
1046+ newConsecutiveRemSignals ++ ;
1047+ if ( newConsecutiveRemSignals >= REM_CONSECUTIVE_REQUIRED ) {
1048+ stage = 'rem' ;
10731049 } else {
1074- if ( localRmssd >= nremAwakeThreshold ) {
1075- const rmssdScore = Math . min ( 1 , ( localRmssd - nremAwakeThreshold ) / nremAwakeThreshold ) ;
1076- score = 0.4 + rmssdScore * 0.3 ;
1077- } else if ( heartRate > localHRMean + 10 ) {
1078- score = 0.5 ;
1079- } else if ( minutesSinceSleepStart < 20 ) {
1080- score = 0.3 ;
1081- } else {
1082- score = 0.1 ;
1083- }
1050+ stage = 'nrem' ;
10841051 }
1052+ } else {
1053+ newConsecutiveRemSignals = 0 ;
1054+ const localHRStd = recentHRs . length >= 3 ? std ( recentHRs ) : 0 ;
10851055
1086- const transitionProb = transitionMatrix [ prevStage ] [ stage ] ;
1087- score += transitionProb * 0.15 ;
1088-
1089- scores [ i ] = score ;
1056+ if ( cv > 0.5 && localHRStd > 5 ) {
1057+ stage = 'awake' ;
1058+ } else {
1059+ stage = 'nrem' ;
1060+ }
10901061 }
10911062
1092- let bestIdx = 0 ;
1093- for ( let i = 1 ; i < scores . length ; i ++ ) {
1094- if ( scores [ i ] > scores [ bestIdx ] ) {
1095- bestIdx = i ;
1096- }
1063+ if ( prevStage === 'rem' && stage !== 'rem' && remScore > 0.15 ) {
1064+ stage = 'rem' ;
10971065 }
10981066
1099- return STAGES [ bestIdx ] ;
1067+ return { stage , consecutiveRemSignals : newConsecutiveRemSignals } ;
11001068}
11011069
11021070/**
0 commit comments