Skip to content

fix: correct WHOOP 5 R-R interval units - #2046

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
Trillient:fix/whoop5-rr-units
Sep 10, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
Trillient:fix/whoop5-rr-units

Conversation

@Trillient

@Trillient Trillient commented Sep 10, 2026 •

Copy link
Copy Markdown

What this PR does

WHOOP 5 type-40 and v18 R-R words are decoded as milliseconds even though paired captures on firmware 50.41.1.0 show the same 1/1024-second words as standard BLE 0x2A37. For example, 1024 ticks represent 1000 ms; treating them as milliseconds scales intervals by 2.4%.

Convert those layouts to rounded milliseconds in Swift and Kotlin and preserve raw ticks in the parsed output. Label historical, native-live and standard-BLE observations so scoring uses one transport per requested window: history when available, otherwise standard BLE. Keep the selected stream's beat order through exact-key collisions and invalidate cached scores when source provenance changes.

Existing unlabelled WHOOP 5 rows remain on disk but are excluded from scoring because their units and transport cannot be recovered reliably. Newly captured or re-offloaded labelled rows supply the corrected input. WHOOP 4 and other-device reads retain their existing policy. This does not improve sparse overnight coverage or change the HRV formula.

Raw CSV export retains legacy and all WHOOP transport rows using its existing quarantine exclusions; scoring selection does not filter the export. Additive R-R source indexes keep provenance-sensitive cache checks bounded by indexed queries. Regression coverage includes archived physical owners and the ambiguous canonical alias after re-pairing.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • Hardware evidence: WHOOP 5, firmware 50.41.1.0. In the original private capture analysis, all 131 native-live R-R arrays matched 131 distinct standard-BLE raw arrays, with a maximum arrival difference of 119 ms. At a consistent two-second history alignment, all 94 historical arrays (104 intervals) matched the standard raw words; none matched the already-converted millisecond arrays. Other firmware remains untested.
  • swift test passed for WhoopProtocol, WhoopStore and StrandAnalytics. Coverage includes conversion/packet bounds, legacy-owner policy, transport precedence, repeated equal beats, zero-insert source promotion and cache invalidation. Includes paired, redacted capture fixtures for firmware 50.41.1.0: R-R count/word bytes retain captured values; identities and original timestamps are omitted, other fields are synthetic, and checksums are recomputed. Exhaustive 16-bit conversion oracles and synthetic boundary fixtures supplement these paired cases.
  • macOS app build and targeted collector, paired-capture and source-union tests passed. Store/repository regressions cover the historical canonical owner after re-pairing; both nightly scoring paths carry that policy and include it in their cache witness.
  • Android app compiled; the instrumentation budget guard and mixed-transport CSV regression pass. Full unit suite: 5813 tests, 6 skips, with two failures in the unchanged RecoveryDriversTest exact floating-point half-tie assertions. Both failures reproduce on the unmodified base commit aff58ec3 (expected -0.5, actual -0.4999999999999929).

Checklist

  • Swift package tests pass for any package I touched
  • Android unit tests pass if I touched android/ — two rounding assertion failures described above
  • UI changes use only StrandDesign tokens — no UI changes
  • No hardcoded hex frame bytes in production; protocol facts live in the decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • No generated Xcode project, credentials or unredacted private captures committed

Related issues

Relates to #1505.

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the code and the wire spec rather than the description. The core claim holds and the
evidence is the right kind, but the blast radius of the exclusion is larger than the description
suggests and nothing tells the wearer.

The units claim is right

BLE's 0x2A37 R-R field is defined in 1/1024-second units, so if the type-40 and v18 words share that
encoding then reading them as milliseconds inflates every interval by 2.4 percent. The paired-capture
evidence is the right shape too: 131 native-live arrays matching 131 distinct standard-BLE arrays, and
94 historical arrays matching the raw words while matching none of the already-converted millisecond
arrays, is a discriminating test rather than one coincidence. Whoop5RR.milliseconds rounds to nearest
and 1024 ticks come out as 1000 ms.

What the exclusion actually does

rrIntervals pins one transport per window:

srcChannel = (SELECT MIN(srcChannel) ... WHERE srcChannel IN (5, 7) ...)

An unlabelled row has srcChannel IS NULL, so it never equals the pinned channel, and a window holding
only unlabelled rows returns ZERO beats rather than fewer. That propagates further than "excluded from
scoring" implies:

  • no beats means no avgHrv for the day
  • recomputeRecovery opens with guard let hrvVal = daily.avgHrv ... else { return nil }, so the day
    has no Charge either

Because the analyze pass re-scores a bounded recent window, the effect is a SEAM: the recent stretch
loses HRV and Charge while older banked days keep the scores they already had, computed from the very
rows now judged unusable. A wearer sees recent history hollow out while last month stays intact, which
reads as the app breaking rather than as a correction.

The reasoning for excluding is sound, and I want to be clear I agree with it: legacy rows genuinely mix
correctly-converted standard-BLE milliseconds with wrongly-scaled native words, and nothing on disk
distinguishes them, so scoring the mixture is worse than declining to. My concern is not the policy.

What is missing

Nothing in these 54 files touches UI or copy. There is no empty state, no note on the HRV card, nothing
in the strap log that a reader would find when their Charge history goes quiet. This project's own
conventions are strict about honest-nil being EXPLAINED rather than merely honest, and a silent
regression to "Calibrating" for a WHOOP 5 wearer is the case those conventions exist for.

A single line where the gap shows, saying earlier WHOOP 5 beats cannot be scored and a re-sync restores
them, would turn a mystery into a known state. That is what I would want before this lands.

Two smaller things

The last line of the description pairs a closing keyword with the issue number, which auto-closes that
issue on merge. Please reword it to a plain reference: issues here are closed deliberately, after the
fix is confirmed on hardware. (Quoting the line verbatim here would do the same thing from my side,
which is why it is described rather than shown.)

The reported "two failures in the unchanged RecoveryDriversTest" do not reproduce. On this PR's own
head it is 10 tests, 0 failures, and the identical caveat on your other PR did not reproduce either, on
main or on that head. Whatever that was on the older base is gone, so the Android checklist box can be
ticked and the caveat dropped. Worth mentioning because an unticked "unit tests pass" on a 54-file
protocol change reads as a real risk when it is not one.

Scope

This carries the units conversion, transport labelling, a read policy, cache invalidation and new
indexes. Each is defensible, but a units correction that changes every WHOOP 5 HRV number is the kind
of change worth landing alone, so that if something moves unexpectedly there is one candidate rather
than five. I am not asking for a split if you would rather not, only noting it.

Thanks @Trillient. The decode work here is careful, and the paired-capture method is exactly how this
should be argued.

@ryanbr
ryanbr merged commit 98975f9 into ryanbr:main Sep 10, 2026
16 checks passed
ksricharank pushed a commit to ksricharank/noop that referenced this pull request Sep 21, 2026
…rtialBaseline

The 260921-0740 log, read through the ledgers 18.16 shipped, showed the
Charge flip's real mechanism. The 260906 abort gate stops a full pass at
night N, and everything after it kept running AS a full pass. Two of those
steps are window-wide: the stale eviction deletes every computed row in
[oldestDay, newestDay] the pass did not reproduce, and persistComputedScores
wide-deletes metricSeries + provenance over the same span. oldestDay is the
whole 21-day window regardless of where the scan stopped, so a pass abandoned
after night 1 deleted the other twenty computed days — four times in one
morning (`ABANDONED after 1/21` x4). Today was then written nil (sub-seed
baseline), and with Sep 12-20 gone the Today/widget anchor fell back to the
oldest scored row OUTSIDE the window, immune to eviction: 18 August, Charge 36.
The next foreground pass restored the window and today read 62. Every
abandoned background pass repeated it.

The 260902 light-pass guard describes this exact failure and keyed it on
`lightPass`; the abort gate opened a second route the flag cannot see. Every
post-scan gate now reads `partialBaseline` (light OR abandoned): persist window
bounded to the scanned days, rest points and weekly stages skipped, eviction
not run. A partial pass rewrites what it produced and nothing else. Nothing
changes for a completed pass or a light pass.

A row ryanbr#2115 preserves — a night whose R-R the WHOOP 5 unit policy (ryanbr#2046)
withholds — has no raw to re-derive from; evicted once, it is gone. That is
how Aug 19-Sep 11 lost their HRV/Charge: the fork ran v11.6.0 (ryanbr#2046, no
ryanbr#2115) from Sep 11 to 19 and each full pass overwrote them with nil; the
eviction then removed any survivors. Not relabelled here, by instruction; the
window heals as it slides.

Two ledgers, always-on, counts only:
  - `evict: removed N stale computed day(s)` — only when N > 0. A delete of
    computed history is never silent.
  - `legacyScores preserved=X hrvMissingWithSleep=Y of N` — per completed
    pass. ryanbr#2115 had no log line; this is the number that must only ever fall.
  - `chargePublish` gains `battery=`, for the widget report.

Tests: AbandonedPassBaselineTests grows to 9 — the eviction predicate and the
persist window, both directions. Full 2488-test macOS suite: only the two
pre-existing failures.

Verified: Strand (macOS) + NOOPiOS build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
JohanStefanes added a commit to JohanStefanes/noop that referenced this pull request Oct 16, 2026
…indow

A WHOOP 5/MG window was pinned to the lowest scorable transport present anywhere in it, so one stray
v18 history record outvoted a whole night of standard-BLE beats: the night then scored on the history
alone and its HRV, respiratory rate and recovery came out thin or blank. The choice is now made per
UTC hour, counted over the whole hour like the WHOOP 4 choice: the transport holding more beats wins,
history on a tie. Two transports are still never merged inside an hour; native realtime (6) and
unlabelled legacy rows stay unscored. No BLE change.

Both platforms change together (WhoopStore.rrIntervals and WHOOP5_RR_INTERVALS_SQL), and
Tools/mg_vs_watch.py follows the same rule. The per-window tests are replaced by one per-hour test with
the same assertions on both sides (fuller wins, tie keeps history, choice precedes LIMIT, whole hour
decides, quarantine, realtime). The two nightly-scoring tests that pinned the old behaviour now assert
that a history record in another hour no longer discards the night's standard beats.

Tests: WhoopStore 632/632, StrandTests IntelligenceRRSourceTests 5/5, Android Whoop5RRSqliteTest and
SlidingStreamWindowTest pass, test_mg_vs_watch passes. Not yet checked on a real night.

Refs ryanbr#2046

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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