Skip to content

sleep: detect naps with a nap detector, not the one built to reject them - #38

Merged
abdulsaheel merged 2 commits into
OpenStrap:mainfrom
svssathvik7:feat/nap-detector
Aug 6, 2026
Merged

sleep: detect naps with a nap detector, not the one built to reject them#38
abdulsaheel merged 2 commits into
OpenStrap:mainfrom
svssathvik7:feat/nap-detector

Conversation

@svssathvik7

@svssathvik7 svssathvik7 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The bug

detectNaps delegated to AdvancedSleepStager.detectSleep, whose minSleepMin = 60 exists — in its own comment — "so daytime naps and stray still-blocks stay excluded", plus a 90-min + HR-dip guard for anything centred 11:00–20:00 local.

The canonical 20–45 min afternoon nap was therefore structurally undetectable: the advertised 20-min floor was unreachable dead code. It failed silently, returning an empty list with the note "no qualifying naps (20 min–3 h)" — wrong about the range (the constant was 6 h) and crediting van Hees to a path that never called van_hees.dart. The same 75-min nap was detected at 10:00 and not at 14:00.

Approach

Those floors are load-bearing for NIGHT accuracy (#90, and the episode where gravity-magnitude stillness produced 389 of 421 false positives), so they are not loosened. New sleep/nap.dart instead, run strictly on the complement of the main sleep window:

  • van Hees z-angle immobility, via the new immobilityMask extracted from van_hees.dart so night and nap share ONE primitive. An angle is orientation-invariant, so it does not inherit the ~13% spread in |accel| across static wrist postures that motivated those guards.
  • Every bout, not just the longest, bridged across brief arousals.
  • An HR dip against the AWAKE-DAYTIME baseline — seconds that are neither the main sleep nor any detected bout. Excluding only the main sleep left the candidate's own low-HR seconds, and the hours of tonight's sleep the nap window borrows, inside the median it is judged against, which makes the gate self-suppressing: the quieter the sleep, the lower the bar.
  • TST and in-bed reported SEPARATELY. Time in bed was the only figure before, and it is what fed the sleep-need credit.
  • A real confidence (HR coverage, dip depth, still fraction, per-bout wear corroboration), not sleep efficiency wearing the name.
  • Abstain, never guess: under 10 min of awake HR, or under 50% HR coverage in a bout, the answer is absent rather than a number.

No sleep-stage claim, deliberately: a 30-min nap holds no complete cycle and the daytime HR duty cycle cannot support a 4-class partition. No timezone dependence either — corroboration is physiological, not clock-based, so a nap no longer appears or vanishes with the machine's local offset.

Durations are wall clock, and a run breaks at a timestamp discontinuity. The substrate is a positional array with pruning and sync holes, so measuring in sample counts let 20 minutes of evidence report a 2-hour nap.

A bout the record cannot see the end of is deferred, and so is any bout chained to it. Deferring only the bout touching the array end is not enough: an ordinary 6-min awakening splits an unfinished night and only the trailing half touches the end, leaving the leading multi-hour fragment to be emitted as a nap.

Measurement

Adds tool/nap_harness.dart, scoring the shipped detector on hand-labelled days and reporting EVENT-level sensitivity and PPV together, TIB error on matched pairs, and the per-subject spread.

Per-second accuracy is deliberately not printed: naps are a few percent of a day, so a detector that reports nothing scores >97%.

Three limits are stated in the header and in the printed output rather than left implicit:

  • No PSG-labelled nap corpus. Note this is a statement about what this evaluation had access to — PSG stages daytime naps perfectly well, the MSLT is exactly that. What this scores is agreement with an annotator on a small self-collected set, and it cannot support a population claim.
  • Duration is scored as TIB, not TST. A label is a [start, end] interval, so its length is the whole episode. This corpus therefore cannot validate tstSec — the field that actually feeds the sleep-need credit.
  • Sensitivity and PPV are conditional on judged days. Abstained days' labels are reported separately, and a separate end-to-end recall charges abstentions as misses, so a detector that abstains on its hard days cannot quietly outscore one that tries.

Removed tests

The old nap tests are removed with the old detector: all five used blocks of 150 min or more, sized (per their own comment) to clear the very gates that made the real nap regime unreachable, so none could ever have exercised it.

Verification

  • dart analyze — No issues found
  • dart test400 passed

Downstream

detectNaps gains wristOff: / exclude: named params and NapWindow gains tstSec / tibSec / efficiency, so this is a breaking change for edge.

Companion PR: OpenStrap/edge#204 — held in draft until this one merges. It consumes the new signature and does not compile against edge's current analytics pin (f0d1153), so once this lands, edge's pubspec.yaml must be repinned to this PR's merge SHA and its pubspec.lock regenerated. Merge order is therefore this PR first, edge#204 second.

Summary by CodeRabbit

  • New Features

    • Added daytime nap detection with sleep duration, time-in-bed, efficiency, and confidence metrics.
    • Nap detection now accounts for movement, heart-rate evidence, recording gaps, exclusions, and overlap with main sleep.
    • Added shared immobility analysis to improve sleep and nap detection consistency.
  • Bug Fixes

    • Improved rejection of unreliable or incomplete nap records and false positives.
  • Tests

    • Added comprehensive coverage for nap detection, boundaries, exclusions, heart-rate requirements, and duration calculations.

`detectNaps` delegated to `AdvancedSleepStager.detectSleep`, whose
`minSleepMin = 60` exists — in its own comment — "so daytime naps and stray
still-blocks stay excluded", plus a 90-min + HR-dip guard for anything centred
11:00–20:00 local. The canonical 20–45 min afternoon nap was therefore
STRUCTURALLY undetectable: the advertised 20-min floor was unreachable dead
code. It failed silently, returning an empty list with the note "no qualifying
naps (20 min–3 h)" — wrong about the range (the constant was 6 h) and crediting
van Hees to a path that never called van_hees.dart. The same 75-min nap was
detected at 10:00 and not at 14:00.

Those floors are load-bearing for NIGHT accuracy (#90, and the episode where
gravity-magnitude stillness produced 389 of 421 false positives), so they are
not loosened. New `sleep/nap.dart` instead, run strictly on the complement of
the main sleep window:

  - van Hees z-angle immobility, via the new `immobilityMask` extracted from
    van_hees.dart so night and nap share ONE primitive. An angle is
    orientation-invariant, so it does not inherit the ~13% spread in |accel|
    across static wrist postures that motivated those guards.
  - every bout, not just the longest, bridged across brief arousals
  - an HR dip against the AWAKE-DAYTIME baseline: seconds that are neither the
    main sleep nor any detected bout. Excluding only the main sleep left the
    candidate's own low-HR seconds, and the hours of tonight's sleep the nap
    window borrows, inside the median it is judged against — which makes the
    gate self-suppressing, since the quieter the sleep the lower the bar.
  - TST and in-bed reported SEPARATELY. Time in bed was the only figure before,
    and it is what fed the sleep-need credit.
  - a real confidence (HR coverage, dip depth, still fraction, per-bout wear
    corroboration), not sleep efficiency wearing the name
  - abstain, never guess: under 10 min of awake HR, or under 50% HR coverage in
    a bout, the answer is absent rather than a number

No sleep-stage claim, deliberately: a 30-min nap holds no complete cycle and
the daytime HR duty cycle cannot support a 4-class partition. No timezone
dependence either — corroboration is physiological, not clock-based, so a nap
no longer appears or vanishes with the machine's local offset.

Durations are WALL CLOCK, and a run breaks at a timestamp discontinuity. The
substrate is a positional array with pruning and sync holes, so measuring in
sample counts let 20 minutes of evidence report a 2-hour nap.

A bout the record cannot see the end of is DEFERRED, and so is any bout chained
to it. Deferring only the bout touching the array end is not enough: an
ordinary 6-min awakening splits an unfinished night and only the trailing half
touches the end, leaving the leading multi-hour fragment to be emitted as a nap.

Adds tool/nap_harness.dart, scoring the shipped detector on hand-labelled days
and reporting EVENT-level sensitivity and PPV together, TST error on matched
pairs, and the per-subject spread. Per-second accuracy is deliberately not
printed: naps are a few percent of a day, so a detector that reports nothing
scores >97%. The honest caveat is in the header — there is no PSG nap corpus,
because PSG is a nocturnal protocol, so this measures agreement with an
annotator on a small self-collected set and cannot support a population claim.

The old nap tests are removed with the old detector: all five used blocks of
150 min or more, sized (per their own comment) to clear the very gates that
made the real nap regime unreachable, so none could ever have exercised it.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR moves nap detection from coaching.dart to sleep/nap.dart, adds shared van Hees immobility processing, exports and documents the new APIs, and adds synthetic tests plus a fixture-evaluation CLI.

Changes

Nap detection

Layer / File(s) Summary
Shared immobility primitive
lib/src/onehz/sleep/van_hees.dart
Adds ImmobilityMask and immobilityMask. vanHeesSleepWindow now uses the shared calculation.
Nap detector and package integration
lib/src/onehz/sleep/nap.dart, lib/src/onehz/sleep/sleep.dart, lib/src/onehz/human/coaching.dart, ALGORITHMS.md
Adds nap result types, thresholds, detection, filtering, TST/TIB metrics, exports, and documentation. Removes the previous coaching implementation.
Nap detector validation
test/onehz/nap_test.dart, test/onehz/coaching_test.dart
Adds synthetic nap tests and removes the former coaching nap tests. Adds unrelated coaching regression coverage.
Fixture evaluation harness
tool/nap_harness.dart
Adds fixture validation, production detector execution, interval matching, and metric reporting.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant detectNaps
  participant immobilityMask
  participant HRSamples
  participant NapWindowMetric
  Caller->>detectNaps: provide acceleration, HR, and exclusion spans
  detectNaps->>immobilityMask: calculate sustained z-angle immobility
  detectNaps->>HRSamples: build awake baseline and check HR dips
  detectNaps->>NapWindowMetric: emit TIB, TST, confidence, and metadata
  NapWindowMetric-->>Caller: return judged or absent metric
Loading

Possibly related PRs

  • OpenStrap/analytics#33: Changes the earlier coaching nap detector’s duration cap, which this PR replaces with the sleep-package detector.

Suggested reviewers: abdulsaheel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: replacing the existing sleep detector with a dedicated nap detector.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ALGORITHMS.md`:
- Line 77: Update the human/coaching.dart row in ALGORITHMS.md to remove
detectNaps from its function list, leaving sleep/nap.dart as the documented nap
source and avoiding references to the removed function.

In `@lib/src/onehz/sleep/nap.dart`:
- Around line 303-311: Rename the inner List<double> variable in the nap
coverage loop from inBout to boutHr, and update its add and length references;
leave the outer boolean inBout baseline mask unchanged.
- Around line 382-394: Update _overlapFraction to clip spans to [start, end),
sort or otherwise order the clipped intervals, merge overlapping or adjacent
coverage, and sum each covered second only once before dividing by dur. Preserve
the existing zero result for non-positive durations, empty spans, and invalid
intervals.
- Around line 287-293: In the nap candidate filtering logic, add outOfRange and
inMainSleep counters alongside the existing rejection counters, increment them
before the duration and mainSleep continue paths respectively, and include
nonzero counts in the skipped note using the specified descriptions.

In `@tool/nap_harness.dart`:
- Around line 22-28: Revise the caveat comment in the nap harness to state that
this evaluation lacks a PSG-labelled nap corpus, without claiming PSG is limited
to nocturnal protocols or that a physiological nap gold standard cannot exist.
Preserve the existing distinction between annotator agreement and
population-level precision claims.
- Around line 134-143: Update the scoring flow around the abstention branch and
summary reporting to track the number of labelled naps excluded from tp, fp, fn,
and perSubject when m.present is false. Report abstained-label coverage and
clearly label sensitivity and PPV as non-abstained-day metrics, including the
related summary paths at the additional referenced sections.
- Around line 164-165: Update the duration-error calculations in the nap
evaluation logic to compare each ground-truth interval duration with nap.tibSec
rather than nap.tstSec, reporting the absolute TIB error in minutes at both
affected locations. Do not infer TST from the start/end-only truth labels; only
use independently labelled TST if the fixture schema is extended.
- Around line 62-72: Harden the fixture-loading and validation flow around
fixture, _validate, and rawAccel before any casts or indexed access. Catch JSON
decode failures and reject non-map roots, then validate each day, acceleration
row shape and numeric values, HR values, labels, and optional spans before
converting them. Ensure malformed acceleration rows cannot reach rawAccel[i][2],
and report invalid fixtures through the existing error path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cdac5abd-4b76-4091-972d-fc4bc96d0cb9

📥 Commits

Reviewing files that changed from the base of the PR and between f0d1153 and 54ba3c6.

📒 Files selected for processing (8)
  • ALGORITHMS.md
  • lib/src/onehz/human/coaching.dart
  • lib/src/onehz/sleep/nap.dart
  • lib/src/onehz/sleep/sleep.dart
  • lib/src/onehz/sleep/van_hees.dart
  • test/onehz/coaching_test.dart
  • test/onehz/nap_test.dart
  • tool/nap_harness.dart
💤 Files with no reviewable changes (2)
  • lib/src/onehz/human/coaching.dart
  • test/onehz/coaching_test.dart

Comment thread ALGORITHMS.md
Comment thread lib/src/onehz/sleep/nap.dart Outdated
Comment thread lib/src/onehz/sleep/nap.dart Outdated
Comment thread lib/src/onehz/sleep/nap.dart Outdated
Comment thread tool/nap_harness.dart Outdated
Comment thread tool/nap_harness.dart
Comment thread tool/nap_harness.dart
Comment thread tool/nap_harness.dart Outdated
…tions

Addresses the review on OpenStrap#38. The two that mattered are both in the harness —
the numbers that justify the detector, not the detector itself.

THE HARNESS SCORED TST AGAINST A TIB LABEL. A fixture label is a
[startSec, endSec] INTERVAL, so its length is the whole episode: time in bed.
It was compared against `nap.tstSec`, so every matched nap was charged its own
awake time as error — a perfectly measured 2 h episode at 70% efficiency
reported a 36-minute miss. That is precisely the TST/TIB conflation this PR
exists to end, reappearing in the tool that validates it. Now compares
`tibSec`, and the metric is renamed `|TIB error|` rather than left mislabelled.

The honest consequence is stated rather than papered over: an interval label
cannot score TST at all, so this corpus does not validate the field that
actually feeds the sleep-need credit. Said in the header and in the output.

ABSTENTIONS WERE FREE. Labelled naps on an abstained day never reached
tp/fp/fn, so `labelled naps` under-reported the corpus and sensitivity/PPV were
silently conditional on the days the detector agreed to judge — a detector that
abstains on its hard days outscored one that tries. Abstained labels are now
counted and printed, the two conditional metrics say JUDGED days only, and a
new end-to-end recall charges abstentions as misses, which is what a user
experiences: an abstention shows them no nap.

PSG CLAIM CORRECTED. "PSG is a nocturnal protocol" is false — the MSLT stages
daytime naps, and PSG nap corpora exist. The true statement is narrower: we do
not HAVE a PSG-labelled nap corpus. Overclaiming that a gold standard cannot
exist is still overclaiming. Fixed in the header and the printed footer.

_overlapFraction summed spans instead of unioning them, so two overlapping
entries counted the same second twice, the fraction could exceed 1, and a bout
only half contradicted was rejected outright with `corroborated` driven to 0 —
while the doc comment above it has always promised "covered by ANY of [spans]".
LATENT, not live: edge's `_toggleSpans` is a correct toggle machine that cannot
emit overlapping spans within a list, and wristOff/exclude are evaluated
independently. Fixed as a contract guarantee for other callers, with a test
verified to fail against the summing version.

Two rejection paths — duration out of range, and overlap with the main sleep
window — dropped their bout without incrementing a counter, while every other
path reports itself in `skipped`. A day whose only candidate was a 7-hour still
block therefore answered "no qualifying nap" with an empty tail, telling the
caller nothing. Both counted now.

The inner `inBout` HR list shadowed the whole-day boolean mask of the same name
declared above the loop. Not a live bug — the mask is not read inside the
loop — but the next edit that reaches for it would get a List<double>. Renamed
to `boutHr`.

ALGORITHMS.md still listed `detectNaps` under `human/coaching.dart`, which this
PR empties, contradicting its own new `sleep/nap.dart` row.

Not taken: defensive validation of the fixture JSON before casting. It is a
dev-only tool reading a hand-written file; a stack trace is adequate signal and
the guard is unearned ceremony.

dart analyze clean; 400 tests pass (was 399).
@abdulsaheel
abdulsaheel merged commit c3a30be into OpenStrap:main Aug 6, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants