tools: multi-device SpO2 @82 candidate validation harness (#103)#808
tools: multi-device SpO2 @82 candidate validation harness (#103)#808dnesdan wants to merge 1 commit into
Conversation
Add validate_spo2_candidate.py to compare nightly mean of in-band v18 @82 samples (sleep_state=asleep) against WHOOP CSV blood_oxygen_pct across one or many straps. Reports r/MAE/bias, offset-specificity (74–92), and a promote checklist; --postable output has no raw SpO2 values for safe #103 sharing. Also: English export header aliases for correlate_ground_truth, spo2_candidate_82 on whoop_activity.decode_v18, docs for the multi-device bar, and unit tests.
|
Reviewed. Good PR — research tooling only, no app code, and the two claims that matter both hold. Verified:
Why your checks are missing, and it isn't your doing: you're a first-time contributor from a fork, so GitHub is holding the workflow runs at One repo hazard, pre-existing but worth naming. There are two Otherwise this is exactly the right shape for #103: an evidence harness with explicit gates rather than an early promote. |
|
Ran your tests here rather than take the numbers on trust. The code is sound, but the test command in the description doesn't cover the part of this PR that changes behaviour. Your new decoder test never runs under
The reported total is a coincidence that hides it: 8 + 13 = 21, so the number looks right while your decoder test silently doesn't run. Worth either converting that file to I executed the bare functions by hand: 17 pass, and the 4 that don't are pytest-only ( The case-split is not cosmetic — it breaks the tests on Linux. That fails on Otherwise verified: the in-band gate matches Also worth knowing: your checks show as missing because you're a first-time fork contributor and the runs are held at |
ryanbr
left a comment
There was a problem hiding this comment.
Anchoring the findings from running the suite locally. The harness itself is well-built and correctly scoped — these are about coverage and about a pre-existing directory split that turns out to be load-bearing.
| "unknown_f32_113": 0.0} | ||
|
|
||
|
|
||
| def test_decode_v18_spo2_candidate_82_tri_mode(): |
There was a problem hiding this comment.
This test does not run under the command in the PR description.
This file is pytest-style — 21 bare def test_* and zero unittest.TestCase — so python3 -m unittest test_whoop_activity collects nothing from it (NO TESTS RAN). That includes this test, which is the only thing guarding the whoop_activity.py change below.
The totals hide it, which is why it is easy to miss:
test_validate_spo2_candidate 8 collected
test_correlate_ground_truth 13 collected
test_whoop_activity 0 collected
──
21 ← the "21 tests, OK" in the description
I executed the bare functions by hand: 17 pass, and the 4 that don't are pytest-only (tmp_path fixtures, one import pytest) rather than real failures. This one passes. So the behaviour is right — it just isn't covered by the documented runner.
Either convert the file to TestCase, or document pytest as its runner. As it stands nothing guards the decode.
| "aux_byte_82": frame[82], # @82 raw (nonzero only while asleep; meaning not pinned) | ||
| "aux_byte_82": frame[82], # @82 raw (nonzero only while asleep) | ||
| # In-band only (70–100): mirrors Swift spo2_candidate_82 / Kotlin twin — instrumentation. | ||
| "spo2_candidate_82": frame[82] if 70 <= frame[82] <= 100 else None, |
There was a problem hiding this comment.
Verified against the app decoder: this matches Interpreter.swift:502 (if (70...100).contains(v)) exactly, inclusive at both ends, and aux_byte_82 is preserved alongside it so nothing downstream loses the raw byte. The only consumer of this module in either tools tree is its own test, so there is no blast radius.
Correct as written — see the coverage note on the test above.
| machine. That's the intended way for other 5/MG owners to contribute field mappings without sharing | ||
| personal data. | ||
|
|
||
| ## SpO₂ candidate validation (`validate_spo2_candidate.py`) |
There was a problem hiding this comment.
Worth flagging the split this sits on, because it is not cosmetic. The new tool lives in Tools/linux-capture/ (capital T) and is documented here in tools/linux-capture/ (lowercase) — two directories differing only in case, 8 files and 17 respectively.
On a case-insensitive filesystem the two fold together, which is why everything works for you. On Linux they don't, and the suite fails before it starts:
correlate_ground_truth.py: import whoop_frame
ModuleNotFoundError: No module named 'whoop_frame'
whoop_frame.py exists only in the lowercase tree. This reproduces on main, so it predates the PR — but it means these tests cannot pass on an Ubuntu CI runner, which matters now that workflow approval is the only thing standing between this branch and CI. Everything passes once the lowercase tree is on PYTHONPATH.
Not yours to fix here. Consolidating the two trees deserves its own issue.
Consolidating Tools/linux-capture into tools/linux-capture left five references to the old path in WHOOP5_DEEP_DATA.md: a markdown link that would 404, two copy-paste `cd Tools/linux-capture` commands that fail on any case-sensitive filesystem, and two inline script paths. Three of them arrived with #808 itself and two predate it. All now point at the lowercase path; a repo-wide sweep for "Tools/linux-capture" returns nothing, and every relative link in the file resolves. Caught by grepping for the old path AFTER the move rather than assuming git mv was the whole job — the move is mechanical, the references are not.
…ded to run (#103) (#814) Carries @dnesdan's #808 harness. The design is theirs and is unchanged: nightly in-band @82 mean while asleep, paired against the export's blood_oxygen_pct, Pearson r / MAE / bias, an offset-specificity scan over 74-92, checklist gates, --batch for multiple devices, and a --postable summary that omits raw SpO2 values. That is the right instrument for the #103 bar. The rest of this change is about the ground it landed on. THE SUITE COULD NOT RUN ON LINUX Tools/linux-capture/ and tools/linux-capture/ were two case-variant directories with ZERO overlapping filenames — 11 and 17 files. On macOS they are one directory and everything works; on Linux they are separate and every cross-boundary import fails. validate_spo2_candidate.py imports whoop_activity (upper) and whoop_frame (lower), so it ran in neither. correlate_ground_truth.py was already broken this way on main. Consolidated into tools/linux-capture/ — the side holding whoop_frame.py, the README and most of the scripts, and the path the docs cite. Repo-root Tools/ is untouched. before: Tools/linux-capture Ran 2 FAILED (errors=2) tools/linux-capture Ran 104 OK after: tools/linux-capture Ran 159 OK (skipped=1) "21 TESTS, OK" NEVER INCLUDED THE NEW TEST test_whoop_activity.py is pytest-style — 21 bare def test_* functions, no unittest.TestCase — so python3 -m unittest, the command the README documents and the only runner in requirements.txt, collects nothing from it and exits 0. That is indistinguishable from passing. The 21 was 8 + 13 from the other two files; the new @82 tri-mode decoder test sat in the file contributing zero and had never executed. Added unittest's load_tests hook rather than rewriting 21 working tests. Running them surfaced 4 real failures, all pytest-only dependencies: three took the tmp_path fixture, one called pytest.skip. Rewritten to stdlib (tempfile.TemporaryDirectory, unittest.SkipTest), both honoured by pytest too, so all 21 run under either runner. BYTE-82 DOCS FROM #812 WERE WRONG #812 documented @82 as an unexplained raw byte with a promising third-party lead. It is already decoded on both platforms as spo2_candidate_82 — tri-mode, sleep-only, with a guard test stopping it reaching spo2Pct and a full write-up in WHOOP5_DEEP_DATA.md. The error came from grepping aux_byte_82, reading a table row that omits the candidate, and never grepping spo2. Rewritten to the real position: the identification is not the open question, the split evidence is — 8 nights at corr +0.99 including a 92% desat and a 98% high, offset-specific in a 74-92 scan, against two nights on the #103 device that moved OPPOSITE to the app value. Resolving that contradiction is exactly what this harness does, which is a better argument for it than the one #808 made for itself. Also repoints five WHOOP5_DEEP_DATA.md paths the directory move broke, including a link that would 404 and two copy-paste commands that fail on a case-sensitive filesystem. Python tooling and docs only: no app code, no BLE path, no schema, no scores, no strings. Verified on Linux, which is the platform that mattered here — 159 tests, OK. Known gap: no workflow runs this suite, so nothing stops it silently breaking again. Co-authored-by: Dan Urbanek <abc@dnesdan.cz>
|
Landed as a446d0b (via #814), with you as co-author. Thanks — the harness design is yours and went in unchanged: the nightly in-band mean while asleep, the pairing against I opened a separate PR only because this one was stuck at
The Worth saying plainly: that one wasn't really yours either. The file was already pytest-style before you added to it, and the README documents Also folded in a correction to my own docs — I'd written up Closing this in favour of the merged version. #103 stays open — your harness is the instrument for answering it, and the real blocker there is the cross-device contradiction (8 nights at corr +0.99 against two nights on the original device moving the wrong way), which is precisely what |
What this PR does
Adds a multi-device SpO₂ @82 validation harness so owners can test whether
spo2_candidate_82tracks the official export — the bar required before any promote tospo2Pct(#103).New:
Tools/linux-capture/validate_spo2_candidate.py@82(70–100) whilesleep_state=asleepblood_oxygen_pct(English + DE/ES headers)@82should win)--batch devices.jsonfor multi-device--postablesummary without raw SpO₂ values (safe for WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist) #103)spo2PctAlso
correlate_ground_truth.py(Blood oxygen %→blood_oxygen_pct) — previously English exports missed SpO₂ ground truthwhoop_activity.decode_v18exposesspo2_candidate_82(tri-mode, mirrors Swift/Kotlin)WHOOP5_DEEP_DATA.md+ linux-capture READMEType of change
How it was tested
No BLE hardware path changed. Synthetic planted captures only in CI; real multi-device evidence still comes from owner captures on #103.
Checklist
python3 -m unittestfor touched Tools/linux-capture tests passRelated issues