fix(oura): label the R-R optical channel so scoring stops reading every beat twice - #1076
Merged
ryanbr merged 1 commit intoAug 5, 2026
Merged
Conversation
…ry beat twice Two Oura tags decode to R-R and both were stored untagged: 0x80 green-quality, which runs the whole night, and 0x6E, which runs only while an SpO2 measurement is on. They are not different beats — they are the same heartbeats measured by two optical channels — so `rrInterval` held roughly two complete copies of every night. Over one 488-min sleep window: 61,524 beats stored where the measured HR curve allows 29,800 (2.06x), sum(rrMs)/wall-clock 2.17x. The app's own `hrv diag` line has been reporting it as `coverage=2.21 rrIntegrity=crossSecondOverCount` with a ~200 ms nocturnal SDNN (healthy adult asleep: 40-100 ms). meanNN and resting HR were never wrong — a mean is insensitive to duplication; it is the variability statistics, built entirely from successive differences, that break. Not a de-duplication. Both rows are real measurements and the second channel is the obvious cross-check on the first, so nothing is deleted: - each decoder stamps its own channel (OuraIBIChannel / OuraIbiChannel), carried onto the durable row as `rrInterval.srcChannel` via GRDB `v32-rr-src-channel` and Room `MIGRATION_25_26` — additive, nullable, outside the primary key (keying on the label would store the same beat twice under two labels); - the scoring read excludes the one channel proven redundant. Not a green-only whitelist: NULL must survive (every WHOOP row is NULL by construction — one beat source), and 0x60/0x44 is kept because it does not fire on the hardware this was measured on and may be another ring's only beat source. Raw CSV export stays deliberately unfiltered — both channels are real, and an export that hid half the rows would make this un-diagnosable from an export, which is exactly how it was diagnosed. Pre-migration rows stay NULL and are still read: the channel was never recorded, so historical Oura nights keep their old inflated coverage rather than a backfilled guess. Verification: swift test green on WhoopProtocol/OuraProtocol/WhoopStore/ StrandAnalytics/StrandImport; `./gradlew testFullDebugUnitTest` 3458 tests, only the 3 failures already present on main (2 locale, 1 AiCoachContext). Both app targets compiled locally (Strand macOS + NOOPiOS), which no default CI job does. The shared Room<->GRDB schema oracle is updated on both sides. NOT yet validated on hardware — needs one post-fix overnight capture to confirm coverage 2.21 -> ~1.0, SDNN back into range, and meanNN/RHR unchanged. Fixes ryanbr#1071 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9fMWqa7gkmnQcksUF9JPK
ryanbr
marked this pull request as ready for review
August 5, 2026 02:36
This was referenced Aug 5, 2026
ryanbr
pushed a commit
that referenced
this pull request
Aug 5, 2026
#1084) 0x44 ibi_event and 0x60 ibi_and_amplitude_event share the bit-packed layout and decoder but are different tags on the wire; until now both stamped the same channel, so a stored beat could not say which tag produced it. Adds durable code ibiBare=4 (OuraIBIChannel/RRSourceChannel + Kotlin twins), appended not renumbered, and stamps 0x44 with it. Label only — the scoring read still excludes only 0x6E, so both tags remain scored exactly as before; byte-parity across Swift and Kotlin. Follows #1071/#1076. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AiQZ5XBeAMgnyUugPMqXPi
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.
Fixes #1071.
Branch:
fix/oura-rr-two-optical-channels-1071(based onmain@3b86b6ef, commitf5c23628)Base:
main— the defect is byte-identical onmain.Related: #823 (beat order within a second) and #1072 (its root cause) are unblocked by this —
ordis meaningless while two channels are interleaved in one stream.The bug
Two Oura tags decode to R-R intervals and both were mapped to
OuraEvent.ibi, so both landed inrrIntervalwith nothing to tell them apart:green_ibi_quality_event— green LED, an 11-bit value on the 1 ms grid, gated on thering's own
quality == 1flag and a 300-2000 ms physiological window. Runs the whole night.spo2_ibi_and_amplitude_event—byte * 8, so always on the 8 ms grid. No qualitygate. Present only while an SpO2 measurement is running.
They are not different beats. They are the same heartbeats measured by two optical channels, so the
store held roughly two complete copies of every night.
Measured over one 488-minute sleep window (Gen 3, app 9.3.1):
rrIntervalsum(rrMs) / wall-clockWhy this is the channels and not sync accumulation
The 8 ms-grid stream is absent until SpO2 measurement starts, then tracks it exactly. First SpO2
sample of this night: 00:02.
spo2SamplerowsOn a night where SpO2 barely ran the 8 ms share is 30 % and the whole-night ratio is only 1.09×.
Across one week the ratio spans 1.09× – 4.11× — that spread is the SpO2 duty cycle, not sync
history.
What it breaks, and what it does not
A mean is insensitive to duplication, so
meanNN(1041 ms = 57.6 bpm, matchinghrSample) andresting HR were never wrong. It is the variability statistics — built entirely from successive
differences — that collapse. The app's own always-on diagnostic has been reporting this for a while:
A nocturnal SDNN of ~200 ms is not physiological (healthy adult sleep: 40–100 ms).
Both tags are Tier A, so neither is behind
allowTierB— this is reachable in a default build.The fix
Not a de-duplication. Both rows are real measurements of the same beat by different optics, and
the second channel is the obvious future cross-check on the first. So nothing is deleted and nothing
is rewritten:
OuraIBIChannel(Swift) /OuraIbiChannel(Kotlin), codes1green /2spo2 /3ibiAmplitude. A channel is neverguessed: a value from a source that does not report one stays nil.
RRInterval.srcChannel/RrSourceChannel, persisted asrrInterval.srcChannelby GRDBv32-rr-src-channeland RoomMIGRATION_25_26(Roomversion 25 → 26). Additive, nullable, no table rebuild, no existing row touched, and deliberately
out of the primary key — keying on the label would make the same beat insertable twice under
two labels, which is the double-count being fixed, arrived at from the other direction.
Why the filter excludes 0x6E rather than whitelisting green
The issue recommends green as the source of truth, and that is what the filter produces. It is
written as an exclusion because of what a whitelist would also drop:
name — as is every row written before v32. A green-only whitelist would delete every WHOOP night
from scoring.
ibiAmplitude(0x60/0x44) is kept. It does not fire on the Gen-3 hardware this was measuredon, so there is no evidence it duplicates green, and dropping a ring's only beat source on an
untested assumption is the more expensive mistake. If a capture ever shows 0x60 and 0x80 firing
together, that is a second exclusion, decided on that evidence.
0x6E is the one excluded because it is both the demonstrated duplicate and the worse measurement:
8 ms quantisation, no quality gate, and running only while SpO2 is on — so scoring off it would make
HRV coverage a function of the SpO2 duty cycle.
Every R-R consumer already reads through the single
rrIntervalsfunction on each platform(
AnalyticsEngine's RMSSD/RHR paths and thehrv diagtrace included), so they all move togetherand the trace stays reproducible.
The raw CSV export is deliberately left unfiltered. It is the raw dump: both channels are real,
and an export that silently hid half the stored rows would make this defect un-diagnosable from an
export — which is exactly how it was diagnosed.
Existing data
Pre-migration rows stay NULL and are still read. The channel was never recorded, so historical Oura
nights keep their old inflated coverage rather than a backfilled guess. Noted in the migration
comment for the record: in an existing DB the two remain separable by
rrMs % 8, an 0x6E row alwaysbeing a multiple of 8 and an 0x80 row landing there only 1 time in 8 by chance.
INTEGERrather than a text label becauserrIntervalis the highest-volume table in the schema(~60k rows a night) and this column rides every one.
Cross-platform
Kotlin twin in the same commit, and the shared Room↔GRDB
schema_oracle.jsonis updated on bothsides (Room version 26, GRDB migration list, and the new
rrInterval.srcChannelcolumn) — so theexisting drift guard covers this migration on both platforms.
The channel enum is deliberately two enums per platform (
OuraProtocoldoes not depend onWhoopProtocol;com.noop.ouradoes not depend on the storage carriers). They pin the same codes,and the translation is written case by case rather than via
fromCode(...)so that adding a case onone side without the other is a compile error, not a silent nil. A test asserts they agree
case-for-case and code-for-code on both platforms.
Tests
New — Swift
IbiChannelTests(OuraProtocol),RrSourceChannelTests(WhoopStore), KotlinRrSourceChannelTest:with distinct
srcChannel— and both still reach the store;durable storage format on both platforms;
table via a test-only unfiltered read;
ibiAmplitudeis not excluded;The Kotlin half guards the read filter by auditing the DAO query text (no SQLite driver on that
unit-test classpath, the same constraint
DeepCaptureMigrationTest/HrFrontierQueryShapeTestworkunder); the behavioural end-to-end read is the Swift twin, which CI runs against a real SQLite.
Verification
No app-target Swift is changed, but the package change reaches both app targets, so both were built
locally (CI builds neither by default):
Covered by
swift-packages.yml.hardware validation
This needs one post-fix overnight capture. The regression check, from
the issue:
coverageinhrv diag: 2.21× → ~1.0SDNN: 201 ms → 40–100 msrrIntegrity:crossSecondOverCount→plausiblemeanNN/ resting HR: unchanged (they were never wrong)If coverage does not move, the channel filter is not actually being read.
Migration numbering
This takes GRDB
v32and Room 26. #1073 also wants a migration and must therefore usev33/ Room 27.