Skip to content

Derive hrSample from banked overnight IBI so an Oura night actually scores#774

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-ibi-hrsample
Jul 24, 2026
Merged

Derive hrSample from banked overnight IBI so an Oura night actually scores#774
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-ibi-hrsample

Conversation

@pipiche38

Copy link
Copy Markdown

Branch: fix/oura-ibi-hrsamplemain
Closes / refs: #728

Problem

An 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.

With zero overnight HR the day-owner falls back off the ring and the whole night scores NULL — no
restingHr, no 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 (Oura: stamp banked IBIs at their real ring-time, not the drain-arrival time #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.

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 the
definitional beats-per-minute of that record — no model, no interpolation, no invented samples. It
simply exposes, as an HR row, a rate the rrInterval data already encodes. The pipeline can then score
the 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 testOuraProtocol green incl. OuraIbiHrTests (6/0) after rebase onto current main.
  • Android compileFullDebugKotlin OK; macOS Strand BUILD SUCCEEDED (app-target Swift touched in
    OuraLiveSource, which default CI does not compile).
  • Simulation-validated vs the 0x5D hr_bpm ground truth: median 52 vs 48, RHR p5 44 vs
    floor 46
    — the derived per-record median tracks the ring's own reported HR.
  • Remaining on-device gate: one overnight where the Oura night scores — skin temp / RHR / HRV
    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.)

…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
pipiche38 force-pushed the fix/oura-ibi-hrsample branch from be38327 to 66aa917 Compare July 24, 2026 17:43
@ryanbr
ryanbr merged commit a3cc332 into ryanbr:main Jul 24, 2026
9 checks passed
@pipiche38
pipiche38 deleted the fix/oura-ibi-hrsample branch July 25, 2026 08:38
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