Skip to content

Commit 36a7573

Browse files
authored
Merge pull request #6 from OpenStrap/fix/sleep-hr-dip-detection
fix(sleep): window-relative HR-dip detection + absolute awake backstop
2 parents 367cc15 + a3d9cc6 commit 36a7573

2 files changed

Lines changed: 58 additions & 2 deletions

File tree

src/__tests__/analytics.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,29 @@ console.log('--- §5 calcSleep ---');
217217
assert(ow.in_bed_min <= 210,
218218
`off-wrist: off-wrist stretch not counted as in-bed (got ${ow.in_bed_min})`);
219219

220+
// Regression (v3): the band's flash record (R24 — the entire overnight) carries NO
221+
// actigraphy, so `activity` is ~0 for every sleep minute and Cole-Kripke is inert; the
222+
// call is HR-driven. A night's HR legitimately runs ABOVE the 5th-pctile RHR floor, so
223+
// the old fixed `1.15*rhr` awake-override flagged the whole night awake → 1-min "sleep"
224+
// (observed in prod). The window-relative reference must detect it. rhr=50 floor, night
225+
// HR ~62 (well above 1.15*50=57.5), activity 0, bracketed by waking HR ~95.
226+
const aboveFloor: Minute[] = [];
227+
for (let i = 0; i < 30; i++) aboveFloor.push(min(i * 60, 95, 0)); // waking evening
228+
for (let i = 30; i < 430; i++) aboveFloor.push(min(i * 60, 62, 0)); // ~6.7h night, HR > RHR floor
229+
for (let i = 430; i < 470; i++) aboveFloor.push(min(i * 60, 95, 0)); // waking morning
230+
const af = calcSleep(aboveFloor, baseline);
231+
assert(af.duration_min >= 380,
232+
`above-floor night (HR>RHR, activity inert) is detected, not clipped to ~1min (got ${af.duration_min})`);
233+
234+
// Regression (v3): the OTHER failure mode — with activity inert, a flat + ELEVATED
235+
// sedentary-awake window must NOT be manufactured into a full night. The absolute
236+
// backstop (HR > 1.5*RHR → awake) clips it. Flat 92 bpm for 5h, rhr 50 → 92 > 75.
237+
const flatAwake: Minute[] = [];
238+
for (let i = 0; i < 300; i++) flatAwake.push(min(i * 60, 92, 0));
239+
const fa = calcSleep(flatAwake, baseline);
240+
assert(fa.duration_min <= 30,
241+
`flat elevated sedentary-awake window is not mis-read as sleep (got ${fa.duration_min})`);
242+
220243
// Plausibility guard: even if (pathologically) almost the whole 18h window
221244
// reads low-activity, a single main-sleep period must never exceed ~14h.
222245
const giant: Minute[] = [];

src/sleep.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)