feat(oura): Experimental "All-day heart rate & HRV" — stand the daytime-HR hold down for the learned night, not for every screen-off - #2413
Conversation
ryanbr
left a comment
There was a problem hiding this comment.
Thanks @pipiche38. Default-off, OFF byte-for-byte identical to today, one predicate still feeding the suspend, the resume and liveHRWanted so no two gates can disagree, and a cold start that falls back to the screen rule instead of inventing a clock for the shift sleepers the learner exists for. The shape is right.
Two things before merge.
Rebase
#2412 merged as e67c051a81dc181dbb2628ba1d1b9c600002af20. This branch still carries the pre-merge cacf6553d, so it needs a rebase onto current main; only 90062204e belongs here.
The parity ledger fails
parity-governance only runs when Tools/ is touched, so CI stays green on this. main is clean, so these are the branch's:
add-unpaired-function: Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::hm/1#1
add-unpaired-function: Packages/StrandAnalytics/Sources/StrandAnalytics/NightStandDown.swift::floorMod/2#1
StrandAnalytics/Sources is inside the inventory globs, so a Swift-only helper there is one-sided by construction. Either give them a disposition or, if the Android twin is coming, say so in the doc with a Kotlin twin: reference.
On the Android question
You offered a setting plus a no-op so the two Settings screens match. Not worth it: a toggle that gates nothing is a promise the platform does not keep. Leave Android without it until OuraLiveSource.kt has the screen-off suspend to gate.
One note
You flag that the band-end resume with the screen still dark is the one path not yet seen as a log line, and the 09-19 DB timing only implies it. That is the path where a bug leaves the ring held all night, which is the thing this feature must never do. A line on resumeAfterStandDownIfReleased firing would make the next night the evidence, the same way the auth lines did for #2412.
9006220 to
71ed770
Compare
…me-HR hold down for the learned night, not for every screen-off An Oura ring emits daytime heart rate only while a client holds it in daytime-HR mode (`DHR_mode:3`); there is no banked daytime HR family it produces on its own. The screen-off suspend (ryanbr#1526) stops holding the ring so its sleep suite can run — the right call at night (r = −0.93 between the overnight hold and the ring producing SpO2 / a hypnogram / `0x6A`) — but its gate is the screen, which is also dark for most of a working day. Measured on the Gen 3 ring's own DB: daytime 5-min HR bins (09–20 h, of 144) read 123–144 every day up to the night that build shipped and a median of ~16 every day since; on 2026-09-16 the 10–14 h hours held 0 samples and every daytime beat was `srcChannel=1` (the `0x80` the ring only emits while held). Windowed rMSSD by day empties for the same reason — the daytime beats are the same records. The 300 s drain is not at fault: the night (channels 2+3) is complete. This adds a default-OFF Experimental toggle, "All-day heart rate & HRV". OFF is today's rule, byte for byte. ON keys the stand-down on the NIGHT instead of on the screen: outside the learned night band a dark screen no longer suspends, so the 15 s re-engage keeps the ring in daytime mode and the ring banks `0x80` for the drain — exactly the pre-ryanbr#1526 daytime behaviour; inside the band the screen-off grace applies unchanged, so the merged night fix is untouched. The trade (the ring's own daytime PPG costs charge) is the user's, which is why this is a toggle and not a new default. The band is `NightStandDown` (StrandAnalytics, pure): bedtime = habitual midsleep − half the typical night, wake = + half, exactly `BatteryEstimator.bedtimeAlert`'s derivation so the two policies share one notion of the user's night, opened 1 h before that bedtime and closed 1 h after that wake. Cold start (fewer nights than the learner needs) yields nil and the screen rule applies — no invented 23:00 clock for the shift/late sleepers the learner exists for. The app layer supplies it from the same hourly midsleep cache the battery night-guard already keeps. `OuraLiveSource.shouldSuspendLiveHR` gains an `allDay` argument (`.off` = the old predicate exactly); `liveHRSuspended`, the suspend, the resume and ryanbr#2275's `liveHRWanted` at auth all keep reading that one predicate, so no two gates can disagree. New: `resumeAfterStandDownIfReleased`, checked from the 300 s history fetch — the only tick that still runs while suspended — re-arms the hold when the band closes with the screen still dark (the screen-on path already re-arms). The suspend / auth log lines name the band ("inside the night stand-down 21:30–07:30 (all-day HR on)") or the cold-start reason. With the toggle on, the enabling auth line names the policy too ("auth OK - enabling live HR (all-day HR on, outside night stand-down 22:12–08:12)", or "inside …", or "no learned sleep schedule yet - screen rule applies"), so the log says which rule decided; toggle off = the old text byte for byte. Android: no twin — `OuraLiveSource.kt` has no screen-off suspend (ryanbr#1546), so its daytime HR is already whatever the process lifetime gives; the toggle would gate nothing there. Settings copy lands in de/es/fr/pt-PT + it/pl/ru/zh-Hans/zh-Hant so the i18n ratchets stay where they are. Stacked on the preceding commit (do not arm daytime HR on a suspended connect) — it reuses that change's auth-time `liveHRWanted`. Tests: `NightStandDownTests` (6, `swift test`: band derivation, circular contains, a daytime sleeper, cold start, whole-day guard, parity with the battery night-guard); `OuraLiveHRSuspendPolicyTests` +5 (toggle off = old rule, on outside/inside the band, grace inside the band, cold start keeps the screen rule, band = learned schedule), 14/14 under `xcodebuild test`. StrandAnalytics 2046/0. `Strand` (macOS) and `NOOPiOS` BUILD SUCCEEDED; `doc_comment_lint` OK; `i18n_audit --ci` unchanged. Hardware (Gen 3, iOS, toggle ON): 2026-09-17 afternoon, phone pocketed, 449 `dhr_enable` in 132 min and zero SUSPENDED lines vs 3 / 12 suspended connects in the same-day control; 13–20 h HR bins 84/84 (09-16: 22/84). Nights 09-17/18 and 09-18/19 held: 0 `dhr_enable` in band, ring `DHR_mode:3` exactly 0 for 23:00–05:59, suite intact. Refs ryanbr#1526 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgqTwKfXLXzKo81ECb4aDz
…sume line say what it did Review follow-up. Parity ledger: `NightStandDown` is Swift-only by design (Android's `OuraLiveSource.kt` has no screen-off suspend for it to gate), so its five functions, two constants and the file get `platform_specific` dispositions instead of being one-sided by accident. `describe`'s nested `hm` helper is replaced by `describeSecOfDay`, which the resume line also uses. Two scanner side effects surfaced by the ratchet are fixed at the source: the test helper `sec(_:_:)` resolved lexically to `GarminExportParser`'s `sec/2` and made it look test-only, so it is renamed `clock`; and `describe`'s only production callers sat inside string interpolation the scanner does not read, so one call is hoisted into a `let`. The derived snapshots are refreshed on top of main's re-derived authority (44ef71e). The band-end RESUMED line already existed; it now names the band and the local time it fired at (a resume stamped inside its own band is the defect that would hold the ring all night), and says whether it re-armed the hold or left it to the next connect instead of claiming a resume it did not make. Verified: StrandAnalytics NightStandDown 7/7 (+1); `OuraLiveHRSuspendPolicyTests` 14/14 under `xcodebuild test`; `Strand` (macOS) and `NOOPiOS` build; `parity_ledger.py` OK; `parity_ratchet.py` 0 errors; `test_parity_*` 180 OK; doc lint OK. Refs ryanbr#1526 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV
71ed770 to
9dd24b7
Compare
|
Thanks @ryanbr. Rebase. Done, onto Ledger. On the old head the full run listed five
I resolved the derived files to Android. Agreed. It stays without the toggle until The resume line. It was already there: Verified: StrandAnalytics |
Replaces #2300, rebased onto current
mainafter #2412 merged. The feature is640b68762;9dd24b72ais the review follow-up (parity dispositions, and a resume line that names its band and what it did). Refs #1526.What
A default-OFF Experimental toggle, All-day heart rate & HRV, for an active Oura ring. OFF is today's rule, byte for byte. ON keys the live-HR stand-down on the user's night instead of on the screen: outside the learned night band a dark screen no longer suspends the 15 s re-engage, so the ring stays in daytime-HR mode and banks
0x80for the 300 s drain — the pre-#1526 daytime behaviour; inside the band the screen-off grace applies unchanged, so the night fix is untouched.NightStandDown(StrandAnalytics, pure): bedtime = habitual midsleep − half the typical night, wake = + half — the same derivationBatteryEstimator.bedtimeAlertuses, so the two policies share one notion of the user's night — opened 1 h before that bedtime, closed 1 h after that wake. Cold start (fewer nights than the learner needs) yields nil and the screen rule applies: no invented clock for the shift/late sleepers the learner exists for.OuraLiveSource.shouldSuspendLiveHRgains anallDayargument (.off= the old predicate exactly).liveHRSuspended, the suspend, the resume and the auth-timeliveHRWantedall keep reading that one predicate, so no two gates can disagree.resumeAfterStandDownIfReleased, checked from the 300 s history fetch — the only tick that still runs while suspended — re-arms the hold when the band closes with the screen still dark; the screen-on path already re-arms.SUSPENDED - screen off 5 min inside the night stand-down 21:30–07:30 (all-day HR on)andauth OK - enabling live HR (all-day HR on, outside night stand-down 22:12–08:12)(orinside …, orno learned sleep schedule yet - screen rule applies). Toggle off = the old text byte for byte.AppModel.ouraAllDayLiveHRKey+ the band from the hourly midsleep cache the battery night-guard already keeps; Settings card under Experimental, Oura-only; copy in de/es/fr/pt-PT + it/pl/ru/zh-Hans/zh-Hant.Android: no twin.
OuraLiveSource.kthas no screen-off suspend (#1546), so its daytime HR is already whatever the process lifetime gives; the toggle would gate nothing there. Happy to add the setting + a no-op if you would rather the two Settings screens match.Why
The ring emits daytime heart rate only while a client holds it in daytime-HR mode; there is no banked daytime HR family it produces on its own. #1526's screen-off suspend stops holding the ring so its sleep suite can run — right at night (r = −0.93 between the overnight hold and the ring producing SpO2 / a hypnogram /
0x6A) — but a dark screen is also most of a working day.Measured on the Gen 3 ring's own DB (
rrInterval,deviceId LIKE 'oura%',tsSuspectexcluded), daytime 5-min bins covered 09:00–20:00 local, of 144: 123–144 every day 07-29 → 08-17, 2–60 (median ~16) every day since 08-18 — the night the suspend build went on the phone. On 09-16,hrSampleper hour: 10–14 h 0, 18 h 0, 20 h 0; every daytime beatsrcChannel = 1(the0x80the ring only emits while held). The 300 s drain is not at fault: the night (channels 2 + 3) is complete. Windowed rMSSD by day is empty for the same reason — the daytime beats are the same records — hence "& HRV" in the name.The trade is ring battery (its own daytime PPG), which is why this is the user's toggle and not a new default.
Verification
NightStandDownTests(6,swift test): band derivation, circular contains across midnight, a daytime sleeper's band that does not cross midnight, cold start, the whole-day guard, parity with the battery night-guard's bedtime.OuraLiveHRSuspendPolicyTests+5 (xcodebuild test, 14/0): toggle off = old rule; on, outside the band never suspends; on, inside the band suspends after the grace and the grace still holds; cold start keeps the screen rule; the band handed to the policy is the learner's.Strand(macOS) andNOOPiOSboth BUILD SUCCEEDED (CI does not build the app targets).doc_comment_lintOK;i18n_audit --ciOK againstmain.dhr_enable(87 % of the 15 s slots), zeroSUSPENDED/live HR suspendedlines. Same-day control on the build without the toggle, 11:22–12:42: 3dhr_enable, 12 connects loggedlive HR suspended (screen off). The hold does not cost link stability: 22 timeouts in 132 min held vs 25 in 80 min unheld (feat(oura): Experimental "All-day heart rate & HRV" — stand the daytime-HR hold down for the learned night, not for every screen-off #2300, comment 5716193499). HR coverage 13–20 h: 84/84 5-min bins (09-16: 22/84, 09-15: 6/84); 2026-09-19 09:00–13:55: 59/59.dhr_enableinside the band across 18 and 15 overnight reconnects, every in-band authlive HR suspended … daytime HR left untouched; the ring's own0x43log showsDHR_mode:3exactly 0 for seven consecutive hours (23:00–05:59) against ~200/h either side; sleep suite intact.RESUMED - night stand-down endedwith the screen still dark. On 09-19 the DB shows HR resuming at 08:08:03 after 70 min of suspended auths, consistent with the 08:03 band end plus the 300 s tick, but the log does not cover that window.