Repository navigation
Description
What
The WHOOP 5/MG v26 record (raw optical PPG) encodes a 25-sample window as one absolute ADC code
followed by 24 deltas. NOOP reads only the 24 deltas, stores them as if they were the samples, and
discards the absolute base entirely.
decodeWhoop5HistoricalV26 reads histI16 from frame-absolute 27 to 75 and returns those 24 values
as samples. The absolute code sits in the 4 bytes immediately before them, at frame-absolute 23, and
nothing reads it.
Evidence
From the real captured frame already in Whoop5PpgWaveformStreamTest (unix 1780917232):
| frame-abs 23..27, u32 LE | 378,307, a valid 20-bit optical code (max 1,048,575) |
| our 24 stored "samples" | -1432, -1332, -1139 ... -164, every one negative |
| base + cumulative deltas | 25 samples, all in 20-bit range, 378,307 down to 362,532 |
| excursion | 15,775 = 4.2% of base, a textbook PPG perfusion index |
An absolute optical ADC code cannot be negative. Ours are all negative, and a plausible absolute code
sits immediately before them at exactly the right offset. 24 deltas plus 1 base is 25 samples, which
also matches the 25-sample optical block v20 carries.
Impact
No shipped metric is wrong. ppgWaveformSample is referenced only in the data layer; nothing in
analytics reads it, and the strap's own PPG-derived heart rate comes from ppgHrSample, a different
stream.
The damage is to the corpus being banked for the work this stream exists for:
- It is a DERIVATIVE series stored as a waveform. Differentiation preserves the fundamental frequency,
so a future PPG-derived heart rate would probably still find a plausible rate. That is worse than an
obvious failure, because it would look like it worked. - The DC level is discarded. That is precisely what an SpO2 ratio-of-ratios needs, and SpO2 encoding is
itself an open question here (spo2_candidate_82).
It is not repairable retroactively. The base was never stored, and a delta series cannot be
inverted without it, so every v26 record ingested to date is unrecoverable. Only future records can be
right, which is the argument for fixing it sooner rather than later.
Fix
Store what the wire actually carries: the base AND the deltas. The samples are packed as little-endian
i16, and an absolute code near 378,000 does not fit in an i16, so re-encoding the reconstructed
absolutes in the existing blob is not an option. A nullable base column alongside the existing delta
blob is both faithful to the wire and self-describing: a null base means a legacy row whose absolute
level is gone, which is exactly true.
Reconstruction is sample[0] = base, sample[i+1] = sample[i] + delta[i], and belongs in one shared
helper with a Kotlin twin rather than at each future call site.
One caveat: the deltas are SATURATED, clamped at the i16 bounds, so a window containing a saturated
delta reconstructs only approximately. The captured frame above has a largest magnitude of 1,833,
nowhere near 32,768, so it does not bite there, but a reconstruction should record where it did.
Provenance
Found by comparing against OpenStrap's Dart decoder (MIT), which documents the v26 window as an
absolute first sample plus 24 saturated deltas. No code taken. The offset it names was then verified
against our own captured frame, which is what the table above is.
Activity
protocol: pin the reading the two widths disagree on, and stop descri…
Read the v26 per-burst counter as the u16 it is (#2021)
meta1971 commentedon Oct 25, 2026
Confirming data for the v26 reading, one MG (fw 50.40.1.0), 24 Sep to 25 Oct: 50,882 unique v26 records, 49,580 pairs of consecutive-second records.
OBSERVED: the u32 at @23 moves with the 24 values as a running sum. The correlation between the second-to-second change of the base and the sum of the 24 values is 0.958 (it would be +1 if the values were pure increments). The median absolute base change per second is 2,293, and the median of |base + sum of the values - next record's base| is 388, about 17% of one second's change. So the running sum accounts for most of the move. I have not separated what is left between saturated deltas and the step from one record's last sample to the next record's first.
OBSERVED: the base is signed. Read as int32 it is negative in 6.6% of records (median 166,158), so on this strap it is not confined to 0 to 1,048,575, and a u32 read, or that range as a validity check, would misread those records. An i32 column would hold it.
So the reading in the issue fits this strap, and storing the base alongside the deltas is what the wire carries.
andynilson commentedon Oct 28, 2026
A second strap agrees with @meta1971: the base at frame-abs 23 is signed. Data: one WHOOP 5.0, currently on fw 50.40.1.0 (the same firmware as the MG above), 10 Sep to 28 Oct, read from the app's own
ppgWaveformSampletable. That's 133,144 records with abaseCodeand 129,777 consecutive-second pairs.OBSERVED: 1,570 stored bases (1.18%) are ≥ 2^31, so they are negative when read as i32 (−98,433 to −24, median −21,587). That's a lower share than the 6.6% on the MG, but it isn't zero.
OBSERVED: the running sum only holds across zero under the signed reading. 279 consecutive pairs have one base ≥ 2^31 and the other below it. In all 279, base + sum of the 24 values lands closer to the next base when both are read as i32. None fit better as u32.
For comparison with the MG numbers: the median absolute base change per second here is 3,574, and the median of |base + sum − next base| is 489.
OBSERVED: every value fits the signed 20-bit range. The maximum is exactly 524,287 (2^19 − 1), reached by 13 records with none in the 1,000 below it, and nothing is below −524,288. INFERRED: this is the encoding
Whoop5RawOptical.swiftalready documents for the v20 samples: signed 20-bit, sign-extended into a 4-byte container, with the positive rail exactly on 2^19 − 1, so "a plain signed 32-bit read is the whole decode". If so, the ceiling is 524,287, not 1,048,575, and a 0 to 1,048,575 validity check would reject the negative records, as @meta1971 noted.If it helps, I can open a PR that:
decodeWhoop5HistoricalV26and its Kotlin twin, with an oracle test over the boundary cases;baseCode ≥ 2^31tobaseCode − 2^32, which is exact, with a test on each side.Nothing reads the table in production yet, so no displayed value changes. If you'd rather leave old rows as they are, the migration can stay out.