Skip to content

feat(oura): Experimental "All-day heart rate & HRV" — stand the daytime-HR hold down for the learned night, not for every screen-off - #2413

Open
pipiche38 wants to merge 2 commits into
ryanbr:mainfrom
pipiche38:feat/oura-all-day-hr-night-band-v2
Open

pipiche38 wants to merge 2 commits into
ryanbr:mainfrom
pipiche38:feat/oura-all-day-hr-night-band-v2

Conversation

@pipiche38

@pipiche38 pipiche38 commented Sep 23, 2026

Copy link
Copy Markdown

Replaces #2300, rebased onto current main after #2412 merged. The feature is 640b68762; 9dd24b72a is 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 0x80 for 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 derivation BatteryEstimator.bedtimeAlert uses, 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.shouldSuspendLiveHR gains an allDay argument (.off = the old predicate exactly). liveHRSuspended, the suspend, the resume and the auth-time liveHRWanted all 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.
  • Both auth lines name the policy when the toggle is on: SUSPENDED - screen off 5 min inside the night stand-down 21:30–07:30 (all-day HR on) and 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). Toggle off = the old text byte for byte.
  • App layer: 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.kt has 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%', tsSuspect excluded), 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, hrSample per hour: 10–14 h 0, 18 h 0, 20 h 0; every daytime beat srcChannel = 1 (the 0x80 the 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.
  • On the rebased head: StrandAnalytics 2054/0, OuraProtocol 254/0. Strand (macOS) and NOOPiOS both BUILD SUCCEEDED (CI does not build the app targets). doc_comment_lint OK; i18n_audit --ci OK against main.
  • Hardware, Gen 3, iOS, toggle ON:
    • Day, 2026-09-17, phone pocketed, NOOP not opened, 13:59–16:11: 449 dhr_enable (87 % of the 15 s slots), zero SUSPENDED / live HR suspended lines. Same-day control on the build without the toggle, 11:22–12:42: 3 dhr_enable, 12 connects logged live 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.
    • Nights 2026-09-17/18 and 09-18/19 — the toggle must not re-create the overnight hold, and it does not: 0 dhr_enable inside the band across 18 and 15 overnight reconnects, every in-band auth live HR suspended … daytime HR left untouched; the ring's own 0x43 log shows DHR_mode:3 exactly 0 for seven consecutive hours (23:00–05:59) against ~200/h either side; sleep suite intact.
    • Daytime ring drain under the hold: 2.92 %/h vs 2.59 %/h the same morning without it (n = 1).
    • Not yet observed as a log line: the band-end RESUMED - night stand-down ended with 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.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

@pipiche38
pipiche38 force-pushed the feat/oura-all-day-hr-night-band-v2 branch from 9006220 to 71ed770 Compare September 23, 2026 11:54
pipiche38 and others added 2 commits September 23, 2026 13:54
…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
@pipiche38
pipiche38 force-pushed the feat/oura-all-day-hr-night-band-v2 branch from 71ed770 to 9dd24b7 Compare September 23, 2026 12:16
@pipiche38

Copy link
Copy Markdown
Author

Thanks @ryanbr.

Rebase. Done, onto 44ef71ebe. The branch is the feature 640b68762 (unchanged from 90062204e) plus the review follow-up 9dd24b72a. cacf6553d is gone.

Ledger. On the old head the full run listed five NightStandDown functions, not two: band, contains, describe, hm, floorMod. hm is gone, replaced by describeSecOfDay, which the resume line also uses. The remaining five functions, the two constants and the file now have platform_specific dispositions: the type is Swift-only by design, since OuraLiveSource.kt has no suspend for it to gate. Running the ratchet also turned up two scanner side effects, and I fixed both in the source rather than dispositioning them:

  • the test helper sec(_:_:) resolved lexically to GarminExportParser's sec/2 and made that look test-only, so it is renamed clock;
  • describe's only production callers were inside string interpolation, which the scanner does not read, so one call is hoisted into a let.

I resolved the derived files to main's copy and re-ran --refresh-derived, as you suggested on #2414. With 44ef71ebe in, the plain refresh and the ratchet both pass without --migrate-authority.

Android. Agreed. It stays without the toggle until OuraLiveSource.kt has the screen-off suspend.

The resume line. It was already there: live-HR re-engage RESUMED - night stand-down ended (all-day HR on), screen still off. On 09-19 the strap log just did not cover the 08:03 window. It did have the problem you describe, though. It printed "RESUMED" before the guard that returns when there is no live link, so it could claim a re-arm that never happened. It now says which case it hit, and it names the band and the local time it fired, so a resume stamped inside its own band shows up in any log, with no Test Centre needed:

Oura: live-HR re-engage RESUMED - night stand-down 22:12–08:12 ended at 08:14 (all-day HR on), screen still off; re-arming the hold now
Oura: live-HR re-engage RESUMED - night stand-down 22:12–08:12 ended at 08:14 (all-day HR on), screen still off; no live link, the next connect arms it

Verified: StrandAnalytics NightStandDown 7/7 (+1 for describeSecOfDay); 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 and i18n_audit --ci clean. The next night on this build should show the line; I will post it here.

@pipiche38
pipiche38 requested a review from ryanbr September 23, 2026 12:22
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