Skip to content

fix: WHOOP 5 R-R words are milliseconds, not 1/1024-second ticks - #2195

Merged
ryanbr merged 3 commits into
ryanbr:mainfrom
Trillient:pr/whoop5-rr-identity
Sep 14, 2026
Merged

ryanbr merged 3 commits into
ryanbr:mainfrom
Trillient:pr/whoop5-rr-identity

Conversation

@Trillient

@Trillient Trillient commented Sep 14, 2026 •

Copy link
Copy Markdown

What this PR does

Reverses the 1/1024-second conversion introduced in #2046.

That PR matched native and standard BLE raw words and assumed they shared the BLE spec's 1/1024-second unit. The assumption was reasonable — the spec is unambiguous — but it turns out to be wrong for this strap.

Four independent estimators over 71,464 CRC-verified type-40 frames from 19 captures (firmware 50.41.1.0) show the words are already milliseconds:

estimator result expected if 1/1024 s expected if ms
elapsed-time slope (words/sec) 1000.19–1000.76 ~1024 ~1000
phase-feasibility peak 1003 ~1024 ~1000
longest self-consistent beat run 83 @1000 vs 49 @1024 longer @1024 longer @1000
HR-byte cross-check median 990 ~1024 ~1000

Transport parity (same raw words on native type-40/v18 and standard BLE 0x2A37) means the strap sends millisecond values on both channels, non-compliant with the BLE spec.

Why #2046 looked right

The paired-capture evidence in #2046 was real: 131 native-live arrays genuinely matched 131 standard-BLE raw arrays. What we got wrong was the UNIT of those matching words. Matching proves the channels carry the same encoding, but does not prove which encoding that is. BLE 0x2A37 says 1/1024 s, so that is what we assumed — but the strap simply does not comply with that part of the spec.

The new evidence distinguishes the two hypotheses using the strap's own internal consistency rather than the BLE spec, which is why the four estimators above all converge near 1000 (ms) rather than 1024 (ticks).

Impact of the regression

The (ticks * 1000 + 512) / 1024 conversion systematically shrank every interval by ~2.3 %, which:

  • broke tachogram contiguity (max self-consistent chain 126 → 1442 beats after correction)
  • starved the RSA respiratory estimator — overnight coverage drops from 30/43 windows to 17/43 because the cumulative shrink triggers false splice detection across otherwise contiguous beat runs
  • biased RMSSD downward

What changes

Whoop5RR.milliseconds becomes identity on both Swift and Kotlin. Oracle hashes, wire-case ms values, and paired-capture tests updated to assert identity (raw == ms) instead of matching the 1/1024 conversion. Two additional Android test files (FramingTest, Whoop5HistoricalDecodeTest) that independently hardcoded the old converted values are also updated. WHOOP 4 decoding is unchanged.

Type of change

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

How it was tested

  • swift test on WhoopProtocol: 704 passed, 1 skipped, 0 failures — includes the paired-capture, oracle FNV hash, wire-case, and boundary tests from fix: correct WHOOP 5 R-R interval units #2046, all updated to the identity conversion.
  • Cherry-picks clean onto current main (bccd945).
  • External HRV validation against 87 concurrent Garmin overnight readings: MAE 8.899 ms, r = 0.739 with identity conversion vs MAE 10.2 ms, r = 0.68 with the 1/1024 conversion.

Checklist

  • Swift package tests pass for any package I touched
  • Android unit tests pass if I touched android/
  • 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

Reverses the conversion from #2046. Relates to #1505.

Benjamin Woolston added 2 commits September 14, 2026 12:08
…icks

Reverses the conversion introduced in #2046. That PR matched native and
standard BLE raw words and assumed they shared the BLE spec's 1/1024-second
unit. The assumption was reasonable — the spec is unambiguous — but wrong
for this strap.

Four independent estimators over 71,464 CRC-verified type-40 frames from
19 captures (firmware 50.41.1.0) now show the words are already milliseconds:
elapsed-time slopes 1000.19–1000.76, phase-feasibility peak at 1003, longest
self-consistent single-beat run 83 @1000 vs 49 @1024, HR-byte cross-check
median 990. Transport parity (same raw words on native and standard BLE
0x2A37) means the strap sends millisecond values on both channels,
non-compliant with the BLE spec.

The old (ticks * 1000 + 512) / 1024 conversion systematically shrank every
interval by ~2.3 %, which broke tachogram contiguity (max chain 126 → 1442
beats corrected), starved the RSA respiratory estimator, and biased RMSSD.

Whoop5RR.milliseconds is now identity on both Swift and Kotlin. Oracle
hashes, wire-case ms, and paired-capture tests updated to assert identity
(raw == ms) instead of matching the 1/1024 conversion.
FramingTest and Whoop5HistoricalDecodeTest hardcoded the old 1/1024
converted values independently of the oracle fixtures. Update them to
assert the raw millisecond values (603/587, 602/613) that match the
identity conversion.
@ryanbr

ryanbr commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Reviewed at b393c9e. The evidence is strong and the epistemics are better than the evidence.

The key sentence is this one: matching proves the channels carry the same encoding, but does not prove which encoding that is. That is precisely where #2046 went wrong, and it is a failure mode that looks like confirmation at the time. Four estimators that use the strap's own internal consistency rather than the spec, all landing near 1000 and not near 1024, is the right way to break the tie. The elapsed-time slope is the one I find hardest to argue with: summed words against wall clock does not care what the spec says.

The external check earns its place too. MAE 10.2 to 8.899 and r 0.68 to 0.739 against 87 concurrent Garmin nights is an independent instrument agreeing, and it moves the right way by roughly the right amount.

The fix is only half applied

StandardHeartRate.kt still converts round(raw / 1024 * 1000), and this PR leaves it. Your own evidence says the strap sends millisecond words on both channels, and your own test comment says so outright: "Standard BLE parser still applies 1/1024 conversion, wrong for this strap."

That is not theoretical. WhoopBleClient parses standard HR and R-R from 0x2A37 for WHOOP straps, and both paths feed the same R-R store. So after this merges, the same beat is 2.3 % shorter when it arrives over 0x2A37 than over type-40, and which one a given night gets depends on which channel was live. The regression you are removing from one path stays on the other, and the two now disagree with each other rather than both being consistently wrong.

I would rather this landed as one change than as a correct half. Either the standard parser needs a WHOOP-5 branch that skips the conversion, or, if there is a reason the native path is the only one whose R-R is ever stored for a 5, that reason belongs in the description because it is not visible from here.

Smaller notes

The oracle staying as ticks alongside milliseconds is good: identity values are hand-checkable, and a one-sided reintroduction of a conversion would still break the pinned pair, so it has not become circular by becoming trivial.

Reversing a merged decision on better evidence, and saying plainly what the earlier evidence did and did not show, is the right way to do this. 16 of 16 checks. The standard-path gap is the only thing between this and a merge from my side.

WHOOP 5 sends milliseconds on the standard BLE 0x2A37 characteristic,
non-compliant with the BLE spec's 1/1024-second unit. The parser was
applying the spec conversion, creating a 2.3% systematic shrink vs the
native channel (which already uses identity).

Add rrRawTicks to the parser return value on both platforms. WHOOP-5-aware
callers (BLEManager on iOS, WhoopBleClient on Android) now use raw ticks
instead of spec-converted values, eliminating the channel disagreement.
The parser itself remains spec-correct for all other devices.
@ryanbr

ryanbr commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Reviewed at 148d4bb. This is the half I was waiting for, and it lands the fix on both platforms rather than leaving the two channels 2.3% apart. 16 of 16 checks.

The assertion that carries the PR is the one added to Whoop5PairedRRTests and Whoop5RRTest:

XCTAssertEqual(standard.rrRawTicks, rawTicks, "raw ticks match the native channel's raw words")

That is what turns the claim from an inference into a fact. Both channels carry byte-identical u16 words, so exactly one of the two conversions can be right, and the native channel's is the one the strap's own store agrees with. The earlier assertNotEqual showed the two disagreed; this one shows they disagree only in the conversion, which is a much stronger statement.

I checked the family read at both selection points, since both default to WHOOP 4 and a wrong read would silently reinstate the bug:

  • Swift router.family is also set on the iOS state-restoration path (BLEManager.swift:6088), so a restored unattended session gets raw ticks rather than the default .whoop4 conversion. That path does not run connect(), so it was worth confirming; it holds.
  • Android connectedFamily is written only at service discovery, and 0x2A37 cannot notify before its subscribe, so the default-WHOOP4 window is not observable here. connectedFamily is not cleared between links, but the same ordering closes that too.

Both are fine. Flagging that I checked so it does not have to be re-derived later.

One residual, not blocking this. The generic strap source still converts on both platforms:

  • android/.../ble/StandardHrSource.kt:630
  • Strand/BLE/StandardHRSource.swift:473

Both read parsed.rr, so a WHOOP 5 arriving through that path gets exactly the bug this PR fixes. It is symmetric across platforms, so nothing in the parity contract breaks, and the path may simply be unreachable for a 5.0.

That last part is a hardware question I cannot answer from code, and you have the strap: does a WHOOP 5 appear in noop's "HR strap" scan list? That scan filters on an advertised 0x180D. The strap clearly exposes 0x180D once connected, but advertising it is a separate matter. One scan settles it, no capture needed. If it does appear, that path wants the same treatment and I will open something for it; if it does not, there is nothing there to fix.

Minor. The doc line saying callers "should use rrRawTicks" is a convention rather than something the type enforces, and the two source files above are already the case where the convention is not followed. I am not asking for a signature change in this PR, since threading the family through parse would touch five call sites and this change is better landed narrow.

The selection ternaries themselves are uncovered on both sides, while the parser is covered well. Both sites are BLE-bound, so that matches how the rest of this layer is tested and I am not treating it as a gap.

Good to merge from my side. Thanks for splitting it this way, and for the paired-capture method, which is what made the whole thing decidable.

@ryanbr
ryanbr merged commit a78e2a6 into ryanbr:main Sep 14, 2026
16 checks passed
@ryanbr

ryanbr commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Merged as a78e2a6. Thanks again for splitting it, and for the paired capture.

Correcting one thing I wrote above, because it would send whoever picks up the residual in the wrong direction. I said the generic strap source "wants the same treatment", meaning the same raw-ticks selection. Having looked properly at what that path does, that is the wrong prescription.

StandardHrSource persists under its own deviceId, built as "<idPrefix>-<address>" and registered by the add-device wizard. The Swift twin takes a deviceId the same way. So a WHOOP 5 added through the generic strap list is not the same device as far as the datastore is concerned: it writes HrRow and RrRow under a second identity, for the same physical strap, for the same seconds, alongside whatever the WHOOP path is already writing.

That is duplicate data, which is a considerably worse outcome than R-R being 2.3% low, and it is the failure class already on record here. Converting the R-R correctly on that path would make the duplicate rows agree with each other, which is arguably worse still, since the duplication would then be harder to spot.

If a 5.0 turns out to be reachable there, the fix is to keep it out of the generic list rather than to parse it better. The codebase already states that principle, in ExperimentalBrand.kt:

Routing kind stored on a device of this brand (from the catalog); liveBLE fallback (a standard-HR strap, never steals the WHOOP path).

There is no WHOOP exclusion in either StandardHrSource.kt or StandardHRSource.swift today, and the advertised name is available at scan time in both, so the exclusion is cheap if it is needed.

So the open question is unchanged and still worth one scan when convenient: does a WHOOP 5 appear in noop's "HR strap" scan list? Only now a yes means something different from what I said earlier.

A knock-on: if the answer is that a WHOOP 5 has no business on that path at all, then no caller of the parser legitimately needs the family, rr is correct for every caller that should exist, and the "callers should use rrRawTicks" convention stops being a trap on its own. That is a reason not to change the parse signature, so I am dropping that suggestion from my review as well.

Nothing here needs anything from you beyond the scan. Recording it on the PR so the reasoning sits with the change rather than in my head.

tanarchytan added a commit to tanarchytan/noop that referenced this pull request Oct 8, 2026
The 0x2A37 R-R word is 1/1024 s per the BLE spec, but a WHOOP 5/MG sends
plain milliseconds, so the 1000/1024 conversion landed 2.3% short (a raw 512
became 500, which reads as a plausible value). Convert per family through a
pure standardProfileRrMs; WHOOP 4 keeps the spec conversion. Recorded in
docs/ALGORITHMS.md as decode arithmetic still to move to whoop-rs (no FFI
export parses the standard HR measurement).

Cherry-picked from upstream a78e2a6 (ryanbr/noop ryanbr#2195).

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