Oura: fix 0x5D hrv_event decode — it's (u8 hr_bpm, u8 rmssd_ms) 5-min pairs, not (u16 time, int8, int8)#729
Conversation
…irs — was mis-framed The 0x5D hrv_event body is a run of (u8 avg HR bpm, u8 avg RMSSD ms) PAIRS, one per 5-min bucket (open_oura `decode_hrv`; confirmed on a real overnight capture). NOOP read it as a 4-byte (u16 time, int8 b1, int8 b2) stride — a mis-framing that: - garbled the first (hr, rmssd) byte-pair into a bogus `time_ms`, - sign-flipped the RMSSD byte (int8, so 131 read as -125), - and only `b1` accidentally landed on a real HR byte. Validation: the corrected HR byte tracks sleeping HR — a captured 22-Jul night decoded to ~52 bpm across the small hours, matching the ryanbr#511 IBI-derived median (54 bpm). The bogus `time_ms` values reconstructed byte-for-byte into the (hr, rmssd) of the first pair. Changes (byte-identical Swift/Kotlin twins): - OuraProtocol: `OuraHRV` now carries `(index, hrBpm, rmssdMs)`; `decodeHRV` walks 2-byte (u8 hr, u8 rmssd) pairs, both UNSIGNED, and returns nil on an empty/ODD-length body (never a partial pair). Golden tests use real overnight bytes (32 84 32 83) + an odd-length-nil case. `oura-decode` CLI line updated. - WhoopStore/data: OURA_HRV event payload is now honestly labelled `hr_bpm`/`rmssd_ms`/ `pair_index` (the byte->unit scaling is pinned now, so the old "raw, units-neutral, no rmssd_ms" caveat no longer applies). Keys/values identical across platforms. - docs/OURA_PROTOCOL.md s6.9 corrected with the pair layout + the decode-correction note. This is a pure decoder-correctness fix. It also unblocks a follow-up (ryanbr#728): the ring's own overnight HR + RMSSD are now available honestly, feeding avgHrv directly and giving a ground-truth HR to validate the IBI-derived hrSample against. Verification: swift test OuraProtocol + WhoopStore green (incl. the corrected 0x5D golden + mapping tests); macOS Strand BUILD SUCCEEDED; Android compileFullDebugKotlin OK. The Android unit-test RUN is blocked only by the unrelated ryanbr#716 test-fake gap (PR ryanbr#725); the Kotlin 0x5D tests mirror the passing Swift ones and run once that lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Reviewed. The decode fix is correct and well-evidenced — I re-derived your reasoning independently rather than taking it on trust:
Parity is genuine. I suspected a signedness divergence at first — Swift does Two things before merge. 1. Stale header docs, both platformsThe inline comments were updated but the file-level contract docs still describe the old layout:
The Kotlin one now contradicts the code it documents: it forbids exactly the 2. Only one 5-minute bucket per record actually persists
This is pre-existing (the old code also emitted N events at one Your own comment anticipates the gap — "buckets are ~5 min apart (consumer applies the offset)" — but no consumer applies it, and the
So sleep phases get distinct timestamps from Either is fine by me:
MinorYour caveat about the Android unit-test run is still accurate — #725 is open, so the Kotlin Nice piece of work — the validation against a real overnight, with the arithmetic shown, is what made this quick to check. |
ryanbr
left a comment
There was a problem hiding this comment.
Anchoring the two findings from my summary comment to the lines they concern, so they are actionable in place. The decode fix itself is correct — verified independently — and neither of these blocks the layout correction.
…series survives storage The 0x5D hrv_event body is a run of (u8 avg HR, u8 avg RMSSD) pairs, one per 5-min bucket. The mapping stamped every bucket in a record with the SAME `ts`, but the event key is (deviceId, ts, kind) with ON CONFLICT DO NOTHING — so N buckets collided on insert and only the first survived, silently dropping the rest of the ring's per-5-min series (the very series this tag now represents). Apply the documented 5-min cadence at mapping time (the OuraHRV.index contract: per-sample times step back from the event time, OURA_PROTOCOL.md s6): bucket `index` is stamped 300 * index seconds before the record time. Distinct rows, ordered oldest-last, none dropped. Derived from the known cadence + record anchor, not a guessed time. Applied identically on Swift and Kotlin. Also fix the stale mapping-layer header docs on both platforms — they still described the dead (time_ms/b1/b2) layout and forbade the rmssd_ms field the code now emits. Addresses reviewer feedback on the storage collision and stale docs. Swift: WhoopStore builds, OuraStreamMappingTests 12/12 pass incl. new multi-bucket test. Kotlin: main source compiles; twin test added (module unit tests still blocked pre-existing by the ryanbr#725 DAO fake gap). No new i18n strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
Problem (related to issue #728)
NOOP decoded the
0x5Dhrv_event as a 4-byte(u16 time_ms, int8 b1, int8 b2)stride and carried the fields raw (units-neutral, never fed to scoring). Cross-checking open_oura'sdecode_hrvand a real overnight capture shows the true layout is a run of(u8 avg HR bpm, u8 avg RMSSD ms)pairs, one per 5-min bucket. The old framing:(hr, rmssd)byte-pair into a bogustime_ms,int8, so131read as-125),b1accidentally landed on a real HR byte.Validation (real device)
On a captured 22-Jul night, NOOP's stored
b1values were 50, 52, 53, 52, 47, 54, 59, 52 — textbook sleeping HR, matching the #511 IBI-derived median of 54 bpm. Reconstructing the bogustime_ms(33842 = 0x8432) back to bytes gives[50, 132]— exactly the(hr, rmssd)of the first pair. Sob1was right by accident;time_msandb2's sign were wrong.Change (byte-identical Swift/Kotlin twins)
OuraHRVnow carries(index, hrBpm, rmssdMs);decodeHRVwalks 2-byte unsigned(hr, rmssd)pairs and returns nil on an empty or odd-length body (never a partial pair). Golden tests use real overnight bytes (32 84 32 83) plus an odd-length-nil case.oura-decodeCLI updated.OURA_HRVevent payload is now honestly labelledhr_bpm/rmssd_ms/pair_index— the byte→unit scaling is pinned now, so the old "raw, normssd_ms" honesty caveat no longer applies. Keys/values identical on both platforms.Scope
Pure decoder-correctness fix, one concern. It's also the precursor to #728: the ring's own overnight HR + RMSSD are now available honestly — feeding
avgHrvdirectly and giving a ground-truth HR to validate #728's IBI-derivedhrSampleagainst.(0x5D alone is too sparse — ~40–96 HR/night — to satisfy the pipeline's
≥200HR gate, so #728 still needs the dense IBI-derived series.)Verification
swift testOuraProtocol + WhoopStore green (corrected0x5Dgolden + mapping tests).StrandBUILD SUCCEEDED; AndroidcompileFullDebugKotlinOK.0x5Dtests mirror the passing Swift ones and run once Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs #725 lands.