Repository navigation
Description
Summary
For a live Oura ring (oura-<uuid>, sourceKind = oura, active), the nightly skin-temperature reading — and, by the same mechanism, resting HR and HRV — never appears on the vital-detail screens. The Skin Temperature screen shows only WHOOP-imported days and stops at the last import.
Root cause: the ring banks overnight IBI (rrInterval) and skin temp (skinTempSample), but no overnight hrSample is ever produced from that IBI. The nightly analytics pipeline gates on hrSample, so with zero overnight HR the day-owner probe finds no data, ownership falls back to a device with nothing for that day, and the whole night scores NULL — taking skin temp down with it even though the skin-temp samples cover the full night.
Evidence (staging DB, live ring oura-5C4C0BF8-…, overnight 00:00–08:00 local)
| Stream | 21 Jul | 22 Jul | Covers night? |
|---|---|---|---|
rrInterval (banked IBI) |
27,080 | 25,816 | ✅ |
skinTempSample |
546 | 545 | ✅ |
hrSample |
0 | 0 | ❌ |
hrSample for the ring exists only in daytime live-streaming windows (hours when the app was open and connected). Resulting dailyMetric rows for those days are under my-whoop-noop with skinTempDevC, restingHr, avgHrv, totalSleepMin all NULL.
Why it cascades
IntelligenceEngine.resolveDayOwnerprobesstore.hrSamples(deviceId:, night window, limit:1)→ 0 → the live ring is not a candidate-with-data → owner falls back (tomy-whoop, which has no data those days).- The nightly funnel additionally requires
guard hr.count >= 200in the night window (IntelligenceEngineday loop) → fails regardless of owner. skinTempDevC/restingHr/avgHrvland NULL.- The Skin Temperature vital screen (
MetricExplorerView→Repository.exploreSeries(key:"skin_temp")) reads the per-dayDailyMetric.skinTempDevCcolumn + imported WHOOPmetricSeries— neither gets a live-Oura value → the Oura days are blank.
Key insight
The overnight HR chart works because that path derives HR from the banked IBI at read time (#511). The daily analytics pipeline instead reads persisted hrSample rows, which are never materialized from the overnight IBI. The two paths disagree — display shows overnight HR, scoring sees none.
Where it lives (both platforms — parity)
- Storage mapping:
Packages/WhoopStore/…/OuraStreamMapping.swift(+ Kotlincom.noop.oura.OuraStreamMapping) — banked IBI (0x60/0x80/0x6E) →.ibi→rrIntervalonly; live.hrpush →hrSample. No banked-window HR derivation. - Gate:
Strand/Data/IntelligenceEngine.swiftresolveDayOwner(HR presence probe) and the day loop'sguard hr.count >= 200; Kotlin twins incom.noop.analytics/RegistryDayOwnerSource.
Scope / not-a-regression
- Not caused by the Oura sleep-persist work — sleep persists from the phase-code stream, independent of HR, so it correctly shows "Oura". Only the HR-gated vitals (skin temp / RHR / HRV) are affected.
- Pre-existing structural gap in the live-Oura path; affects the ring the moment it's the active device.
Proposed approach
Option A (recommended — the complete fix): derive + persist hrSample from the banked Oura IBI
When the banked overnight IBI is decoded and stored to rrInterval, also compute HR and persist hrSample under the same deviceId, anchored to ring-time (same anchor as the IBI, per #677 — never drain-arrival now):
bpm = round(60000 / ibiMs), with the existing honesty guards (drop implausible bpm; already applied for live HR).- Cadence: ~27k IBI/night is too dense to store 1:1 as HR; aggregate to a sane cadence (e.g. one HR per rolling window / per second) so
hrSamplevolume matches the live path. - Dedup: the daytime live
.hrpush already writeshrSample; the banked derivation should fill only the gap and dedup bytsso a window covered by both isn't double-counted. - Parity: implement as byte-identical Swift/Kotlin twins with a pure, fixture-tested derivation (belongs in
Packages/com.noop.oura, not the app layer). - Payoff: overnight
hrSample≥ 200 in the night window →resolveDayOwnerroutes to the live ring → the funnel computesskinTempDevC,restingHr,avgHrvand scores the night. Lights up skin temp and RHR and HRV and proper day-ownership in one change.
Option B (narrower, less complete)
Teach resolveDayOwner's presence probe and the funnel's HR gate to treat rrInterval as HR presence for an Oura owner, and derive an in-memory HR series from IBI inside the funnel. Avoids new stored rows but spreads Oura-special-casing across analytics and still needs an HR series — Option A is cleaner and reusable.
Related follow-up (separate)
The skin_temp daily column is a deviation (DailyMetric.skinTempDevC, ±°C) while the WHOOP import metricSeries is absolute °C — so even once an Oura day scores, it would render on a different scale than the import rows in the readings table. Worth reconciling (own issue) so Oura and WHOOP skin temp read consistently.
Activity
pipiche38 commentedon Jul 23, 2026
#728 — IBI→hrSample derivation: offline simulation vs the ring's own
0x5DHRValidated the derivation against a real overnight from the staging DB, cross-checked against the ring's
own
0x5Dhr_bpm(its 5-min average HR) as ground truth.Approach under test
Derive
hrSamplefrom the banked IBI (rrInterval): every IBI decoded from one record shares thatrecord's ring-time, so group by ring-time (= per record) and emit one median HR per record —
HR = round(60000 / median(IBI))over the record's physiological beats (IBI 300–2000 ms, bpm 30–220).This is what the pipeline needs, because the daily rebuild reads
hrSampleand gates onhr.count >= 200in the night window.
Simulation
rrInterval; also tested a Malik 20%-of-median within-record filter.0x5Dhr_bpmsamples in the same window (overall distribution + per-5-min).Results
0x5D(ring 5-min avg)Per-5-min agreement (window median of #728 vs the
0x5Dsample): bias +6 bpm, mean |diff| ~7 bpm,worst windows ±40 bpm (near-wake movement: 07:57 and 08:20 blow out to 73–82 while
0x5Dstays ~48–50).Findings
≥200gate and routes the day-owner, so the Oura night will score (unblocks RHR / skin-temp / HRV).
(within ~2–3 bpm). The ~4–6 bpm median offset is the expected instantaneous-IBI vs ring-smoothed-average
difference.
/ near-wake movement inflates the mean by ~+9 bpm. Fine for a percentile-based RHR; would bias
any mean-HR metric.
motion records, not within-record outliers (the per-record median already handles those). Cleaning them
needs a cross-record running-median reject or a motion-gate (
0x47/0x6B), which is what thering itself uses.
Verdict
The core of #728 is validated for its purpose: it produces the dense HR the pipeline needs, the night
scores, and the RHR the pipeline derives (a low percentile) matches the ring within a couple bpm. The
0x5Dcross-check confirmed the median and RHR are right. The known limitation is noise in the upper tail,which does not affect the percentile-based RHR / skin-temp gating.
Recommendation — path forward
RHR is honest.
0x47/0x6Bmotion) and/or a cross-record running-median reject to bring the series toward
0x5D's cleanliness —worth doing before any downstream metric relies on mean HR.
0x5Das the permanent ground-truth cross-check (and the direct, clean source foravgHrvvia its RMSSD).
(Simulation scripts:
sim728.py/sim728b.py; run against the stagingwhoop.sqlite.)pipiche38 commentedon Jul 23, 2026
Work under https://github.com/pipiche38/noop/tree/fix/oura-ibi-hrsample
Oura: feature-ID/enum doc corrections from the Ring-4 APK + name the …
feat(oura): derive hrSample from banked overnight IBI so the nightly …
feat(oura): derive hrSample from banked overnight IBI so the nightly …
feat(oura): derive hrSample from banked overnight IBI so the nightly …
Merge upstream release (ryanbr#787) into binnacle
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…
Oura: feature-ID/enum doc corrections from the Ring-4 APK + name the …
feat(oura): derive hrSample from banked overnight IBI so the nightly …
fix(oura): decode 0x5D hrv_event as (u8 hr_bpm, u8 rmssd_ms) 5-min pa…