Skip to content

fix(recovery): seed HRV/RHR baseline from on-device nights the import left blank (#393) - #421

Closed
digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/393-missing-charge-recovery-android
Closed

digitalerdude wants to merge 1 commit into
ryanbr:mainfrom
digitalerdude:fix/393-missing-charge-recovery-android

Conversation

@digitalerdude

Copy link
Copy Markdown

Fixes #393.

The bug

A strap user whose daily rows come from a Health Connect sleep import (HC never carries WHOOP HRV, so every one of those days is avgHrv = null) had the on-device HRV/RHR the strap computes from raw HR blocked from seeding the recovery baseline. The union merge in IntelligenceEngine filled only days whose key was absent; a day that already existed as a persisted row with a null value was treated as "covered" and never filled. So the HRV baseline starved below the 4-night seed (nValid stuck at 2), recovery never calibrated, and Charge stayed blank even on worn, sleep-recorded nights.

Confirmed from the reporters report.txt:

charge day=2026-06-30 nilScore reason=hrvBaselineNotUsable hrvStatus=calibrating hrvNValid=2 (need nValid>=4)
hrv day=2026-06-30 avgHrv=34.7   hrv day=2026-06-27 avgHrv=41.4   ...   (6+ valid nights)

~6 nights carry a perfectly good computed HRV, yet the baseline counted only 2.

The fix

Fill a day whose stored value is null (absent OR present-but-null) with a real on-device value, and never override a real imported value — so the "imported cloud value wins" intent holds while a strap+HealthConnect users computed HRV/RHR can finally seed the baseline. Extracted into a shared NightlyBaselineMerge.fillBlankNights routed by both engines, so production and tests share one rule; this also removes a latent Swift/Kotlin divergence on the (absent, nil) case.

  • Cross-platform, byte-identical: Strand/Data/IntelligenceEngine.swift + android/.../analytics/IntelligenceEngine.kt, new NightlyBaselineMerge.{swift,kt}. HRV/RHR/resp all routed through it.
  • One concern only. (A separate follow-up: Today shows "Needs the strap" instead of "Calibrating" while a baseline is genuinely mid-calibration — filed separately.)

Verification

  • swift test --filter NightlyBaselineMergeTests (StrandAnalytics) — pass
  • ./gradlew testFullDebugUnitTest (NightlyBaselineMergeTest, BaselineSeedingTest) — pass; Android app compiles
  • macOS xcodebuild -scheme Strand … build — BUILD SUCCEEDED (app-target IntelligenceEngine.swift, which no default CI covers)
  • Tests prove the filled baseline crosses the seed and tracks a varying injected HRV level (35 vs 70 ms), not one coincidental match (per CONTRIBUTING's derive-a-signal rule).

Note for the reporters specific strap: their most recent nights have no raw samples yet (freshly re-added 4.0, history not fully synced), so "last night" still needs a completed history sync to score; this fix restores Charge for the older nights that already carry HRV.

… left blank (ryanbr#393)

A strap user whose daily rows come from a Health Connect SLEEP import (HC never
carries WHOOP HRV, so every such day is present-but-null) had the on-device
HRV/RHR the strap computes from raw HR blocked from seeding the recovery
baseline: the union merge filled only KEY-ABSENT days, so a present-but-null day
was treated as "covered" and never filled. The HRV baseline starved below the
4-night seed (nValid stuck at 2), so recovery never calibrated and Charge stayed
blank despite worn, sleep-recorded nights.

Fill a day whose stored value is null (absent OR present-but-null) with a real
on-device value, never overriding a real imported value ("cloud wins" preserved).
Extracted into a shared NightlyBaselineMerge helper routed by both engines so
prod and tests share one rule; this also removes a latent Swift/Kotlin divergence
on the (absent, nil) case. Byte-identical across platforms; HRV/RHR/resp aligned.

Tests (both platforms) prove the filled baseline crosses the seed and TRACKS a
varying injected HRV level (35 vs 70 ms), not one coincidental match.

Verified: swift test (StrandAnalytics), gradle testFullDebugUnitTest, and a macOS
Strand app build (app-target IntelligenceEngine.swift, which no default CI covers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@digitalerdude

Copy link
Copy Markdown
Author

Closing as superseded: #401 (merged just before this opened) fixes the same root cause the same way — the nightly-into-history merge only checked KEY presence, so an imported row with a null value shadowed the real strap-computed night, starving the baseline below the 4-night seed ("Needs the strap" despite scored sleep). #401 already covers this on both platforms with shadow tests including the 7/7 starvation shape, so this PR adds nothing. Thanks @TheBoroer.

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.

Needs the Strap[master] Bug report

1 participant