Skip to content

Sleep presentation: stitch short mid-night wakes into one displayed night (+ motion-aware wake classification) #364

Open

Description

@vishk23

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.

Activity

  1. vishk23 commented on Jul 13, 2026

    @vishk23
    Author

    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).

  2. vishk23 commented on Jul 13, 2026

    @vishk23
    Author

    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.

  3. ryanbr commented on Jul 13, 2026

    @ryanbr
    Owner

    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:

    1. Display-time, not the detector. Keep sessions split in storage — it preserves the sleep-time edit affordances (fix(sleep): scope userEdited/manual sleep corrections to their own day so one edit can't pin every night (#299) #318), the union dedup, and the Apple-Health round-trip. Just have the display + export group by mainNightGroupIndices.
    2. 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.
    3. 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.

  4. ryanbr commented on Jul 13, 2026

    @ryanbr
    Owner

    ⚠️ 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).

  5. vishk23 commented on Jul 13, 2026

    @vishk23
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions