Skip to content

fix(oura): choose the R-R beat channel per hour, so a day-wide read keeps the daytime beats - #2625

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-rr-channel-per-hour
Oct 2, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-rr-channel-per-hour

Conversation

@pipiche38

Copy link
Copy Markdown

Refs #2624, Refs #2423

Problem

#2423 made the scoring read keep one Oura beat channel for the whole requested window. Over a night that is right: 0x60 and 0x80 overlap, and reading both over-counts. Over a window that crosses wake it is wrong, because the channels are disjoint in time: 0x60 is banked overnight only, 0x80 carries every daytime beat. On a real ring, [00:00, 17:29] held 38,667 amplitude beats (night) and 11,377 green (day), so the read kept amplitude and dropped the whole day. The Deep Timeline's Windowed rMSSD for "Today" stopped after the night.

Change

WhoopStore.rrIntervals (Swift) and RR_INTERVALS_SQL (Kotlin) choose the Oura channel per UTC hour, counted inside the requested window with the same time/suspect predicates as before. This is the shape the WHOOP 4 per-hour source choice already uses (whoop4HourChoice).

WHOOP 4 and strict WHOOP 5 reads are unchanged: their branches are separate and untouched.

Verification

  • New twin test, one fixture shared byte-for-byte (testTheChannelIsChosenPerHourAcrossANightAndADay / theChannelIsChosenPerHourAcrossANightAndADay): a night hour (0x60 full, 0x80 partial), a green-only hour, and a mixed hour that amplitude wins. Against the old read it fails exactly as reported: the green-only hour's beats are missing. It passes on the new read.

  • swift test in Packages/WhoopStore: 632 tests, 0 failures.

  • Android: OuraOneChannelReadSqliteTest 7, RrSourceChannelTest 14, Whoop5RRSqliteTest 29, 0 failures. The Room @Query compiles through KSP.

  • doc_comment_lint: no new sites. parity_ratchet.py --base upstream/main: errors=0.

  • Real data. The shipped WhoopStore.rrIntervals + HRVAnalyzer.rollingRmssd (600 s window, 75 s stride, as the Deep Timeline uses for a day span) were run over [00:00, 17:29] of a real Oura database, old read v new read:

    hours old: beats / points new: beats / points
    00–07 28,160 / 349 28,160 / 349, identical per hour (beats, points, median)
    08–17 48 / 1 10,548 / 367 (34–42 points per full hour)
  • macOS app built from this branch with that database loaded: Deep Timeline → Windowed rMSSD → Today now draws through the day.

  • Effect on nights. Over 40 nights of the same ring, 18 hours carry both channels with green holding more beats. All of them sit at a sleep edge (07 h, 20–22 h) or in daytime. In those hours the night now takes the fuller green train instead of the thinner amplitude one, which is the fix(oura): score one R-R beat channel per window, not 0x80 on top of 0x60 #2423 rule applied at hour resolution. Every other night hour reads as before.

Not covered

  • The daytime rMSSD values are not validated against an external reference. Daytime 0x80 beats arrive in clumps of 1–5 while the wearer moves, and a few hours read high (hourly medians 83–86 ms at 10 h and 12 h). This PR restores the beats the read was discarding. It makes no claim about daytime HRV accuracy.
  • Not run: the iOS app build. The change is confined to Packages/WhoopStore and the Android data layer, and no app-target Swift changed.

…eeps the daytime beats

ryanbr#2423 made the scoring read keep ONE Oura beat channel for the whole requested
window: green (0x80) when it holds more beats than the amplitude family
(0x60 + 0x44), the amplitude family otherwise. That is right over a night, where
the two channels overlap. It is wrong over a window that crosses wake, where they
are disjoint in time: 0x60 is banked only overnight and 0x80 carries every daytime
beat. On a real ring, 00:00-17:29 held 38,667 amplitude beats (night only) and
11,377 green (all of the day), so the whole window kept amplitude and dropped every
daytime beat. The Deep Timeline's Windowed rMSSD for "Today" then drew the night
and nothing after ~07:00.

The choice is now made per UTC hour, counted inside the requested window with the
same time/suspect predicates as before, the shape the WHOOP 4 per-hour source
choice already uses. An hour where both channels fire still keeps the fuller one;
an hour where only one fires keeps it. A window inside one hour reads exactly as
the whole-window rule did, so every ryanbr#2423 test is unchanged.

Swift WhoopStore.rrIntervals and Kotlin RR_INTERVALS_SQL change together; a new
twin test with one shared fixture (night hour, green-only hour, mixed hour) fails
on the old read and passes on the new one.

Refs ryanbr#2423

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zPBbdmGTu19hgrMbnwSG1
@ryanbr

ryanbr commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Reviewed at fec4ba6. This is a correctness fix on a scoring read and it holds up. 17 checks green,
mergeable_state: clean. No changes requested.

What I verified rather than took on trust

The test discriminates. Working the fixture's arithmetic by hand: over the whole window it is 18
amplitude against 12 green, so the old rule keeps amplitude and hour 1's five green-only beats vanish
outright. Per hour, hour 0 keeps amplitude (12 v 4), hour 1 keeps the only channel it has, hour 2 keeps
amplitude (6 v 3). That is the reported failure exactly, so the test fails on the old read and is not
merely describing the new one.

The SQL does what the prose says. ts / 3600 buckets by UTC hour with the SAME deviceId, window and
suspect predicates as the outer read; GROUP BY matches the projection; the LEFT JOIN plus the two
leading disjuncts let NULL and non-Oura rows through before keepGreen is ever consulted; and a tie
still falls to the amplitude family, so the #2423 rule is unchanged in shape.

The strict WHOOP 5 branch is extracted verbatim, not reworked. Its srcChannel = (SELECT MIN(...))
subquery over the requested window is the one piece here that must not be touched, and lifting it into
whoop5SourcePredicate leaves it character for character as it was. Splitting the ternary into a third
else if is what made that possible to see.

Parity is real. Same CTE name, same bucket expression, same join and predicates, same ORDER BY and
LIMIT. SELECT rrInterval.* on the Kotlin side is necessary rather than stylistic, since the join adds
columns Room would otherwise try to map onto the entity. The fixtures and expected lists are identical
on both platforms, which is what makes the twin test worth having.

Two things worth stating plainly, both of which the description already does

It changes historical night scoring. 18 hours across 40 nights flip to green, all at sleep edges or
in daytime. That is the #2423 rule applied at hour resolution rather than a new rule, but nights already
scored will move, and that is worth knowing before anyone reads a trend as a physiological change.

Daytime values are restored, not validated. The "Not covered" section says the daytime 0x80 beats
arrive in clumps while the wearer moves and that a few hours read high, with no external reference. Saying
so is the right call: this makes the read stop discarding beats, and claims nothing about what the
resulting daytime HRV means.

The AS MATERIALIZED asymmetry is documented against the existing WHOOP4_RR_INTERVALS_SQL precedent, so
the two platforms diverge for a stated reason rather than by accident.

Real-data evidence of 48 beats and 1 point becoming 10,548 and 367 over 08-17 is the kind that makes the
rest of this easy to check.

@ryanbr
ryanbr merged commit 9670774 into ryanbr:main Oct 2, 2026
17 checks passed
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