You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Sleep presentation: stitch short mid-night wakes into one displayed night (+ motion-aware wake classification) #364
The presentation. When the sleep detector sees a sustained mid-night wake, it ends the session and starts a new one. A real example: a 16-minute wake at ~3 AM turns one night into two sessions — the sleep screen headlines the main block ("3h 05m") with a second 1h17m entry below it, and the Apple Health write-back exports two separate sleep entries. The daily totals are correct (both blocks are counted), but the presentation reads as "two sleeps" where a person would say "one bad night."
Prior art. WHOOP's own app treats a night as one sleep activity: brief wakes are folded in as awake time (disturbances) within the night, and only genuinely separate sleep (naps, long gaps) splits. Oura and Apple Watch similarly render one night with awake segments in the hypnogram.
Proposal 1 — night-stitch at the presentation layer. Merge sessions separated by a short wake gap (strawman: ≤ 20–30 min) into one displayed night whose hypnogram carries the gap as an wake segment — in the Sleep/Today screens and in the Apple Health export (one .inBed span + stages, matching what the other vendors write). Stored sessions could stay split (detector output untouched) with the merge purely at read/display time, or the detector could emit one session — maintainer preference.
Proposal 2 — motion-aware wake classification (the data is already collected). For the same example night, the type-47 streams answer "did they get up?" directly: zero stepSample rows in the gap, and gravitySample shows the wrist held the same lying orientation throughout (vs. a clearly different, motionless orientation once re-asleep). So wake segments could be sub-classified — restless in bed vs out of bed — from data every 5.0/MG strap already streams. That label would (a) make the hypnogram more honest, (b) give the stitch rule a principled input (an in-bed wake is "same night" almost by definition; an out-of-bed wake might still stitch but under a different threshold), and (c) match how users actually remember their nights.
Questions for maintainers: Is the stitch wanted at display-time only, or in the detector? What gap threshold (fixed vs. motion-informed)? Should the Apple Health export follow the stitched night (one entry) or the stored sessions (two)? Happy to build whichever shape is wanted — this is adjacent to the recent sleep-merge precedence work (#241), so flagging before writing code.
Correction to the example in Proposal 2: on closer inspection with wrap-aware step-counter decoding (stepSample.counter is a cumulative wrapping u16, not per-row steps — an earlier naive query read it as zero), the example night's 16-minute wake was actually an out-of-bed trip: ~32 counter ticks with a posture change at one point in the gap, ~7 more walking back, then a new still orientation. So the motion data distinguishes the two cases even more sharply than the original text claimed — but the correct label for that example is out of bed, not restless in bed. Strengthens the case for doing this classification once, in shared code, rather than ad-hoc: the counter-wrap subtlety is easy to get wrong (I did).
Data point on the "should the Apple Health export follow the stitched night?" question: probably no change needed on the export side. For the example night, Health receives the two split entries but its own day view already renders them as one night — continuous stage timeline with a visible gap, and the daily summary sums both blocks (time in bed / time asleep both correct). So the raw split entries are a good export shape as-is, and the stitch would only need to live in NOOP's own sleep display (headline duration + session list), which narrows the proposal to a presentation-layer change.
Thanks — really well-framed, and the prior art is right (WHOOP/Oura/Apple Watch all render one night with the wake folded in). One thing worth knowing before you write code, because it makes this much smaller than a from-scratch stitch: NOOP already bridges wake gaps into one night — the gap is just that a couple of consumers don't use that bridge yet.
What already exists
SleepStageTotals.mainNightGroupIndices (#561 / #525 / #861) bridges fragments separated by a wake gap into a single main-night group, with gapBridgeMaxMin = 60 (plus a 90-min night-tail bridge). So:
Your 16-min wake is well inside the 60-min bridge — the fragments are already grouped as one night for scoring, which is exactly why you see the daily totals come out correct.
It's already read-time (sessions stay split in storage, bridged on read), and the threshold is already 60 min — so the ≤20–30 min strawman is actually more conservative than what ships today.
The actual gap (two consumers that still read raw sessions)
Apple Health write-back: HealthKitBridge.writeSleep(sessions:) iterates the raw stored sessions, so it emits one HealthKit entry per fragment → the two-entry export you're seeing.
Display: the Sleep screen surfaces the second fragment as its own card rather than a wake segment inside the one night.
Recommended shape (answers your three questions)
Reuse the existing bridge rather than adding new stitch logic or a new threshold:
Gap threshold: reuse gapBridgeMaxMin (60 min). A separate 20–30 min rule would fragment the logic and break the cross-platform parity the shared bridge gives us.
Apple Health: one.inBed span per bridged night, with the gap as an in-hypnogram wake segment — matching the other vendors. Both platforms (iOS HealthKit + Android Health Connect).
So the concrete work is: make writeSleep group by mainNightGroupIndices and emit one span (+ the awake gap in the stages), and render the secondary fragment as a wake segment on the Sleep screen instead of a separate card. Small, parity-safe (the bridge is already shared), and it leaves the detector + stored sessions untouched.
Proposal 2 (motion-aware wake classification) — great, but a follow-up
The catch is gravitySample reliability: on a WHOOP 4.0 the gravity stream is often too sparse to tell "restless in bed" from "out of bed" (this is the same sparse-motion limitation behind the recent 4.0 sleep-staging work). So motion-aware classification would be a 5.0/MG-leaning refinement that can't be trusted on 4.0 — worth doing later, honestly labelled, but v1 doesn't need it since the 60-min bridge already decides "same night."
If you're up for it, the display + export routed through mainNightGroupIndices is the exact narrow shape wanted here — happy to review a PR of that.
⚠️ Heads-up for anyone picking this up — there's a number collision to be aware of. Several BLE sites in the codebase reference "#364" but mean a different thing than this issue:
So when implementing this, don't wire anything near the BLE #364 sites — they're the sync subsystem, not sleep staging.
The actual work here stays as scoped above: route the display + Apple Health write-back through the existing SleepStageTotals.mainNightGroupIndices 60-min wake-gap bridge (which already folds fragments into one night for scoring — hence the correct daily totals), rather than adding new stitch logic. Sessions stay split in storage; the merge is read-time. Motion-aware wake classification stays a follow-up (WHOOP 4.0 gravity is too sparse to rely on).
Real-world data point for Proposal 2 (motion-aware wake classification), from a 5.0 night with pharmacologically-elevated HR (supplement protocol, not illness):
Stager scored 194 min wake across ~6.4h in bed (efficiency 0.50). The user reported sleeping through the night.
Minute-level step-tick + gravity analysis of the four largest wake blocks: zero walking cadence. Each block = a single-minute turn-over burst (20–36 ticks, posture-variance spike) followed by minutes of complete stillness (variance <0.01, zero ticks). A genuine get-up looks entirely different (sustained multi-minute cadence).
The four blocks recur on a ~90-min rhythm and the night was credited only 37 min REM — consistent with hot-but-atonic REM being scored as wake.
Phone recorded zero steps all night until the real morning rise.
So on dense-motion straps the discriminator Proposal 2 needs is already in the data: locomotion cadence + posture stability, exactly the "same sparse-motion limitation" caveat inverted — the 5.0 streams are dense enough to trust. Follow-up PR incoming: a default-off Experimental refinement pass that self-gates on observed motion density (so 4.0 nights are untouched per #345), reclassifying still, non-locomotor wake segments to light. Numbers above reproduce with the noop-cloud audit tooling; happy to share methodology.
Activity
vishk23 commentedon Jul 13, 2026
Correction to the example in Proposal 2: on closer inspection with wrap-aware step-counter decoding (
stepSample.counteris a cumulative wrapping u16, not per-row steps — an earlier naive query read it as zero), the example night's 16-minute wake was actually an out-of-bed trip: ~32 counter ticks with a posture change at one point in the gap, ~7 more walking back, then a new still orientation. So the motion data distinguishes the two cases even more sharply than the original text claimed — but the correct label for that example is out of bed, not restless in bed. Strengthens the case for doing this classification once, in shared code, rather than ad-hoc: the counter-wrap subtlety is easy to get wrong (I did).vishk23 commentedon Jul 13, 2026
Data point on the "should the Apple Health export follow the stitched night?" question: probably no change needed on the export side. For the example night, Health receives the two split entries but its own day view already renders them as one night — continuous stage timeline with a visible gap, and the daily summary sums both blocks (time in bed / time asleep both correct). So the raw split entries are a good export shape as-is, and the stitch would only need to live in NOOP's own sleep display (headline duration + session list), which narrows the proposal to a presentation-layer change.
ryanbr commentedon Jul 13, 2026
Thanks — really well-framed, and the prior art is right (WHOOP/Oura/Apple Watch all render one night with the wake folded in). One thing worth knowing before you write code, because it makes this much smaller than a from-scratch stitch: NOOP already bridges wake gaps into one night — the gap is just that a couple of consumers don't use that bridge yet.
What already exists
SleepStageTotals.mainNightGroupIndices(#561 / #525 / #861) bridges fragments separated by a wake gap into a single main-night group, withgapBridgeMaxMin = 60(plus a 90-min night-tail bridge). So:The actual gap (two consumers that still read raw sessions)
HealthKitBridge.writeSleep(sessions:)iterates the raw stored sessions, so it emits one HealthKit entry per fragment → the two-entry export you're seeing.wakesegment inside the one night.Recommended shape (answers your three questions)
Reuse the existing bridge rather than adding new stitch logic or a new threshold:
mainNightGroupIndices.gapBridgeMaxMin(60 min). A separate 20–30 min rule would fragment the logic and break the cross-platform parity the shared bridge gives us..inBedspan per bridged night, with the gap as an in-hypnogramwakesegment — matching the other vendors. Both platforms (iOS HealthKit + Android Health Connect).So the concrete work is: make
writeSleepgroup bymainNightGroupIndicesand emit one span (+ the awake gap in the stages), and render the secondary fragment as a wake segment on the Sleep screen instead of a separate card. Small, parity-safe (the bridge is already shared), and it leaves the detector + stored sessions untouched.Proposal 2 (motion-aware wake classification) — great, but a follow-up
The catch is
gravitySamplereliability: on a WHOOP 4.0 the gravity stream is often too sparse to tell "restless in bed" from "out of bed" (this is the same sparse-motion limitation behind the recent 4.0 sleep-staging work). So motion-aware classification would be a 5.0/MG-leaning refinement that can't be trusted on 4.0 — worth doing later, honestly labelled, but v1 doesn't need it since the 60-min bridge already decides "same night."If you're up for it, the display + export routed through
mainNightGroupIndicesis the exact narrow shape wanted here — happy to review a PR of that.ble: extract dataRangeOldestUnix into WhoopProtocol.DataRange + pin t…
ryanbr commentedon Jul 13, 2026
BLEManager/WhoopBleClient/DataRange(thedataRangeOldestUnixdecoder,AUTO_CONTINUE_CAP, the auto-continue / spin-detector comments,#364/#451,#364/#25) = the unrelated "last night didn't sync" backfill-drain work. Those#-refs run on a separate internal numbering that happens to collide with this GitHub number.So when implementing this, don't wire anything near the BLE
#364sites — they're the sync subsystem, not sleep staging.The actual work here stays as scoped above: route the display + Apple Health write-back through the existing
SleepStageTotals.mainNightGroupIndices60-min wake-gap bridge (which already folds fragments into one night for scoring — hence the correct daily totals), rather than adding new stitch logic. Sessions stay split in storage; the merge is read-time. Motion-aware wake classification stays a follow-up (WHOOP 4.0 gravity is too sparse to rely on).sleep: present a wake-split night as one night — Health export + hypn…
vishk23 commentedon Jul 13, 2026
Real-world data point for Proposal 2 (motion-aware wake classification), from a 5.0 night with pharmacologically-elevated HR (supplement protocol, not illness):
So on dense-motion straps the discriminator Proposal 2 needs is already in the data: locomotion cadence + posture stability, exactly the "same sparse-motion limitation" caveat inverted — the 5.0 streams are dense enough to trust. Follow-up PR incoming: a default-off Experimental refinement pass that self-gates on observed motion density (so 4.0 nights are untouched per #345), reclassifying still, non-locomotor wake segments to light. Numbers above reproduce with the noop-cloud audit tooling; happy to share methodology.
feat(sleep): motion-aware wake refinement behind an Experimental togg…
fix(sleep): Asleep/Woke row + hypnogram axis span the whole split nig…
fix(sleep): Asleep/Woke row + hypnogram axis span the whole split nig…
fix(sleep): headline "Asleep" reflects the whole bridged night, not j…
Backfill: don't false-alarm "no banked history" on an auto-continue's…
ble: extract dataRangeOldestUnix into WhoopProtocol.DataRange + pin t…
sleep: present a wake-split night as one night — Health export + hypn…
feat(sleep): motion-aware wake refinement behind an Experimental togg…
fix(sleep): Asleep/Woke row + hypnogram axis span the whole split nig…
fix(sleep): headline "Asleep" reflects the whole bridged night, not j…
capture: sample the PHONE battery so the log covers battery drain, no…
test centre: adb-like detailed capture — rolling on-device strap-log …
ble: stop the auto-continue empty-offload storm — guard 2a on real ro…
ble: extract dataRangeOldestUnix into WhoopProtocol.DataRange + pin t…
sleep: present a wake-split night as one night — Health export + hypn…
feat(sleep): motion-aware wake refinement behind an Experimental togg…
fix(sleep): Asleep/Woke row + hypnogram axis span the whole split nig…
fix(sleep): headline "Asleep" reflects the whole bridged night, not j…
test centre: adb-like detailed capture — rolling on-device strap-log …
ble: stop the auto-continue empty-offload storm — guard 2a on real ro…
health-connect: retract sleep records the export no longer produces (#…
fix(background): don't re-arm or lose the progress bar across sessions
Restore explicit sync status on Apple Health