Skip to content

A sedentary day is not eight naps: keep sedentary wake in the awake baseline - #39

Merged
abdulsaheel merged 1 commit into
mainfrom
fix/nap-awake-baseline
Aug 7, 2026
Merged

A sedentary day is not eight naps: keep sedentary wake in the awake baseline#39
abdulsaheel merged 1 commit into
mainfrom
fix/nap-awake-baseline

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #38, which is now on main (c3a30be).

The defect

The awake HR baseline excludes the main sleep and every detected bout:

if (hr[k] <= 0 || inBout[k]) continue;

Every stretch of ≥5 min of wrist immobility is a bout, so this removes all of the day's still time. What survives is the ambulatory HR median wearing the name "awake baseline". The gate medHr > baseline * napRestingHrMult is then cleared by any motionless awake stretch whose HR sits more than 5% below walking HR — desk work, reading, driving, a sofa.

Measured, not argued

Run against the merged code on main. A synthetic day of 8 × (6 min walking @ 96 bpm, 25 min motionless @ 72 bpm) — nobody napped:

present=true  note=8 nap(s) via van Hees z-angle immobility + an HR dip…
PHANTOM NAPS: 8   (each tib=1499s, conf=0.85 — the cap)
total fabricated nap minutes: 199

An unremarkable 10% contrast (84 vs 76 bpm) does the same, at 0.84. Keeping the still seconds puts the median at 72, and 0.95 × 72 = 68.4 < 72 rejects all eight — the exclusion is what breaks it.

Nothing consumes this yet (edge is pinned to f0d1153, pre-nap), so no user is affected today. edge#204 exists to consume it.

What this keeps

#38 was reaching for two real exclusions, and both survive:

  • The candidate's own low-HR seconds — otherwise a bout is graded against a median it is itself dragging down, and the quieter the sleep the lower the bar it has to beat. That is inherently per-candidate, so the baseline moves inside the loop: the day's awake pool minus this bout.
  • Any unfinished bout — the nap window deliberately runs hours past midnight, so the first hours of tonight's sleep sit in the record. That is sleep, not sedentary wake, and belongs in no baseline. Still excluded, day-wide.

What is no longer excluded is ordinary daytime stillness, which is precisely what an awake baseline is supposed to be made of. #38's own regression test (a real nap is still detected on a sleep-dominated window) still passes.

Absence stays honest

Moving the baseline per-candidate moved the "not enough awake HR" abstain with it. A day whose only still block cannot be judged must not return an empty list, which every caller reads as judged, none. A new noBaseline counter carries that case: when nothing was emitted and at least one candidate went unjudged, the day returns Metric.absent with the count. The existing too few awake HR samples abstains rather than setting a threshold test pins this — it failed on the first cut of this change, which is how the gap was found.

The tests are not decorative

Reintroducing the bug (drop the if (!unfinished[b]) continue; guard) fails exactly the three new tests, 26 → 23, while every pre-existing nap test still passes.

That is the point: the old suite structurally could not catch this. Its false-positive fixture (still but with no HR dip is desk work, not a nap) uses bpm: 80 for both its active and its still segments — active and sedentary HR are identical, so the baseline cannot be inflated. The contrast is the bug, so the contrast is now in the fixture.

The third new test runs the other way: a genuine 56 bpm nap on an otherwise sedentary day is still detected, so this does not over-correct into missing real naps.

Verification

  • dart analyze clean
  • dart test409 passing
  • Mutation-verified as above

Note for edge#204

This changes no public signature — detectNaps, NapWindow, tstSec/tibSec/efficiency are all untouched. If it lands before #204 repins, #204 should pin to this merge commit rather than c3a30be.

Summary by CodeRabbit

  • Bug Fixes
    • Improved nap detection accuracy for sedentary daytime activity, reducing false-positive nap classifications.
    • Genuine low-heart-rate naps continue to be detected during otherwise sedentary days.
    • Nap results now indicate when available data is insufficient to make a reliable determination.
    • Improved reporting for nap candidates that lack enough awake heart-rate data for evaluation.

…aseline

#38's awake HR baseline excluded the main sleep and EVERY detected bout. Since
every stretch of >=5 min of wrist immobility is a bout, that removed all of the
day's still time, and what was left was the AMBULATORY HR median wearing the
name "awake baseline". The dip gate `medHr > baseline * napRestingHrMult` is
then cleared by any motionless awake stretch whose HR sits more than 5% below
walking HR: desk work, reading, driving, a sofa.

Measured on the merged code, not argued. A synthetic day of 8 x (6 min walking
@ 96 bpm, 25 min motionless @ 72 bpm) — nobody napped — returned EIGHT naps
totalling 199 minutes, each at confidence 0.85, the cap. An unremarkable 10%
contrast (84 vs 76 bpm) did the same. Keeping the still seconds puts the median
at 72, and 0.95 x 72 = 68.4 < 72 rejects all eight.

The change was reaching for two real exclusions, and both are kept:

  * The CANDIDATE's own low-HR seconds, or a bout is graded against a median it
    is itself dragging down. That is inherently per-candidate, so the baseline
    now moves inside the loop: the day's awake pool minus THIS bout.
  * Any UNFINISHED bout. The nap window deliberately runs hours past midnight,
    so the first hours of tonight's sleep sit in the record; that is sleep, not
    sedentary wake, and belongs in no baseline. Still excluded, day-wide.

What is no longer excluded is ordinary daytime stillness — which is precisely
what an awake baseline is supposed to be made of.

Absence stays honest. Moving the baseline per-candidate moved the "not enough
awake HR" abstain with it, and a day whose only still block cannot be judged
must not come back as an empty list that every caller reads as "judged, none".
A new `noBaseline` counter carries that case, and when nothing was emitted and
at least one candidate went unjudged the day returns Metric.absent with the
count — restoring at day level what the whole-day check used to give for free.
The existing "too few awake HR samples" test pins it.

Tests: three new cases, and they are not decorative. Reintroducing the bug (drop
the `if (!unfinished[b]) continue;` guard) fails exactly these three, 26 -> 23,
while EVERY pre-existing nap test still passes — the old suite structurally
could not catch this, because its false-positive fixture uses bpm 80 for both
its active and its still segments, so active and sedentary HR are identical and
the baseline cannot be inflated. The contrast is the bug, so it is now in the
fixture. The third test is the other direction: a genuine 56 bpm nap on an
otherwise sedentary day is still detected, so this does not over-correct into
missing real naps.

dart analyze clean; 409 tests green.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Nap detection now retains sedentary wake heart rates, computes a separate baseline for each candidate bout, and reports absent metrics when candidates cannot be judged. Tests cover sedentary false positives and genuine low-heart-rate naps.

Changes

Nap detection

Layer / File(s) Summary
Candidate-specific baseline evaluation
lib/src/onehz/sleep/nap.dart
The awake heart-rate pool retains sedentary wake data. Each candidate excludes its own seconds when calculating a baseline. Candidates without a valid baseline are tracked and reported.
Sedentary-day regression coverage
test/onehz/nap_test.dart
Tests verify that sedentary periods do not produce false naps and that a genuine low-heart-rate nap is detected.
Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: svssathvik7

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: retaining sedentary wake in the awake baseline to prevent false nap detection.
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.

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: 2

🤖 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 `@lib/src/onehz/sleep/nap.dart`:
- Around line 332-357: Add a regression fixture in the nap tests that gives
detectNaps an awakeIdx pool meeting minAwakeHrSamples but leaves fewer than that
threshold after removing the candidate interval. Assert that detectNaps returns
Metric.absent when no other nap is detected, ensuring the per-candidate baseline
shortage branch is exercised.

In `@test/onehz/nap_test.dart`:
- Around line 425-450: Update the test “a real nap is still detected on a
SEDENTARY day” to insert an active interval longer than the bout-chain bridge
immediately before the 50-minute nap, separating it from the surrounding
25-minute sedentary bouts. Keep the existing single-nap assertion and
additionally assert that the detected nap’s tstSec is approximately 50 * 60.
🪄 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: 276617ac-8a85-46c2-804b-085c53c637d8

📥 Commits

Reviewing files that changed from the base of the PR and between c3a30be and 81bc55e.

📒 Files selected for processing (2)
  • lib/src/onehz/sleep/nap.dart
  • test/onehz/nap_test.dart

Comment on lines +332 to +357

// PER-CANDIDATE baseline: the day's awake pool minus THIS bout. A bout must
// not be graded against a median it is itself pulling down — see the pool
// construction above for why only the candidate and the deferred bouts come
// out, and not every still block in the day.
final awakeHr = <double>[];
for (final k in awakeIdx) {
if (k >= start && k < end) continue;
awakeHr.add(hr[k]);
}
if (awakeHr.length < minAwakeHrSamples) {
// The day had enough awake HR, but not once this candidate is removed —
// so THIS bout cannot be corroborated, while others still may be. Abstain
// for it rather than judging it against a median built from a handful of
// seconds. Counted separately from `unverifiable` because it is the one
// rejection that makes the DAY's verdict incomplete: see the check after
// the loop.
noBaseline++;
continue;
}
final baseline = median(awakeHr)!;
if (baseline <= 0) {
noBaseline++;
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a regression test for the per-candidate baseline shortage path.

test/onehz/nap_test.dart Lines 322-336 leave the day-wide awakeIdx pool below minAwakeHrSamples. That test returns at Line 268. It does not execute the new check at Line 342.

Add a fixture where awakeIdx.length >= minAwakeHrSamples, but removing the candidate leaves fewer samples. Assert that detectNaps returns Metric.absent when no other nap is emitted.

🤖 Prompt for 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.

In `@lib/src/onehz/sleep/nap.dart` around lines 332 - 357, Add a regression
fixture in the nap tests that gives detectNaps an awakeIdx pool meeting
minAwakeHrSamples but leaves fewer than that threshold after removing the
candidate interval. Assert that detectNaps returns Metric.absent when no other
nap is detected, ensuring the per-candidate baseline shortage branch is
exercised.

Comment thread test/onehz/nap_test.dart
Comment on lines +425 to +450
test('a real nap is still detected on a SEDENTARY day', () {
// The other side of the same coin, and the reason the fix keeps sedentary
// wake in the pool instead of dropping every bout: a day that is mostly
// sitting still must still be able to report the one block that was
// actually sleep. Baseline lands at desk HR (~72), and 56 clears
// 0.95 x 72 = 68.4 comfortably.
final d = _Day();
for (var b = 0; b < 4; b++) {
d
..active(6, bpm: 96)
..still(25, bpm: 72);
}
d.still(50, bpm: 56); // the genuine nap, a real autonomic dip
for (var b = 0; b < 4; b++) {
d
..active(6, bpm: 96)
..still(25, bpm: 72);
}
d.active(60, bpm: 96); // end awake: nothing to defer at the record end

final m = detectNaps(d.accel, d.hr);

expect(m.value, hasLength(1),
reason: 'the deep-dip block is a nap even though the day around it '
'is sedentary');
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Separate the 50-minute nap from adjacent sedentary bouts.

The preceding still(25) and following still(25) are contiguous with still(50). The detector therefore evaluates one approximately 100-minute immobility bout. hasLength(1) passes even if it reports the combined bout instead of the intended 50-minute nap.

Insert an active interval longer than the bout-chain bridge before the nap. Assert that tstSec is close to 50 * 60.

Proposed test correction
       for (var b = 0; b < 4; b++) {
         d
           ..active(6, bpm: 96)
           ..still(25, bpm: 72);
       }
+      d.active(6, bpm: 96);
       d.still(50, bpm: 56); // the genuine nap, a real autonomic dip
       for (var b = 0; b < 4; b++) {
         d
           ..active(6, bpm: 96)
           ..still(25, bpm: 72);
       }
@@
       expect(m.value, hasLength(1),
           reason: 'the deep-dip block is a nap even though the day around it '
               'is sedentary');
+      expect(m.value!.single.tstSec, closeTo(50 * 60, 90));
🤖 Prompt for 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.

In `@test/onehz/nap_test.dart` around lines 425 - 450, Update the test “a real nap
is still detected on a SEDENTARY day” to insert an active interval longer than
the bout-chain bridge immediately before the 50-minute nap, separating it from
the surrounding 25-minute sedentary bouts. Keep the existing single-nap
assertion and additionally assert that the detected nap’s tstSec is
approximately 50 * 60.

@abdulsaheel
abdulsaheel merged commit cd7374c into main Aug 7, 2026
2 of 3 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.

1 participant