Skip to content

Analytics (W3): leave low-quality SpO₂ candidate readings out of the night, one read for every surface - #40

Merged
ayiskakov merged 15 commits into
mainfrom
review/w03-spo2-quality
Sep 30, 2026
Merged

ayiskakov merged 15 commits into
mainfrom
review/w03-spo2-quality

Conversation

@ayiskakov

Copy link
Copy Markdown
Owner

Out-of-phase W3 fix of the whole-project review, from the owner's 2026-09-30 strap export: two nights of low SpO₂ candidate readings that turned out to be a strap-fit problem, not desaturation. W03-007 adds a reading-quality gate to the byte-82 candidate. It is followed by the fixes from its independent V2 review and from a /code-review xhigh pass (W03-009 … W03-022). One commit per fix; the tracker rows are in docs/review/workstreams/W03-analytics.md.

What the data showed

The strap measures byte 82 in 30-second readings about every 20 minutes of band SLEEP. Across 192 in-session readings of one MG:

  • On the two affected nights, 81–83 % of readings were mixed with the strap's own non-percentage codes (32, 128, 160). On the six nights before, it was 17–60 %.
  • The in-band fragments between those codes jump, or settle low. Every low reading on those two nights but one was such a reading.
  • Readings without codes agree with the earlier nights.
  • Neither the status words at 77/79 nor bit 7 of byte 36 separates code records from in-band records. The byte's own codes are the only per-record quality marker observed.

docs/PROTOCOL_SENSORS.md §Byte 82 measurement windows records this as observation, not meaning.

What the literature says (research summary, cited as found)

  • Discard rates: validated wrist-oximetry studies discard 15–50 % of overnight data by their own quality indices (Braun 2020 and 2021, EMBC; Galaxy Watch studies).
  • Regulation: the FDA's January 2025 draft pulse-oximeter guidance asks for "an indicator of signal inadequacy".
  • WHOOP's own help text, as quoted in search results (the page itself could not be opened): samples without clean signal quality are discarded.
  • Sampling: one 30-second reading about every 20 minutes cannot show a desaturation event, only a low reading.
  • No published rule exists for this byte, so the thresholds below come from this one strap, without a reference oximeter.

Fixes

W03-007 (S3): a reading counts only when the strap reported it well

A reading counts when all three hold:

  • it has at least 10 in-band seconds;
  • at least two thirds of its measured seconds are in band (not codes);
  • at least three fifths of those lie within 2 points of its median.

The gate judges how the reading was reported, never whether it is low: a steady, well-covered low reading stays a low reading. On the good-fit nights 80–100 % of readings pass, and the night's figures barely move anywhere between one half and four fifths coverage.

  • No reliable reading. A night whose readings all fail is stated as having no reliable reading (the owner's choice), not averaged and not skipped. A night of codes-only readings says only that none produced a value (W03-010).
  • Stored data. The stored series gain spo2_candidate_windows_low_quality, which also marks a gated night. A gated night's stale mean and minimum are deleted after a persist that landed.
  • Card.
    • It names the readings left out, with plural forms in nine locales.
    • "Dips" becomes "Low readings", and the Low tile says "below 90%".
    • Its chart plots only reliable readings.
  • Strap log. The line says how many readings were left out, and why.

W03-013 (S3): a floor of 10 in-band seconds

Both gates are ratios, so a reading cut to one second by a session edge passed both. No real reading moves: on 15 backups every reliable reading has 20 or more in-band seconds.

W03-009, W03-011, W03-012, W03-015 … W03-018: one read for every surface

The Health tile, classic and Liquid Today, and the Metric Explorer each resolved the candidate night on their own. The Health tile carried an older night's mean past a newest night with no reliable reading. Today rounded half-to-even (%.0f) where the card rounds half away from zero.

Spo2CandidateSeries.Read (package, tested) now holds every candidate series and answers:

  • the newest night;
  • a given day's night;
  • the nightly means, leaving out a mean left on a night with no reliable reading.

Repository.spo2CandidateSeries loads it once, concurrently, and every surface resolves through it. Leftover means are therefore handled on the read side, rather than by widening a delete across computed ids (W02-005 showed how a delete over the shared canonical id loses data).

Smaller fixes

  • W03-014: the chart's per-strap merge gives a second to the first id with a byte-82 value, and runs off the main actor.
  • W03-019: the legacy mean's doc says what nil means now.
  • V2 (independent subagent):
    • the reading-count caption no longer claims a pass on a codes-only night;
    • the notice no longer asserts a cause;
    • the German "Readings" label reads "Messungen";
    • stale comments and the validator's docstring are corrected.

Recorded, not fixed

  • W03-008: the card's chart resolves over the longest session and the strap-id union, while the figures resolve over the day's sessions for the scoring owner. A reading straddling a session edge can be judged differently. Not yet looked for in the owner's data.
  • W03-022: the first four commits do not carry their tracker rows; every later one does. Squashing on merge settles it.

Validation

  • V0: the fixes with a test fail on the code before them (false dips from a noisy reading; a one-second fragment as the night's low; the tile carrying an older mean). W03-012 was reproduced standalone: %.0f prints 96 for 96.5, where the card shows 97.
  • V1:
    • the oracle (31 cases) was re-captured and matched line for line by an independent Python implementation;
    • on 108 real sessions across 15 backups the Swift resolver and that implementation agree on every figure;
    • removing the merge fix's value check fails its test.
  • V2: an independent subagent review (1 S2, 4 S3, 8 S4), then /code-review xhigh (14 findings, each re-verified before its fix).
  • Tests: package suite 2,213 and StrandTests 2,128, 0 failures.
  • Builds: Strand builds, and NOOPiOS builds (without the watch app; no watchOS runtime on this Mac).
  • Hygiene: doc_comment_lint, the protocol source-reference check and the diff-scoped i18n gate pass.
  • Strap run due:
    • a night on the build whose card reads "N of M" with the readings left out named, plus its strap-log line;
    • ideally one night with the strap deliberately loose, to see "no reliable reading".

…night (W03-007)

A byte-82 reading counts only when at least two thirds of its measured
seconds carry an in-band value and at least three fifths of those sit
within 2 points of its median. Loose-strap readings, mixed with the
strap's own codes, no longer become the night's low or its dips. A
night whose readings all fail is a night with no reliable reading, not
nil. The stored series gain spo2_candidate_windows_low_quality, the
marker of a gated night; latest() shows such a night instead of
skipping to an older one.

Oracle re-captured (28 cases) and matched by an independent Python
twin; 108 real sessions across 15 backups agree between the two.
…-007 V2)

Spo2CandidateSeries.Night.readingsNote keeps a reading left out as low
quality apart from a reading of codes only, so a codes-only night no
longer reads "passed the quality check" beside "none passed". Adds
boundary tests at the 30-second shape and narrows the trace helper's
claim to identical inputs.
The series writer stores the low-quality count and deletes a gated
night's stale mean and minimum only after a persist that landed. The
strap-estimate card says "no reliable reading" when every reading
failed, names readings left out, calls them "Low readings" and plots
only reliable readings. The Today Blood Oxygen tile resolves its night
through the same funnel instead of the sparkline's last value. The
strap-log line names what it left out. Translations for all nine
locales.
…eads Messungen

The shared "Readings" label read "Lesungen" (readings of a text) beside
"Messungen" in the captions under it.
…ds (W03-013)

Both quality gates are ratios, and one in-band second of one measured
passes both, so a reading a session edge cuts to a fragment could still
become the night's low. A reading now needs ten in-band seconds (a third
of the strap's reading) before the ratios judge it. No real reading
moves: on 15 backups every reliable reading has twenty or more.
…009, W03-011, W03-012, W03-015 … W03-018)

Spo2CandidateSeries.Read holds every candidate series and answers the
newest night, a given day's night and the nightly means, leaving out a
mean left on a night with no reliable reading. Repository loads it once;
the Health card and tile, classic and Liquid Today and the Metric
Explorer resolve through it. The Health tile stops carrying an older
mean past a newest night with no reliable reading; Today and Liquid
round with meanRounded; Liquid names a night with no reliable reading;
Today drops its second mean read and a misplaced doc block.
… readings (W03-010)

A night with no reliable reading now carries why: readings that had
values and failed the quality check, or readings that were codes only.
Only the first keeps the loose-strap note; the second says that none of
the night's readings produced a value.
…tor (W03-014)

The chart's merge gave each second to the first strap id with any row
there, so a row without a byte-82 value could hide another strap's
reading, and it ran on the main actor. A second now goes to the first
id that reported a nonzero byte, and the merge and resolve run in a
detached task.
@ayiskakov
ayiskakov merged commit 786f7e9 into main Sep 30, 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