Skip to content

SpO₂ strap estimate (W3): per-reading nightly figures and truthful copy - #32

Merged
ayiskakov merged 2 commits into
mainfrom
review/w03-spo2-windows
Sep 26, 2026
Merged

ayiskakov merged 2 commits into
mainfrom
review/w03-spo2-windows

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

W3 Analytics, taken out of phase at the owner's request after the BLE-dossier fact-check. The WHOOP 5/MG SpO₂ strap estimate (byte 82 of the v18 record) is now resolved per strap reading instead of per second, and the copy stops saying the strap reports it every second. Finding rows are in docs/review/workstreams/W03-analytics.md, one commit per fix. Nothing here changes what the app sends to the strap or what it stores in spo2Pct; the estimate stays behind the default-off Experimental toggle.

What the strap actually does

Measured on one MG (WS50_r00, firmware 50.39.1.0), 588,883 records over seven nights, now in docs/PROTOCOL_SENSORS.md ("Byte 82 measurement windows"):

  • The byte is nonzero only in runs of exactly 30 records: 175 windows.
  • The first window of a SLEEP bout starts 1173–1175 s after the bout's first SLEEP record, then one every 1200 s. That rule predicts all 175 windows, with no miss and no extra, over 15 bouts. The phase against unix time therefore changes from night to night.
  • The byte is nonzero in exactly the records whose status words at 77 and 79 leave their idle pair, with no exception either way. Zero means "not measuring", and a code means a measurement that gave no value.
  • Inside a window the value changes about every five records and can blip for a single record, so the window is the reading.
  • The strap never paired with the official app, has no account and never received the R22 sequence, and gave in-band values from its first night: no app-side unlock is needed on this strap and firmware.

Fixes

  • W03-003 (S2): the night's low, dips, mean and evidence count were per second, so a one-second blip inside a steady window became the night's low and a dip no window showed. nightlySpo2CandidateNight now groups nonzero seconds into windows (no two more than 30 s apart, none spanning 60 s or more) and values each window by the lower median of its in-band seconds. The mean, low, high and dips are per window. New windows and windowsAttempted are stored under two new metricSeries keys, which also mark a night resolved this way. The card shows readings as valid of attempted, counts dip readings without claiming a duration, and shows dashes for the low and dips of a night scored before this change. The strap-log line names the windows. validate_spo2_candidate.py values a window the same way, so the tool and the app give one nightly figure.
  • W03-004 (S4): the card footnote and the Settings toggle said the strap reports the value every second. They now describe the 30-second readings, scoped to the straps measured so far, and WHOOP5_DEEP_DATA.md no longer describes per-second runs.
  • Also: the Polish "%lld of %lld" read "or" instead of "of"; the Italian "Readings" was missing.

Recorded, not changed

  • W03-005 (S4, predates this PR): the strap-log line resolves over the one stored session it prints, while the scoring pass uses all of the day's sessions, so on a two-session day its "shown as" figure can differ from the screen.
  • A night whose readings all failed still resolves to no result, as before, so the card cannot show "0 of N".
  • The night chart still plots raw seconds; its caption now says so and says when a reading is a dip.

Verification

  • V0: testASingleSecondBlipInsideAWindowIsNotADip fails on the old resolver: one dip and a low of 77 inside a 95–96 window.
  • V1: the oracle is re-pinned from the helper's own output and matched line for line by an independent Python implementation of the rule (17 cases, including a night of real-shaped windows). Named tests pin the gap budget, the window cap, a continuous stream, a clock-stepped window, code-only windows and the lower median.
  • V2: an independent reviewer and /code-review high. Fixed from them: no cap on window length, then a cap with no margin for a clock step (now 60 s); a dip caption that claimed seconds below the threshold; per-second figures of pre-change nights under per-reading copy; the tool valuing a window by its mean; stale comments and copy.
  • V3: on all eleven owner backups the resolver matches the independent implementation night by night. Replayed on seven stored nights against main: in-band seconds unchanged on all seven, the displayed mean moves on one night by one point, the low rises on six, and dips fall from ten to one, the one left being a whole window below the threshold.
  • swift test in StrandAnalytics: 2,197 run, 2 skipped, 0 failures. StrandTests: 1,992 run, 2 skipped, 0 failures. Strand and NOOPiOS build; NOOPiOS was built without the watch app. Tools/linux-capture: 235 tests pass. The doc-comment, protocol-example, source-reference and i18n gates pass. The four core Tools/ failures are W11-001 (macOS /private/var), unchanged on main.
  • Not tested on a device. At the next strap run: with the toggle on, a night on this build should show "N of M" readings on the Blood Oxygen card, and the strap log's SpO₂ line should name the windows.

The strap does not report byte 82 every second. It measures in windows of 30
records, 1200 s apart while the band scores SLEEP, and inside a window its value
is a rolling figure that moves a few points and can blip for a single second. The
night's low, its dips and its evidence count were resolved per second, so a
one-second blip inside a steady window became the night's low and a dip that no
window showed, and the count was seconds of one measurement.

nightlySpo2CandidateNight now groups nonzero seconds into windows (no two more
than 30 s apart, none spanning 60 s or more, so a strap that reported the byte
continuously is still cut into readings) and values each window by the median
of its in-band seconds, the lower middle value for an even count. The mean, low,
high and dips are per window. `windows` and `windowsAttempted` are new, stored
under two new metricSeries keys whose presence also marks a night resolved this
way. The dip properties are renamed `dipSamples` and `dipSpanSeconds`: they are
the dip readings' seconds and measured span, not time below the threshold.

The Blood Oxygen card shows readings as valid of attempted, counts dip readings
without claiming a duration, and shows dashes for the low and dips of a night
scored before this change. The strap-log line names the windows.
validate_spo2_candidate.py values a window the same way, so the comparison tool
and the app give one nightly figure. PROTOCOL_SENSORS.md records the window
facts measured on one MG: the schedule predicts 175 of 175 windows, and the
status words at 77 and 79 mark every window with no exception. Also corrects
the Polish "%lld of %lld" (it read "or") and adds the Italian "Readings".

Replayed on seven stored nights against main: in-band seconds unchanged, the
displayed mean moves on one night by one point, the low rises on six, and dips
fall from ten to one, the one left being a whole window below the threshold. On
all eleven backups the resolver matches an independent Python implementation
night by night. The oracle is re-pinned and matched line for line by the same
implementation.

W03-003; W03-005 recorded from its review.
The Blood Oxygen card's footnote and the Settings toggle said the strap sends the
byte 82 candidate every second. On the straps measured so far it takes a
30-second reading about every 20 minutes once the band scores the wearer asleep,
and some readings fail without a value. Both now say that, scoped to the straps
measured, and the card says a reading counts once, by its middle value, so a
one-second blip inside it is not a dip. The night chart's caption says each point
is one second the strap reported, and when a reading is a dip. WHOOP5_DEEP_DATA.md
described per-second runs; it now describes the readings. Translated for the
nine catalogue languages.

W03-004.
@ayiskakov
ayiskakov merged commit 706162b into main Sep 26, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant