Skip to content

WHOOP 5/MG v26 optical windows are stored as deltas, and the absolute base is discarded #2019

Open

Description

@ryanbr

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

  1. meta1971 commented on Oct 25, 2026

    @meta1971

    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.

  2. andynilson commented on Oct 28, 2026

    @andynilson

    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 ppgWaveformSample table. That's 133,144 records with a baseCode and 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.

    read as u32 (today) read as i32
    corr(second-to-second change of the base, sum of the 24 values) −0.076 0.972
    zero-crossing pairs where base + sum lands nearest the next base 0 of 279 279 of 279

    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.swift already 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:

    • reads frame-abs 23 as i32 in decodeWhoop5HistoricalV26 and its Kotlin twin, with an oracle test over the boundary cases;
    • adds a versioned migration on both GRDB and Room that rewrites stored rows with baseCode ≥ 2^31 to baseCode − 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.

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