Derive hrSample from banked overnight IBI so an Oura night actually scores#774
Merged
Merged
Conversation
…pipeline scores an Oura night (ryanbr#728) The Oura ring banks overnight IBI (0x60/0x80/0x6E) but NO heart-rate stream, so an Oura night lands only in `rrInterval`. The nightly analytics pipeline gates on `hrSample` — `resolveDayOwner`'s presence probe and the `hr.count >= 200` guard — so with zero overnight HR the day-owner falls back off the ring and the whole night scores NULL: no restingHr / avgHrv, and skin temp (gated behind the same probe) never surfaces. (Confirmed on device: overnight `rrInterval` ~25k/night but `hrSample` 0.) Fix: materialise one median HR per IBI-record as `hrSample`. - `OuraIbiHr.perRecordMedianHR([OuraIBI]) -> [OuraHR]` (pure, both platforms): every IBI decoded from a record shares that record's ring-time, so grouping by ringTimestamp == grouping by record; HR = round(60000 / median(IBI)) over the record's physiological beats (IBI 300..2000 ms), gated to 30..220 bpm. Grouping also collapses a record's 6-or-7 same-ts beats into ONE row, matching hrSample's (deviceId, ts) key. - Wired ONLY into the banked path: in `ingest`/`emit`, a batch with NO live-HR push (`.hr`) is history data (the live push always emits [.hr, .ibi] together), so live HR is never double-counted. The derived HR is enqueued as `.hr` → the mapping writes `hrSample`, but it does NOT pass through the `.hr` switch case, so it triggers no wear/live-badge side-effects. Per-record ring-time anchored (ryanbr#677); an unanchored record is skipped and re-derived when it re-serves after the 0x42 anchor. hrSample's (deviceId, ts) primary key makes re-drains idempotent. Byte-identical Swift/Kotlin twins; `OuraIbiHr` fixture-tested on both (grouping, median, IBI gate, 6-beats-collapse, ordering, empties). `OuraHR`'s existing physiological gate + the live path are untouched. Verification: swift test OuraProtocol green (incl. OuraIbiHrTests); macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK. Materialisation on real banked IBI is validated on the next capture (nightly scoring lighting up skin temp / RHR / HRV); the 0x5D hr_bpm gives a ground-truth cross-check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38
force-pushed
the
fix/oura-ibi-hrsample
branch
from
July 24, 2026 17:43
be38327 to
66aa917
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch:
fix/oura-ibi-hrsample→mainCloses / refs: #728
Problem
An Oura ring banks overnight IBI (
0x60/0x80/0x6E) but no heart-rate stream, so an Oura nightlands only in
rrInterval. The nightly analytics pipeline gates onhrSample:resolveDayOwner's presence probe, andhr.count >= 200guard.With zero overnight HR the day-owner falls back off the ring and the whole night scores NULL — no
restingHr, noavgHrv, and skin temp (gated behind the same probe) never surfaces. Confirmed ondevice: overnight
rrInterval~25k/night buthrSample0.Fix
Materialise one median HR per IBI-record as
hrSample.OuraIbiHr.perRecordMedianHR([OuraIBI]) -> [OuraHR](pure, both platforms). Every IBI decoded from arecord shares that record's ring-time, so grouping by
ringTimestamp== grouping by record.HR = round(60000 / median(IBI))over the record's physiological beats (IBI 300..2000 ms), gated to30..220 bpm. Grouping also collapses a record's 6-or-7 same-ts beats into ONE row, matching
hrSample's(deviceId, ts)key.ingest/emit, a batch with no live-HR push (.hr) ishistory data (the live push always emits
[.hr, .ibi]together), so live HR is never double-counted.The derived HR is enqueued as
.hr→ the mapping writeshrSample, but it does not pass throughthe
.hrswitch case, so it triggers no wear/live-badge side-effects.re-serves after the
0x42anchor.hrSample's(deviceId, ts)primary key makes re-drainsidempotent.
OuraHR's existing physiological gate and the live-HR path are untouched.Why this is honest (not fabricated HR)
The IBIs are real, Tier-A, per-beat intervals the ring already banks;
60000 / median(IBI)is thedefinitional beats-per-minute of that record — no model, no interpolation, no invented samples. It
simply exposes, as an HR row, a rate the
rrIntervaldata already encodes. The pipeline can then scorethe night the same way it scores any HR-bearing source.
Cross-platform parity
Byte-identical Swift/Kotlin twins (
OuraIbiHr.swift/OuraIbiHr.kt), fixture-tested on both:grouping, median, IBI gate, 6-beats-collapse, ordering, empties.
Verification
swift test— OuraProtocol green incl.OuraIbiHrTests(6/0) after rebase onto currentmain.Android compileFullDebugKotlinOK; macOS Strand BUILD SUCCEEDED (app-target Swift touched inOuraLiveSource, which default CI does not compile).0x5Dhr_bpmground truth: median 52 vs 48, RHR p5 44 vsfloor 46 — the derived per-record median tracks the ring's own reported HR.
populate instead of NULL. (WHOOP per-second HR is not available for a per-beat cross-check; the
WHOOP import is per-day RHR only, so any further cross-check stays RHR-level. Option-2 follow-up —
motion-gate / cross-record-median cleanup — is documented but out of scope for this PR.)