@@ -71,6 +71,38 @@ export function calcSleep(minutes: Minute[], baseline: Baseline): Metric<SleepVa
7171 // garbage (everything reads "awake"). Abstain until we have one.
7272 if ( rhr == null || rhr <= 0 ) return empty ( ) ;
7373
74+ // Per-window sleep-HR reference for the HR-dip awake override.
75+ //
76+ // The band's flash record (R24, the entire overnight) carries NO usable
77+ // actigraphy — its accel is a single 1 Hz gravity sample, so per-minute
78+ // `activity` reads ~0 and the Cole-Kripke score never clears its count-scaled
79+ // S<1 threshold. The awake/asleep call is therefore HR-driven in practice.
80+ // Anchoring "elevated → awake" to `rhr` is WRONG, because `rhr` is the
81+ // 5th-PERCENTILE sleep HR (a floor): normal/REM sleep HR legitimately runs
82+ // 20–40% above that floor, so a fixed 1.15*rhr cutoff flags almost every real
83+ // sleep minute awake (observed: 167/168 worn minutes on a real night, RHR 58 →
84+ // 67 bpm cutoff vs a 66–94 bpm sleeping band → 1-minute "sleep"). Instead anchor
85+ // the cutoff to THIS window's own depressed-HR level (a low percentile of worn
86+ // HR), with `rhr` as a floor so a window that is genuinely all-awake can't
87+ // manufacture a low reference. Percentiles are robust to the high-HR
88+ // evening/morning tail that shares the search window.
89+ const wornHr = sorted
90+ . filter ( ( m ) => m . wrist_on && m . hr_avg > 0 )
91+ . map ( ( m ) => m . hr_avg )
92+ . sort ( ( a , b ) => a - b ) ;
93+ const pctl = ( p : number ) =>
94+ wornHr . length ? wornHr [ Math . min ( wornHr . length - 1 , Math . floor ( p * wornHr . length ) ) ] : rhr ;
95+ const sleepHr = Math . max ( rhr , pctl ( 0.10 ) ) ; // the quiet sleep-HR level for this window
96+ const ASLEEP_HI = 1.05 ; // ≤ this × sleepHr → strong dip, nudge asleep
97+ const AWAKE_HI = 1.20 ; // > this × sleepHr → clearly elevated, nudge awake (clears REM)
98+ // Absolute backstop: HR at/above this × the RHR floor reads awake REGARDLESS of the
99+ // window. Without it, a flat + motion-inert window anchors sleepHr to its own level and
100+ // every minute falls under ASLEEP_HI*sleepHr → a sedentary-awake stretch (TV, desk)
101+ // would be mis-read as a full night. Real sleep, including REM, rarely SUSTAINS >1.5×
102+ // the 5th-percentile RHR floor, so this clips the false-positive without clipping sleep.
103+ // (The proper tie-breaker is actigraphy; R24 carries none today — see decode note.)
104+ const ABS_WAKE = 1.5 ;
105+
74106 // 1. Cole-Kripke score + HR-dip fusion → boolean asleep per epoch.
75107 const asleep : boolean [ ] = new Array ( n ) . fill ( false ) ;
76108 for ( let i = 0 ; i < n ; i ++ ) {
@@ -93,8 +125,9 @@ export function calcSleep(minutes: Minute[], baseline: Baseline): Metric<SleepVa
93125 let isAsleep = s < 1 ;
94126 // HR-dip fusion (only when we have a usable HR reading).
95127 if ( m . hr_avg > 0 ) {
96- if ( m . hr_avg < 0.95 * rhr ) isAsleep = true ; // strong dip → asleep
97- else if ( m . hr_avg > 1.15 * rhr ) isAsleep = false ; // clearly elevated → awake
128+ if ( m . hr_avg > ABS_WAKE * rhr ) isAsleep = false ; // absolute backstop → awake regardless
129+ else if ( m . hr_avg <= ASLEEP_HI * sleepHr ) isAsleep = true ; // at/below the sleep level → asleep
130+ else if ( m . hr_avg > AWAKE_HI * sleepHr ) isAsleep = false ; // clearly elevated → awake
98131 }
99132 asleep [ i ] = isAsleep ;
100133 }
0 commit comments