Skip to content

WHOOP 5.0 v18 historical R-R may be in 1/1024-s ticks, not milliseconds — the same strap converts the same quantity on the 0x2A37 path #1505

Open

Description

@gdorgian

While labelling R-R rows by transport (to chase the cross-second over-count in #1008/#1118), a unit inconsistency showed up that I think is worth a second pair of eyes.

The observation

A WHOOP 5.0 emits the same beat train twice:

  1. Live over the standard Bluetooth Heart Rate Measurement characteristic (0x2A37), where the Bluetooth spec fixes RR-Interval as uint16 in 1/1024 s. Strand/BLE/StandardHeartRate.swift:23 converts it correctly:
    rr.append(Int((Double(raw) / 1024.0 * 1000.0).rounded()))   // 1/1024 s → ms
  2. Again inside its own type-47 / layout-v18 historical record, where Interpreter.swift reads the u16 and stores it directly as rrMs — no conversion.

So one strap appears to be reporting one quantity in two different units, and nothing compared them, because until R-R rows carried a transport label the two copies were indistinguishable in the store.

Evidence

It's suggestive rather than conclusive, so I'd rather raise it than silently diverge.

Physiological cross-check (60000 / mean(R-R) ≈ heart_rate) on the multi-interval v18 records in decoder_oracle.json:

record raw v18 as ms err as 1/1024-s ticks err
hr = 102 [602, 613] 98.8 bpm 3.23 101.1 bpm 0.90
hr = 109 [568, 578] 104.7 bpm 4.29 107.2 bpm 1.76

Both improve under the tick reading.

The single-interval records do not discriminate, and I want to be explicit about that rather than cherry-pick: hr = 101, rr = [595] is better read as milliseconds (err 0.16 vs 2.27). But one interval is an instantaneous beat while the record's heart_rate is an average — normal beat-to-beat variability is larger than the 2.4% the units differ by, so n=1 records can't separate the hypotheses either way.

The existing assertion in Whoop5HistoricalTests is XCTAssertEqual(60000.0 / meanRR, 102, accuracy: 8). Both readings pass at ±8 bpm, which is why this never surfaced.

Scope

Version-local to v18, as far as I can tell:

  • v24 (WHOOP 4) values are already milliseconds — their unconverted values satisfy the cross-check (hr = 63, rr = [870] → 69.0 bpm as ms, 70.6 as ticks).
  • v20 / v21 / v25 / v26 carry no R-R.

Impact if it's right

Every 5.0 historical R-R is ~2.4% long, which reads as a slightly low heart rate and a slightly inflated RMSSD — on exactly the records scoring prefers over the live copy. Small, but systematic, and it moves HRV and the RSA-derived respiratory rate.

The decisive test

Now that R-R rows can be labelled by transport, the same beat can be found in both copies. If v18 were milliseconds the two transports would agree; if it's ticks, the unconverted historical copy is 2.4% longer than the live copy of the same beat. That needs a real strap with both a live session and its historical replay, which is more than my single-device sample can settle on its own — hence the issue rather than a PR.

Happy to open a PR with the conversion plus tests if you agree with the reading, or to drop it if there's a capture that shows v18 is genuinely milliseconds.

Activity

  1. ryanbr commented on Aug 21, 2026

    @ryanbr
    Owner

    Thanks — the transport question is the right one, and the observation that nothing ever compared the two
    copies is correct. Two requests, and one piece of field data you didn't have.

    Please split it

    The transport tagging stands on its own and I'd take it as its own PR. It targets #1008/#1118, which we
    traced independently to the same place: emission clean at ratio ~1.0 while storage read coverage=2.53.
    The re-score fingerprint hole you found is real and separate from all of this.

    The units change should be a second PR, because the two aren't independent in effect. The selection
    prefers the historical copy — which is exactly the copy whose units are in question. Today both copies are
    banked and the bias, if it exists, is diluted; selecting historical exclusively would expose it in full. So
    either the first PR prefers the LIVE copy until the units are settled, or the units land first.

    On the units, one number needs reconciling

    The v18 decoder's own header records that 60000/mean(R-R) ≈ heart_rate holds 88% of the time under
    the millisecond reading. Your change replaces that line rather than reconciling with it.

    (Correcting my own wording there: the header states 88% without a denominator on that bullet — the "500
    records" is attached to the gravity check. I read it as one validation set across all four bullets, which
    is the natural reading, but it is an inference and I shouldn't have written it as though it were stated.
    Whatever the sample was, it is not one record.)

    That statistic is the strongest evidence either way, it's already yours, and recomputing it under both
    readings would likely settle this without hardware. If v18 were 2.4% long, that check should fail
    systematically rather than pass 88% of the time.

    The evidence table

    Two of its four rows are whoop4_v24_synthetic_anchor fixtures — WHOOP 4, and synthetic. A hand-authored
    fixture can't test units: agreement is a property of whoever wrote it. The hr=63 one is internally
    inconsistent by 12% under the millisecond reading it's cited to support.

    Their cited values are also the oracle's × 1.024 (555→568, 564→578, 850→870), while the hr=102 row
    cites [602, 613], which is raw. So the table mixes raw and pre-scaled values, and the two multi-interval
    rows are v24 rather than v18.

    That leaves one real worn v18 record as the whole base. At mean RR 607.5 ms the units question is worth
    2.31 bpm, and beat-to-beat scatter on a two-beat mean is 1.7–5.8 bpm — signal ≈ noise, before counting
    error in the record's own averaged heart_rate.

    Field data you may be able to use

    From the #1451 diagnostic, a single second carrying two R-R rows:

    -1s[872#0, 893#0]
    

    Both ord 0. Per RRInterval.ord's contract, repeated ords mean SEPARATE deliveries, not two beats from
    one record's array. And 893 × 1000/1024 = 872.07.

    That is the two-transport comparison you said needs a strap, and it favours your reading.

    Stated with its limit: 21 ms is also an ordinary beat-to-beat difference, and it's n=1 — the same shape of
    evidence that withdrew #194. More duplicate pairs from that capture, all showing the ratio, would be
    decisive. That plus the 88% recomputation is what I'd want before the conversion lands.

  2. ryanbr commented on Aug 21, 2026

    @ryanbr
    Owner

    One thing my last comment left out: both halves need Kotlin twins, and the work I've seen is Swift-only.

    Today the two platforms agree exactly, which is worth stating because it means the asymmetry you found
    is not a Swift bug — it is the same on both:

    • StandardHeartRate.kt:51 converts 1/1024 s → ms on the 0x2A37 path, with a comment saying it matches
      the Swift parser exactly (there is a second live path at WhoopBleClient.kt:5958 doing the same);
    • HistoricalStreams.kt reads the v18 u16 straight into rrVals with no conversion, exactly as
      Interpreter.swift does;
    • RrSourceChannel exists on both with identical codes.

    So Android reads v18 R-R the same way, and if the tick reading is right, Android is 2.4% long too.

    That makes the parity requirement sharp for both halves:

    Units. rrMs is stored data and crosses the .noopbak boundary. Converting on Swift only would make
    the same bytes decode to different stored values per platform — the one thing the parity contract rules
    out outright. The Kotlin change is small ((ticks * 1000 + 512) / 1024 beside the Swift line, same integer
    rounding so the two can't drift), but it has to be in the same PR.

    Tagging. RRSourceChannel's own doc says the raw values "must stay in lockstep with Kotlin
    RrSourceChannel — they are written to SQLite and cross the .noopbak boundary, so they are a wire
    format". Appending WHOOP transport cases on Swift alone means a restored backup hands Android codes it
    doesn't know.

    One gap you'll hit on the Kotlin side: RrInterval there has no ord, so if the selection keys on ord
    that field needs threading through the Kotlin decode first.

    Struck — I was wrong, please ignore it. ord is assigned at the STORAGE layer on both platforms, not
    at decode: every Swift decode site constructs RRInterval(ts:rrMs:) and the field's own doc says it is
    "nil on a decode (nothing has been stored yet)", while Android computes it in WhoopRepository when
    writing the Room row. Kotlin's decode class omitting the field mirrors Swift's decode never populating it.
    There is no prerequisite there, and I'd have sent you down a pointless path.

    None of this changes the split — it just means each PR is two platforms wide.

  3. gdorgian commented on Aug 21, 2026

    @gdorgian
    Author

    You're right on the table, and the error is worse than mis-citing — thank you for checking it.

    The fabricated row

    hr=109, raw [568, 578] does not exist anywhere. The fixture is whoop4_v24_real_worn at [555, 564]. My script recovered "raw ticks" from stored values by multiplying by 1.024, which is correct for a v18 row (stored post-conversion) and meaningless for a v24 one, which is never converted — its stored values are its raw values. So I manufactured a row and it then agreed with the hypothesis it was cited to support. It was also WHOOP 4, not WHOOP 5, exactly as you said.

    Removing it leaves one real multi-interval v18 record as the entire basis. The two real v18 records:

    record raw as ms as ticks
    whoop5_v18_real_worn (hr 102) [602, 613] 98.77 (−3.23) 101.14 (−0.86)
    whoop5_v18_real_one_rr (hr 101) [595] 100.84 (−0.16) 103.26 (+2.26)

    Mean |residual|: 1.70 bpm as milliseconds, 1.59 as ticks. A tie.

    On the 88% — I think this answers it, and not in my favour

    You asked me to reconcile with the decoder header's 88% rather than replace the line. Recomputing it won't help, and the reason is visible in the fixtures you pointed me at.

    whoop4_v24_real_worn is a layout we never convert, and the one the 88% was measured on. Under the millisecond reading it is supposed to agree. It reads:

    • as milliseconds: 107.24 (−1.76)
    • as ticks: 109.81 (+0.81)

    A record that is milliseconds reads "better" as ticks. The check is not measuring units at this sample size. The units question is worth 2.4–2.6 bpm on all three real records, and the residual scatter on a one- or two-beat mean is the same size or larger.

    So an 88% pass rate is consistent with both readings, and recomputing it under the other one will not separate them. That is a stronger statement than "the evidence is thin" and it is also not the answer I wanted.

    What can settle it, with no new capture

    Your -1s[872#0, 893#0] pair is the right measurement — it just needs to be many pairs rather than one. Once R-R rows carry a transport label, seconds where both transports banked a beat can be found in bulk and their ratios plotted. Two clusters mean opposite things:

    • 1.024 → one beat, two units.
    • ~1.00 → two different beats in one second, which says nothing.
    • 0.977 → only appears once a converting build has banked rows, and means the conversion is wrong.

    I've built that as RRUnitEvidence + a store read, printing to the strap log. It refuses to conclude below 20 pairs — one pair on 1.024 is precisely the #194 shape, and 21 ms is an ordinary beat-to-beat step at 872 ms. I'll report the distribution from a real night rather than argue further from fixtures.

    On the split, and on Kotlin

    Agreed on both, and your amplification point is the one that actually matters: the selection prefers the historical copy, which is the copy whose units are in question, so shipping them together turns a diluted bias into a full one. Tagging first with the live copy preferred would be the safe order if the units aren't settled by then.

    On parity — my fork dropped android/, so I can't test a Kotlin twin honestly, and I'd rather say that than submit one I haven't run. If you want the units change upstream I'll write the Kotlin line beside the Swift one with the same integer rounding for review, flagged as compile-checked only. The tagging half genuinely does need someone who can exercise both sides, given RRSourceChannel is a wire format across .noopbak.

    Thanks also for striking the ord note rather than leaving it — I'd have gone and threaded a field that didn't need it.

  4. gdorgian commented on Aug 27, 2026

    @gdorgian
    Author

    Settled, on a reproducible capture — and it is not the evidence I originally offered

    My earlier basis for this issue was one real v18 record plus a row I had manufactured, which I withdrew above. Replacing it: a public PacketLogger capture that answers the question directly, without any physiological inference.

    Capture: https://github.com/digitalerdude/noop/releases/tag/whoop5-hci-capture-2026-07-10
    Asset SHA-256: 92e8fbb3e3091f6769968f1117b9626470fba80bdb348d3a37140347336fd558

    I downloaded it, verified the digest, and ran the analysis myself:

    sha256=92e8fbb3e3091f6769968f1117b9626470fba80bdb348d3a37140347336fd558
    standard_rr_values=14322
    v18_rr_values=14911
    v18_records_with_rr=14536
    contiguous_match_start=578
    matched_values=14322
    mismatches=0
    lag_seconds_after_clock_offset=1:7044,2:7278
    

    What is being compared

    Two independent sources inside the same HCI capture, both read as raw uint16 before NOOP applies anything:

    1. 0x2A37 Heart Rate Measurement, parsed per the SIG layout (flags bit 0 for 16-bit HR, bit 3 Energy Expended, bit 4 R-R present). The Bluetooth spec fixes this field's unit at 1/1024 s.
    2. WHOOP type-47 layout-v18 historical frames — SOF 0xAA, type at @8, version at @9, embedded unix at @15, count at @23, intervals from @24.

    All 14,322 standard values are a contiguous subsequence of the historical values, with zero mismatches.

    Conclusion

    The v18 historical field carries the same raw integers as a field the spec defines as 1/1024-second ticks. So v18 is ticks, and the version-local ms = (ticks * 1000 + 512) / 1024 conversion is correct as applied.

    The boundary is narrow and worth stating: this is layout v18 in this capture. It says nothing about other layouts — v20/v21/v26 carry no R-R, and WHOOP 4 v24 remains milliseconds. Your point about the Kotlin twins stands; this result applies to both platforms because both read v18 the same way, but the change has to land on both.

    One correction to my own framing

    I previously described the transport lag as fixed. It is not. This capture puts the 0x2A37 delivery 1 or 2 s after v18 embedded time once its known clock offset is removed (7,044 pairs at +1, 7,278 at +2). A later field log from a different strap and firmware contains three checkable equal-value pairs at +3 s.

    So a diagnostic that pairs the two transports must search a lag range rather than require equal timestamps — pairing on the same second finds only different beats and reports a null result. That is a measurement bug I shipped, not a property of the strap.

  5. Zebsi235 commented on Aug 27, 2026

    @Zebsi235

    Your lag correction at the end matches something I had already measured and, until your comment, was reading the wrong way.

    I have six nights of rr dupPairs (#1510) off a real MG, which is the same-second version of this measurement: seconds holding exactly two rows, both ord 0, ratio in parts per thousand. 70,233 qualifying pairs, NOOP 10.6.0-staging on Android:

    night n pairs same (<=1.005) tick (1.019-1.029) other medPPT spread
    08-13 12,983 421 912 11,650 1052 1001-2962
    08-21 12,189 517 1,029 10,643 1074 1001-2630
    08-22 13,239 579 1,378 11,282 1055 1001-2855
    08-24 16,014 761 1,589 13,664 1062 1001-4381
    08-25 13,023 512 1,098 11,413 1068 1001-3286
    08-26 2,785 117 316 2,352 1054 1001-1929
    all 70,233 2,907 (4.1%) 6,322 (9.0%) 61,004 (86.9%)

    There is no cluster at 1.024 anywhere in it. The median ratio is 1.052 to 1.074 on every night, and the tick band holds 9.0% of pairs over 11 ppt of width, which is the same density as the same band's 4.1% over 5 ppt (0.82 vs 0.83% per ppt). Baseline, not a bump.

    Before your comment I had this drafted as evidence against the tick reading. Your capture shows why that would have been wrong twice over. With the two transports 1 to 3 s apart, a same-second pairing can only ever catch different beats, so the null is structural, and these 70k pairs are what that null looks like at volume on real nights: median around 1.05 to 1.07, spread out to 2.6 to 4.4x. And on my captures the live copy never banks at all in the first place (every rr emit line is path=historical, 45 of 45 across two straps), so my pairs are historical against historical before the lag even enters into it. Both push the same way.

    One consequence for the tree, since the conversion is presumably landing now: duplicatePairRatios' own doc says "a tight cluster at 1.024 is a unit mismatch", but the pairs it is restricted to are exactly the ones that cannot contain the signature. Its tick bucket will read baseline whether or not a unit mismatch exists. If it stays after the conversion, it probably wants either a lag-aware window or a comment saying its tick count is not unit evidence, otherwise the next person reads a low tick share as bearing on units, which is precisely the mistake I nearly posted.

    Happy to run a lag-aware version over my store once the tagging lands, or to dump the raw ppt values if the distribution shape is useful for anything else.

    Device: WHOOP MG, hw WS50_r00, fw 50.39.1.0, no subscription, Pixel 9 Pro / Android 15, NOOP 10.6.0-staging. Code read against main at 4c7b1b5.

  6. gdorgian commented on Aug 28, 2026

    @gdorgian
    Author

    @Zebsi235 — this is a useful dataset, and your reading of it is right. Three things back, one of which is a correction to me.

    Your density argument is the correct way to read that table

    9.0% over an 11 ppt window is 0.82%/ppt against the same band's 0.83%/ppt. That is baseline, and counting raw bin totals instead would have made 6,322 look like a finding. Worth stating explicitly because the same trap is what the tick bucket invites.

    Your point about duplicatePairRatios is correct — confirmed independently

    Its doc comment sets up the units frame directly:

    A WHOOP 5 emits the beat train live over 0x2A37 … and again inside its v18 historical record… If those are the same beat in two units, a duplicated second holds two values 1024/1000 apart.

    But the function is restricted to a second holding exactly two rows, both ord 0. If the two transports land 1–3 s apart, that restriction excludes precisely the pairs that could carry the signature. Its tick bucket reads baseline whether or not a unit mismatch exists, exactly as you say. It needs a lag-aware window or a doc line saying its tick count is not unit evidence.

    The one place I'd push back — your evidence, not your conclusion

    every rr emit line is path=historical, 45 of 45 across two straps

    That line cannot say anything else. RrEmissionStats.logLine has exactly one production call site per platform — Strand/Collect/Backfiller.swift:350 and android/app/src/main/java/com/noop/ble/Backfiller.kt — and the label is the string literal path: "historical". 45 of 45 is by construction, not measurement.

    But you are right anyway, and for a stronger reason — there is a platform asymmetry here

    Android — the standard 0x2A37 copy never reaches the store. WhoopBleClient.kt:6631 routes it to _state.update { it.withRRIntervals(rr) }, and withRRIntervals (:234) writes rrRecent, a bounded 60-item rolling buffer for the Live console. insertRr has a single caller, WhoopRepository.kt:501, fed from streams.rr — the decoded frame streams, which the standard profile is not part of.

    iOS — it does. Collector.ingestStandardHR buffers RRInterval(ts:rrMs:) for every plausible standard R-R and flushStandardHR persists it with store.insert(Streams(hr:rr:), deviceId:).

    To be precise about scope: this is the standard 0x2A37 path. The custom REALTIME_DATA (type 40) frames feed streams.rr and do bank on both platforms when realtime streaming is on.

    What that changes

    It corrects my comment on #1331. I wrote that up from an iOS strap log and framed the over-count as two transports generally. On Android there is no second transport in the store to de-duplicate, so transport tagging cannot help there. I'll note that on #1331 too.

    It makes your 70k pairs a clean measurement of something else. They are historical-against-historical before lag enters. Set beside the capture result on #1451 — 14,171 of 18,602 v18 records carry exactly one interval, only 365 carry two or more — a second holding two ord 0 rows is two records stamped onto one timestamp, not one record decoding into two intervals. That points the Android over-count at timestamp assignment rather than at duplication, and your median of 1.05–1.07 with a tail to 2.6–4.4× is what adjacent-beat scatter looks like, not a unit factor.

    And it may mean the 4.0 and 5.0 numbers are two different bugs. The 2.9× figures on this thread are Android; the 1.8× I posted is iOS. Same symptom, possibly not the same cause.

    On your offer

    A lag-aware run is worth doing, but on iOS, where two transports actually coexist in the store. On Android a lag search would compare history against itself and find its own autocorrelation. If it helps, the strongest Android measurement is probably the inverse: for seconds holding two ord 0 rows, how far apart are the two records' embedded timestamps before they were rounded onto the same second.

    Happy to be wrong on any of this — the file:line references above are all on origin/main and quick to check.

  7. ryanbr commented on Aug 28, 2026

    @ryanbr
    Owner

    Thanks @gdorgian — this was the right call to raise rather than PR, and the way you framed it (the
    single-interval records don't discriminate, "suggestive rather than conclusive") is what made it
    checkable. Your reading of the code is correct: case 18: falls through to a block that stores the raw
    u16 labelled "ms" with no conversion, while StandardHeartRate converts raw / 1024 * 1000 on the
    live path. Same strap, same quantity, two units.

    I went looking for more evidence and found some. On it, milliseconds is the better reading — but two
    things in the evidence table need correcting first, and they cut against the scoping rather than the
    conclusion.

    The oracle records aren't quite what the table says

    Both copies of decoder_oracle.json agree, and there are only two distinct real multi-interval
    records
    in the whole file:

    name ver real? hr rr ms err tick err
    whoop5_v18_real_worn 18 yes 102 [602,613] 3.23 0.86
    whoop4_v24_real_worn 24 yes 109 [555,564] 1.76 0.81
    whoop5_v18_real_one_rr 18 yes 101 [595] 0.16 2.26
    whoop4_v24_synthetic_anchor 24 synthetic 63 [850] 7.59 9.28

    The other three frames are synthetic clones of the same payloads testing high-bit handling, so they
    aren't independent evidence.

    Two corrections:

    1. The hr = 109 record is v24, not v18. It's whoop4_v24_real_worn with [555,564]. The cited
      [568,578] and [870] don't appear anywhere in the repo.
    2. That inverts the scoping. The issue says v24 is already milliseconds — but the only real v24
      multi-interval record also favours ticks. The one cited for milliseconds is the synthetic anchor, a
      fabricated frame that can't be evidence about a strap's units.

    So the oracle never could have answered this either way: two real discriminating records, and if the
    tick reading were right it wouldn't be version-local at all.

    A capture with 151 discriminating v18 records

    @digitalerdude's WHOOP 5.0 HCI capture (published for #103) contains a real offload. Decoded with our own
    whoop-decode, all frames crcOK: 3,499 HISTORICAL_DATA frames, 2,852 v18 records carrying R-R, 151
    of them multi-interval with a heart_rate.

                    mean |err|   median      p90
    as ms                 8.29     6.49    16.76
    as ticks              9.53     7.74    17.85
    
    ticks fits better on 21/151 records (14%)
    signed bias    as ms: +6.15 bpm    as ticks: +7.83 bpm
    

    Milliseconds fits better on 86% of real records, and the direction is the part I'd weigh most: the
    intervals already compute an HR above the record's own heart_rate, and reading them as ticks shortens
    the intervals further, so it makes the discrepancy worse rather than better.

    Being fair to your evidence: this capture's records sit at 51–82 bpm where the oracle's are at 102,
    so it is a different regime, and the +6 bpm signed bias in both readings says something else is going on
    between a record's 2 sampled intervals and its averaged heart_rate. Neither of those rescues the tick
    hypothesis, but they're worth knowing.

    Your decisive test is still the decisive test — this capture just can't run it

    It carries both transports, which is rare: 17,941 standard 0x2A37 notifications and 22,335 puffin
    frames. But they don't overlap in time:

    v18 historical records : 2026-07-09 22:57 .. 23:53
    live 0x2A37 stream     : 2026-07-10 01:06 .. 06:05
    

    The offload replays a period the live stream never covered, so no beat exists in both copies. Comparing
    the distributions instead shows a ~10% median difference — four times the 2.4% at stake, and just
    different HR regimes.

    So: not confirmed, and the weight of real data points the other way. If you can get a capture where
    a live session and its own historical replay overlap, that settles it properly — and the transport
    census in #1693 now labels R-R rows by transport, which is the tooling that test needs.

  8. gdorgian commented on Aug 28, 2026

    @gdorgian
    Author

    Thanks for digging into this properly. I think the two transports do overlap in that capture, and that the non-overlap reading comes from comparing the two streams in different clock frames. Everything below is from the same file you used, and it is falsifiable in a couple of minutes.

    The clock frame

    v18 historical records : 2026-07-09 22:57 .. 23:53
    live 0x2A37 stream     : 2026-07-10 01:06 .. 06:05
    

    Your live range is exactly two hours later than mine, which is the capture-clock offset this capture carries (PacketLogger wall clock vs the strap's embedded unix seconds). Put both in strap time and:

    standard 0x22   : 07-09 23:06:56 .. 07-10 04:05:53   (299 min, 13,982 notifications with R-R)
    historical 0x9A3: 07-09 22:57:07 .. 07-10 04:06:35   (309 min, 14,536 v18 records with R-R)
    seconds present in BOTH: 11,177
    

    We agree exactly on the raw counts — 17,941 standard notifications, 22,335 puffin frames — so this is only about how the two are lined up.

    The decisive part: a lag sweep over exact integer equality

    Restricting to seconds where each transport contributed exactly one interval (14,171 historical, 13,567 live), and asking how often live[t] equals hist[t-k] as the same integer:

    lag k pairs exact matches at 1.024
    −4 11,049 36 (0.3%) 277
    −2 11,019 69 (0.6%) 303
    0 10,652 41 (0.4%) 314
    +1 11,901 6,633 (55.7%) 185
    +2 12,295 6,982 (56.8%) 147
    +3 10,673 60 (0.6%) 283
    +5 10,869 52 (0.5%) 290

    Off-lag exact agreement runs 0.3–0.6%; at +1 and +2 it is ~56%. That is a hundredfold enrichment at exactly two adjacent lags, which is what a receive delay looks like. The beats split between +1 and +2 because the notification boundary falls either side of the second.

    And the part that answers the units question: at the winning lags the 1.024 bucket goes down, to below its own off-lag baseline. The two transports do not carry values 2.4% apart. They carry the same integers.

    Raw, unconverted, from the same second (strap clock):

    01:25:07  hist rr=[839]   live rr=[833]      live[t] == hist[t-1]
    01:25:08  hist rr=[1451]  live rr=[839]
    01:25:11  hist rr=[1542]  live rr=[1451]
    01:25:15  hist rr=[1456]  live rr=[1357]
    

    Both sides read straight off the wire — @24 in the v18 record, and the R-R field of the HR Measurement. The spec fixes the second one at 1/1024 s. So the first one is too.

    On the 151-record fit

    I do not think that test can separate the two readings, and I would say the same if it had come out my way. The effect at stake is 2.4%, about 1.5 bpm at these rates. Your own figures put mean |err| at 8.29 bpm as ms and 9.53 as ticks — four to six times the effect — with a +6.15 / +7.83 bpm signed bias in both readings. A systematic bias present under both hypotheses says the record's heart_rate byte and its 1–2 sampled intervals are not describing the same window, which is the thing that has to be true for the comparison to mean anything. 86/14 on a statistic whose noise floor is several times the signal is not a discrimination.

    That is the same reason I withdrew my own oracle argument, and your corrections to that table were right.

    One thing worth checking your side

    Your decode yields 2,852 v18 records with R-R ending 23:53; the same file gives me 14,536 ending 04:06. Both are ~0.8 records/second, so it is not thinning — it looks like the decode stops partway. If whoop-decode is truncating this capture at 23:53, that alone would remove the entire overlap window and is worth a look independent of this issue.

    Repro

    The parser is about 40 lines and handle-filtered (0x22 standard, 0x9A3 puffin). Asset SHA-256 92e8fbb3e3091f6769968f1117b9626470fba80bdb348d3a37140347336fd558. Happy to push the lag-sweep script wherever is useful, or to be shown where it is wrong — the sharpest way to kill it would be a handle or framing mistake on my side, and both are quick to check.

  9. ayiskakov commented on Sep 21, 2026

    @ayiskakov

    Independent-hardware confirmation that WHOOP 5 sends milliseconds on both transports, from a WHOOP MG on fw 50.39.1.0 (one firmware below the 50.41.1.0 the #2046/#2195 conclusion was drawn on).

    Cross-transport census of a 4-night .noopbak (314k labelled beats): compared v18 historical (srcChannel 5) against the standard 0x2A37 profile (srcChannel 7), matched hour-by-hour over buckets where both carry ≥30 beats.

    historical/standard mean-RR ratio: median 0.9998, mean 1.0002 (61 matched hourly buckets)
    57/61 buckets within ±2% of parity
    implied HR: historical 65.9 bpm, standard 66.4 bpm
    

    If v18 were 1/1024-s ticks misread as ms, the ratio would sit at ~1.0240; it doesn't. The two channels also land on the same discrete millisecond grid (both cluster at 894/901/902/918/920 ms), which a tick-based series would not. So this firmware corroborates the ms-on-both conclusion rather than reopening it.

    (One artifact surfaced while doing this — an exact-500 ms fill value banked unflagged on both transports — filed as #2371 — a data-hygiene issue, not a units question.)

  10. meta1971 commented on Oct 27, 2026

    @meta1971

    Data from an MG (WS50_r00, fw 50.40.1.0), v18 per-second records only; I did not look at 0x2A37 or packet 40. Labels as in #891.

    OBSERVED: the interval words in v18 bytes 24 to 31 are milliseconds, not 1/1024 s ticks. docs/PROTOCOL_SENSORS.md (the R18 table, bytes 24 + 2i) lists them as 1/1024 s with the rounded-millisecond conversion of packet 40. Applied to these words that conversion would read every interval 2.3% short.

    Estimator 1, elapsed time: 300 s windows of consecutive per-second records whose beat count is within 3% of the count expected from the records' own heart-rate byte (4,804 windows, 24 Sep to 24 Oct). The words sum to 999.7 per elapsed second (10th to 90th percentile 996.5 to 1,002.0). Ticks of 1/1024 s would give about 1,024.

    Estimator 2, per beat: words per beat times mean heart rate divided by 60, over 62,564 windows of 60 s: median 1,001.5 (25th to 75th percentile 997.9 to 1,015.0), against about 1,024 for ticks.

    Neither needs a reference device. Both select windows with the heart-rate byte, which does not depend on the unit. This is the stored v18 path. It agrees with the cross-transport result above for fw 50.39.1.0 and with #2195 (v11.7.0), whose evidence was type-40 frames on fw 50.41.1.0 and whose release note describes the correction for the standard heart-rate profile path.

    Checked: the figures are from the full-corpus script output (4,804; 999.7; 996.5 to 1,002.0; 62,564; 1,001.5; 997.9 to 1,015.0) and the same two estimators on the 24 to 26 Oct records alone (158,778 seconds) give 999.9 (123 windows) and a median 1,003.1 (1,929 windows). The doc line is from the 26 Oct copy of main, and I have not checked whether it has changed since; the 2.3% is 1,024 / 1,000.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions