Skip to content

fix(oura): label the R-R optical channel so scoring stops reading every beat twice - #1076

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-rr-two-optical-channels-1071
Aug 5, 2026
Merged

fix(oura): label the R-R optical channel so scoring stops reading every beat twice#1076
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-rr-two-optical-channels-1071

Conversation

@pipiche38

Copy link
Copy Markdown

Fixes #1071.

Branch: fix/oura-rr-two-optical-channels-1071 (based on main @ 3b86b6ef, commit f5c23628)
Base: main — the defect is byte-identical on main.
Related: #823 (beat order within a second) and #1072 (its root cause) are unblocked by this —
ord is 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 in
rrInterval with nothing to tell them apart:

  • 0x80 green_ibi_quality_event — green LED, an 11-bit value on the 1 ms grid, gated on the
    ring's own quality == 1 flag and a 300-2000 ms physiological window. Runs the whole night.
  • 0x6E spo2_ibi_and_amplitude_eventbyte * 8, so always on the 8 ms grid. No quality
    gate. 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):

Beats stored in rrInterval 61,524
Beats the measured HR curve allows 29,800
Ratio 2.06×
sum(rrMs) / wall-clock 2.17×

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

window 8 ms beats 1 ms beats 8 ms share spo2Sample rows
23:14–23:44 315 1890 14 % — the 12.5 % chance rate, i.e. no real 0x6E 0
23:44–00:14 1165 1431 45 % 52
01:14–01:44 2768 1702 62 % 138
03:44–04:14 2744 1377 67 % 138
06:44–07:14 2543 1313 66 % 138

On 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, matching hrSample) and
resting 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:

hrv diag day=… rmssd=68ms sdnn=201ms meanNN=1041ms rr=60052/53117
               coverage=2.21 collapsedCov=1.29 rrIntegrity=crossSecondOverCount

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:

  1. Label at decode. Each decoder stamps its own channel — OuraIBIChannel (Swift) /
    OuraIbiChannel (Kotlin), codes 1 green / 2 spo2 / 3 ibiAmplitude. A channel is never
    guessed: a value from a source that does not report one stays nil.
  2. Carry it to the row. RRInterval.srcChannel / RrSourceChannel, persisted as
    rrInterval.srcChannel by GRDB v32-rr-src-channel and Room MIGRATION_25_26 (Room
    version 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.
  3. Score off one channel — the read filters, the table keeps both.

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:

  • NULL must survive. Every WHOOP row is NULL by construction — one beat source, no channel to
    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 measured
    on, 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 rrIntervals function on each platform
(AnalyticsEngine's RMSSD/RHR paths and the hrv diag trace included), so they all move together
and 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 always
being a multiple of 8 and an 0x80 row landing there only 1 time in 8 by chance.

INTEGER rather than a text label because rrInterval is 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.json is updated on both
sides (Room version 26, GRDB migration list, and the new rrInterval.srcChannel column) — so the
existing drift guard covers this migration on both platforms.

The channel enum is deliberately two enums per platform (OuraProtocol does not depend on
WhoopProtocol; com.noop.oura does 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 on
one 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), Kotlin
RrSourceChannelTest:

  • each decoder stamps its own channel, on the same golden fixtures whose values are already pinned;
  • the driver preserves it, so a 0x6E record and a 0x80 record covering the same interval yield rows
    with distinct srcChannel — and both still reach the store;
  • an unlabelled IBI stays nil rather than being guessed;
  • the two channel enums agree case-for-case and code-for-code, and the raw codes are pinned as a
    durable storage format on both platforms;
  • migration adds the column, keeps it out of the primary key, and is additive/nullable/no-default;
  • both channels are stored and only one is read — the 0x6E rows are asserted still present in the
    table via a test-only unfiltered read;
  • WHOOP rows carry no channel and are never filtered, and [hrv] Same-second R-R beats are read in magnitude order, not emission order — biases RMSSD down #823 emission order is unchanged;
  • pre-v32 NULL rows are still read, in the pre-v30 fallback order;
  • ibiAmplitude is not excluded;
  • the coverage claim itself: 600 known beats written twice read back as exactly 600.

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 / HrFrontierQueryShapeTest work
under); the behavioural end-to-end read is the Swift twin, which CI runs against a real SQLite.


Verification

Packages/WhoopProtocol    swift test  531 tests, 0 failures
Packages/OuraProtocol     swift test  164 tests, 0 failures
Packages/WhoopStore       swift test  346 tests, 0 failures
Packages/StrandAnalytics  swift test 1234 tests, 0 failures
Packages/StrandImport     swift test  217 tests, 0 failures (1 skipped)
android  ./gradlew testFullDebugUnitTest --rerun-tasks
         3458 tests, 3 failed — all three are pre-existing on main
         (StandardHrSensorFormatTest ×2 locale, AiCoachContextTest), verified
         by running them on a clean main checkout

No app-target Swift is changed, but the package change reaches both app targets, so both were built
locally (CI builds neither by default):

xcodebuild -scheme Strand   -destination 'platform=macOS'      → BUILD SUCCEEDED
xcodebuild -scheme NOOPiOS  -destination 'generic/platform=iOS' → BUILD SUCCEEDED

Covered by swift-packages.yml.

hardware validation

  • This needs one post-fix overnight capture. The regression check, from
    the issue:

  • coverage in hrv diag: 2.21× → ~1.0

  • SDNN: 201 ms → 40–100 ms

  • rrIntegrity: crossSecondOverCountplausible

  • meanNN / 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 v32 and Room 26. #1073 also wants a migration and must therefore use
v33 / Room 27.

…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
ryanbr marked this pull request as ready for review August 5, 2026 02:36
@ryanbr
ryanbr merged commit fddf903 into ryanbr:main Aug 5, 2026
16 checks passed
@pipiche38
pipiche38 deleted the fix/oura-rr-two-optical-channels-1071 branch August 5, 2026 06:17
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
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.

[oura][hrv] rrInterval mixes two optical channels, so every beat is stored twice

2 participants