You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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:
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
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.
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.
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.
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.
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.
Two independent sources inside the same HCI capture, both read as raw uint16 before NOOP applies anything:
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.
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.
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.
@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.
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:
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.
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.
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.
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.)
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.
Activity
ryanbr commentedon Aug 21, 2026
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_rateholds 88% of the time underthe 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_anchorfixtures — WHOOP 4, and synthetic. A hand-authoredfixture can't test units: agreement is a property of whoever wrote it. The
hr=63one is internallyinconsistent 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 thehr=102rowcites
[602, 613], which is raw. So the table mixes raw and pre-scaled values, and the two multi-intervalrows 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:
Both
ord 0. PerRRInterval.ord's contract, repeated ords mean SEPARATE deliveries, not two beats fromone 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.
ryanbr commentedon Aug 21, 2026
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:51converts1/1024 s → mson the0x2A37path, with a comment saying it matchesthe Swift parser exactly (there is a second live path at
WhoopBleClient.kt:5958doing the same);HistoricalStreams.ktreads the v18u16straight intorrValswith no conversion, exactly asInterpreter.swiftdoes;RrSourceChannelexists 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.
rrMsis stored data and crosses the.noopbakboundary. Converting on Swift only would makethe 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) / 1024beside the Swift line, same integerrounding 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 KotlinRrSourceChannel— they are written to SQLite and cross the.noopbakboundary, so they are a wireformat". 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:RrIntervalthere has noord, so if the selection keys onordthat field needs threading through the Kotlin decode first.
Struck — I was wrong, please ignore it.
ordis assigned at the STORAGE layer on both platforms, notat 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
WhoopRepositorywhenwriting 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.
Tally per second, and a width the multiply can't outgrow (#1505)
Measure how a duplicated second's two copies compare (#1510)
gdorgian commentedon Aug 21, 2026
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 iswhoop4_v24_real_wornat[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:
whoop5_v18_real_worn(hr 102)[602, 613]whoop5_v18_real_one_rr(hr 101)[595]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_wornis a layout we never convert, and the one the 88% was measured on. Under the millisecond reading it is supposed to agree. It reads: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: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, givenRRSourceChannelis a wire format across.noopbak.Thanks also for striking the
ordnote rather than leaving it — I'd have gone and threaded a field that didn't need it.gdorgian commentedon Aug 27, 2026
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:
92e8fbb3e3091f6769968f1117b9626470fba80bdb348d3a37140347336fd558I downloaded it, verified the digest, and ran the analysis myself:
What is being compared
Two independent sources inside the same HCI capture, both read as raw uint16 before NOOP applies anything:
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) / 1024conversion 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.
Zebsi235 commentedon Aug 27, 2026
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: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
sameband'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 emitline ispath=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. Itstickbucket 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.
gdorgian commentedon Aug 28, 2026
@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
sameband'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 thetickbucket invites.Your point about
duplicatePairRatiosis correct — confirmed independentlyIts doc comment sets up the units frame directly:
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. Itstickbucket 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
That line cannot say anything else.
RrEmissionStats.logLinehas exactly one production call site per platform —Strand/Collect/Backfiller.swift:350andandroid/app/src/main/java/com/noop/ble/Backfiller.kt— and the label is the string literalpath: "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
0x2A37copy never reaches the store.WhoopBleClient.kt:6631routes it to_state.update { it.withRRIntervals(rr) }, andwithRRIntervals(:234) writesrrRecent, a bounded 60-item rolling buffer for the Live console.insertRrhas a single caller,WhoopRepository.kt:501, fed fromstreams.rr— the decoded frame streams, which the standard profile is not part of.iOS — it does.
Collector.ingestStandardHRbuffersRRInterval(ts:rrMs:)for every plausible standard R-R andflushStandardHRpersists it withstore.insert(Streams(hr:rr:), deviceId:).To be precise about scope: this is the standard
0x2A37path. The customREALTIME_DATA(type 40) frames feedstreams.rrand 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 0rows 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 0rows, 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/mainand quick to check.ryanbr commentedon Aug 28, 2026
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 rawu16labelled"ms"with no conversion, whileStandardHeartRateconvertsraw / 1024 * 1000on thelive 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.jsonagree, and there are only two distinct real multi-intervalrecords in the whole file:
whoop5_v18_real_worn[602,613]whoop4_v24_real_worn[555,564]whoop5_v18_real_one_rr[595]whoop4_v24_synthetic_anchor[850]The other three frames are synthetic clones of the same payloads testing high-bit handling, so they
aren't independent evidence.
Two corrections:
hr = 109record is v24, not v18. It'swhoop4_v24_real_wornwith[555,564]. The cited[568,578]and[870]don't appear anywhere in the repo.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 framescrcOK: 3,499HISTORICAL_DATAframes, 2,852 v18 records carrying R-R, 151of them multi-interval with a
heart_rate.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 shortensthe 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 tickhypothesis, 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
0x2A37notifications and 22,335 puffinframes. But they don't overlap in time:
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.
test(protocol): say what the v18 cross-check actually guards, which i…
gdorgian commentedon Aug 28, 2026
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
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:
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]equalshist[t-k]as the same integer: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):
Both sides read straight off the wire —
@24in 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_ratebyte 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-decodeis 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 (
0x22standard,0x9A3puffin). Asset SHA-25692e8fbb3e3091f6769968f1117b9626470fba80bdb348d3a37140347336fd558. 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.test(protocol): the v18 R-R cross-check asserted on literals and coul…
Explain the WHOOP 5 nights that cannot be scored (#1505)
ayiskakov commentedon Sep 21, 2026
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.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.)
fix(analytics): start WHOOP 5 HRV comparisons at the labelled RR boun…
meta1971 commentedon Oct 27, 2026
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.