Skip to content

tools: SpO2 @82 multi-device validation harness (#103) — with the fixes it needs to run - #814

Merged
ryanbr merged 3 commits into
mainfrom
tools/spo2-candidate-harness
Jul 26, 2026
Merged

ryanbr merged 3 commits into
mainfrom
tools/spo2-candidate-harness

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 26, 2026 •

Copy link
Copy Markdown
Owner

Carries @dnesdan's #808 unchanged as the first commit, then the three fixes it needs. Opened here because #808 is stuck at action_required and can't get CI on a fork.

Credit for the harness is entirely @dnesdan's — the design is right, and the offset-specificity scan and multi-device gating are exactly the shape #103 needs. What follows is about the ground it landed on.

1. The suite couldn't run on Linux at all

Tools/linux-capture/ and tools/linux-capture/ are two case-variant directories with zero overlapping filenames — 11 files and 17. On macOS they're one directory and everything works. On Linux they're separate, and every import across the boundary fails. validate_spo2_candidate.py imports whoop_activity (upper) and whoop_frame (lower), so it couldn't run in either. 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 BLE docs already cite. Repo-root Tools/ is untouched.

before (main) after
Tools/linux-capture Ran 2, FAILED (errors=2) — merged away
tools/linux-capture Ran 104, OK Ran 159, OK (1 skipped)

2. "21 tests, OK" never included the new test

test_whoop_activity.py is pytest-style — 21 bare def test_*, 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's indistinguishable from passing.

The 21 in #808's body is 8 + 13 from the other two files. The new @82 tri-mode decoder test lives in the file contributing zero and had never executed.

Fixed with unittest's documented 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 of which pytest also honours, so all 21 now run under either runner.

3. My own byte-82 docs from #812 were wrong

I documented @82 in #812 as an unexplained raw byte with a promising third-party lead. It's 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. I grepped aux_byte_82, read a table row that omits the candidate, and never grepped spo2. "Stays raw and unnamed" and "no source for at all" were false.

Rewritten to the actual position: the identification isn't the open question — the split evidence is. Eight 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. That contradiction is precisely what this harness resolves, which makes it the fix for the gap rather than a detour. whoop-local (#715) is recorded as corroboration of the identification, not its origin.

Verification

cd tools/linux-capture
python3 -m unittest discover -s . -p 'test_*.py'
# Ran 159 tests — OK (skipped=1)

Run on Linux, which is the point. The one skip needs a real capture DB. No app code, no BLE path, no schema, no scores, no strings — Python tooling and docs.

#103 stays open — this is the instrument for answering it, not the answer. Deliberately phrased to avoid GitHub's auto-close keywords, which fire regardless of any negation in front of them.

dnesdan and others added 3 commits July 25, 2026 19:32
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.
…e-82 docs

Builds on @dnesdan's #808 (cherry-picked as the first commit). Three fixes it
needs, plus a correction to documentation I got wrong.

1. THE CASE-SPLIT MADE THE SUITE UNRUNNABLE ON LINUX

Tools/linux-capture/ and tools/linux-capture/ are two case-variant directories
with ZERO overlapping filenames — 11 files and 17 files. On macOS they are one
directory and everything works; on Linux they are separate and every import across
the boundary fails. validate_spo2_candidate.py imports whoop_activity (upper) AND
whoop_frame (lower), so it could not run in either. correlate_ground_truth.py was
already broken this way on main.

Consolidated into tools/linux-capture/ — the side that holds whoop_frame.py, the
README and the majority of the scripts, and the path the BLE docs already cite.
Repo-root Tools/ (i18n, release scripts) is untouched.

  before, on main:  Tools/linux-capture  Ran 2   FAILED (errors=2)
                    tools/linux-capture  Ran 104 OK
  after:            tools/linux-capture  Ran 159 OK (skipped=1)

2. THE "21 TESTS, OK" IN #808 NEVER INCLUDED ITS OWN 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. The
21 in the PR body is 8 + 13 from the other two files; the new @82 tri-mode decoder
test is in the file contributing zero and had never executed.

Added unittest's documented `load_tests` hook rather than rewriting 21 working
tests. Running them then surfaced 4 real failures, all pytest-only dependencies:
three took the `tmp_path` fixture, one called pytest.skip. Rewritten to stdlib —
tempfile.TemporaryDirectory and unittest.SkipTest, both of which pytest also
honours. All 21 now run under either runner.

3. THE BYTE-82 DOCS I MERGED IN #812 WERE WRONG

I 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. I had grepped aux_byte_82 and read a table row that omits the
candidate, and never grepped spo2. Claims like "stays raw and unnamed" and "no
source for at all" were simply false.

Rewritten to state the actual 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. That contradiction is what #808's harness exists to
resolve, which makes this PR the fix for the gap and not a detour. whoop-local is
recorded as corroboration of the identification, not its origin.

Suite: 159 tests, OK (1 skipped, needs a real capture DB), verified on Linux.
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.
@ryanbr
ryanbr merged commit a446d0b into main Jul 26, 2026
1 check passed
ryanbr added a commit that referenced this pull request Jul 26, 2026
…r ran (#815)

Follow-up to #814, which fixed unittest collection for test_whoop_activity.py and
missed the identical instance in the same directory — in a file that PR had moved.

test_whoop_spot_hrv.py is also pytest-style: 8 bare `def test_*`, no
unittest.TestCase. So `python3 -m unittest` collected nothing from it and exited 0,
which is indistinguishable from passing. Its docstring told readers to use pytest,
which is not in requirements.txt and is not what the README documents, so in
practice these eight ran for nobody.

All eight take no fixtures, so the same load_tests hook is enough. All pass.
Docstring corrected to name both runners. Suite 159 -> 167, OK (1 skipped). A
repo-wide sweep now finds no Python test file that unittest silently skips.

Mutation-tested before merging, because a test that has never executed has never
been shown to catch anything. Inverting the local-maximum comparison in find_peaks
fails 2 of the 8, so the collected tests do guard real behaviour.

Two mutations survived and are worth recording as a known coverage gap, not fixed
here: dropping the min_dist spacing filter, and dropping the min_prom prominence
gate, both leave all 8 passing. find_peaks has three behaviours and these tests
pin one. Collecting them is still a strict improvement over running zero.
ryanbr added a commit that referenced this pull request Jul 26, 2026
…ty signals (#824)

Re-implements the landable parts of #807 (@dnesdan) against current main. Their PR
picked up a conflict when #814 rewrote the same @82 paragraph; writing fresh avoided
moving a bigger conflict onto them.

KEPT, AND THEIRS — the SpO2 protocol facts, recorded nowhere else:

  - No SpO2 read opcode is known. Our CommandNumber catalogue carries 80 commands
    and none reads oxygen.
  - Computed on-device during sleep — our own decode corroborates it, aux_byte_82
    is nonzero only while the band sleep flag reads asleep.
  - The CSV blood_oxygen_pct is a per-cycle aggregate, read on the
    physiological-cycles row beside recovery_score_pct and day_strain
    (WhoopExportImporter.swift:272), so it will not equal a mean of wire samples.
  - A night with no export value is a real gap, not a decode failure.

DROPPED — their model-id range table (0…37 = MG, 48…85 = 5.0).

Not for provenance: #822 settled today that decompile-sourced facts are admissible
with attribution, so the original objection no longer applies. Dropped because we
already resolve identity a better way and use model-ids nowhere. Whoop5Variant reads
the DIS serial prefix (5AM/5AG) and the WG50 hardware-revision token — shipped in
#781, sourced from #520 — and that is documented here from our own code, including
that contradictory signals return .unknown and that .unknown is not MG.

CHANGED — the v18 field map is a pointer, not a copy. Their §8.1 duplicated the
authoritative table in BLE_REVERSE_ENGINEERING.md, and drift had already started:
theirs predates the dynamic_acceleration@41 row added earlier today. One table, one
place to keep correct.

FOUR REVIEW PASSES, ALL FINDING MY OWN MISTAKES

  - Wrong heading level and wrong parent: ## 7.9/7.10 under a section whose
    subsections are ### 7.1..7.5, and neither topic belongs under "Historical-data
    offload". Moved to top-level 9 and 10, File map renumbered to 11 after checking
    nothing cross-references §8/§9 and that the file has no TOC.
  - Overclaimed negative: "there is no GET_SPO2 opcode" restated as "none is known".
    §6 is explicitly a safe SUBSET and the 98-vs-87 dispute shows the map is
    incomplete; the catalogue is evidence, not proof about the strap.
  - Two inherited claims passed through unverified, now sourced: the per-cycle
    export shape (cited to our importer) and the DIS characteristics (cited to
    BLEManager.swift:473-474).
  - One claim that cannot be checked here — naps/incomplete nights carrying no
    export SpO2 — kept but explicitly marked a contributor observation, so it is not
    later cited back as established.

Every claim in these sections is now either checked against this tree or labelled
unverified. Docs only.

Co-authored-by: Dan Urbanek <abc@dnesdan.cz>
ryanbr added a commit that referenced this pull request Jul 27, 2026
Re-reviewing my own PR: the Swift twin's repo.hrBuckets(from:to:bucketSeconds:) UNIONs
the active strap with the canonical "my-whoop" and dedupes by bucket start. I called
Kotlin's single-id hrBuckets, so after a strap re-add Android would read strictly less
data than Apple — plausibly missing the >= 24-bucket floor and rendering no estimate
where Swift renders one.

hrBucketsUnion is documented as the twin of exactly that overload. Single-WHOOP install
resolves to one id either way, so nothing changes for most users; the divergence only
appears on the multi-namespace case the read spine exists for.

A parity bug in the parity fix.
ryanbr added a commit that referenced this pull request Jul 27, 2026
…rm gap (#855)

Body Clock has shipped on iOS/macOS all along: AppModel.computeCircadianPhase pools HR by
local hour-of-day as the activity proxy, fits the cosinor, publishes circadianPhase, and
SkinTempCardsView renders it. Android has a byte-for-byte twin of the engine, its own
full test suite and a finished BodyClockCard — and never fed any of it. V5HealthSignals
hardcoded bodyClock = null, so HealthScreen ?.let skipped the card on every device.

The comment guarding it was wrong. It said we lack "per-hour rest-activity bins we do
not bank here". Swift does not bank them either — it derives them from HR buckets
Android also has. The pipe was never built on this side, not missing.

Mirrors the Swift maths exactly: 3600 s buckets over 14 days, the >= 24-bucket floor,
pooling by LOCAL hour, mean bpm per populated hour, distinct local days as daysObserved,
and the >= 6-bin floor before a fit is attempted. The binning lives in AppViewModel
rather than CircadianEngine precisely because Swift keeps it in AppModel — the engine
stays an untouched byte-for-byte mirror on both platforms.

Reads hrBucketsUnion, not hrBuckets: the Swift twin UNIONs the active strap with the
canonical my-whoop (#814 read spine). Caught in re-review — the single-id read would
have given Android strictly less data than Apple after a strap re-add, enough to miss
the 24-bucket floor and render no estimate where Swift renders one. A parity bug inside
the parity fix.

The pure half is a top-level circadianBinsFrom, unit-testable with no Context, no
ViewModel and no database. Six tests pin the floors, local-hour pooling, mean-not-sum,
the negative-offset wrap and sparse coverage; every expectation was cross-checked
against an independent implementation before commit.

Both new parameters default, so every existing evaluate() caller is byte-identical. The
synchronous settings-toggle re-evaluate reuses the collector pass cached bins rather
than blanking the estimate. That cache needs no synchronisation and the reasoning is
recorded at the declaration: the write resumes on viewModelScope
(Dispatchers.Main.immediate), the read is a main-thread settings callback, and the Swift
twin gets the same guarantee from @mainactor.

Supersedes #853, which proposed DELETING all of this on the mistaken belief there was no
Swift twin. Closed.

KNOWN DIVERGENCE, deliberately not closed here: Swift derives habitualWakeHour from the
most recent banked wake and falls back to 07:00; Kotlin has no such helper, so this uses
the existing 7.0 default, shifting offsetVsScheduleMinutes for anyone who does not wake
at 07:00.

Does NOT settle whether this should ship at all. Without an observedTempMinHour the
estimate derives the CBT minimum as activityAcrophase - 12.0h, a population constant,
and the confidence bands key on data sufficiency rather than on whether that constant
holds for this person. That question is about the ALREADY-SHIPPED Apple card. This lands
first so the platforms are consistent and the decision can be made once, for both.

Not compiled — android.yml is disabled.
ryanbr added a commit that referenced this pull request Jul 27, 2026
…the rename is for

I renamed workoutHrDeviceId -> workoutHrDeviceIds and updated the KOTLIN resolver test
while missing its SWIFT twin, which still called the old name — a compile break in
StrandTests. Nothing caught it: StrandTests runs only under xcodebuild on macOS, and
app-build.yml is disabled. Anyone running tests locally on a Mac would have hit it,
which #854 has just made possible again.

The four existing cases keep their expectations, now as single-element lists.

Added the two cases none of them covered, which are the whole point of the change:
an IMPORTED row on a multi-namespace install resolves to the full #814 union (the case
the single-id resolver silently under-read), while a DETECTED row on that same install
still resolves to exactly one id — the union must not leak into the branch where it
would pull in a strap that never recorded the bout.
ryanbr added a commit that referenced this pull request Jul 27, 2026
… HRR (#857)

Four surfaces on one workout card, no two agreeing.

  Swift avg      workoutHrDeviceId (single)  under-reads imported after a strap re-add
  Swift chart    day union                   WRONG STRAP for a bout detected on a 2nd WHOOP
  Swift zones    day union                   same
  Swift HRR      day union                   same
  Kotlin chart   single active id            both failure modes
  Kotlin zones   single active id            both
  Kotlin HRR     day union                   wrong strap for detected

So on iOS the Avg HR and the HR curve on the same card could describe DIFFERENT straps.
Not a Kotlin-vs-Swift divergence — a live self-inconsistency.

Neither existing rule is right on its own. The correct read is source-class-dependent,
which workoutHrDeviceId already encoded; it just returned one id where one case needs two:

  detected          -> [recording strap]   a union mixes in a strap that never recorded it (#510)
  manual / imported -> active u canonical  no strap of its own, and the worn strap may bank
                                           under either after a re-add (#814)

workoutHrDeviceIds returns that list on both platforms and all FOUR surfaces consume it:
chart, zone minutes, Avg HR / Effort, and heart-rate recovery. HRR reads past the bout,
which is the one reason it might have wanted different treatment; it does not, since the
strap that recorded the session is still on the wrist minutes later.

The aggregate needed real work. A naive `deviceId IN (...)` double-counts a second banked
under both ids — inflating n and skewing avg, silently, because both numbers stay
plausible. GROUP BY ts with MIN(pri) keeps one row per second and takes the primary is,
matching the dedup the chart already did. Fixed 2-arity rather than a bound collection:
importedReadIds is 1 or 2 by construction, and Room @query is static SQL that cannot
express per-id priority through an IN list.

Passing the same id twice is byte-identical to the old single-id read, so a single-WHOOP
install is unchanged and needs no special case. That is the control test.

The aggregate lives in WhoopStore, where swift-packages.yml actually runs its tests — the
placement that made #841 Swift half verifiable and its Kotlin half not.

Verified against SQLite before commit, and the Kotlin @query was extracted from source and
executed to confirm it returns the same numbers as the Swift twin:
  A(0..9) + B(5..19), A primary -> n=20 avg=150.0 max=200   (naive IN would say 25)
  B primary                     -> n=20 avg=175.0
  PPG anti-join per id          -> n=15 max=130
  same id twice                 -> identical to the pre-change read

Tests: four added in WhoopStoreTests (overlap, precedence, per-id PPG anti-join, single-id
control); five existing moved to the two-id signature with assertions untouched; both
resolver tests moved to the list API, plus two new Swift cases for the behaviour the rename
exists for — an imported row getting the full union on a multi-namespace install, and a
detected row still resolving to one id on that same install.

Day-level hrSamplesUnion / hrBucketsUnion are untouched and still used by Today, the
full-day chart and the auto-workout nudge, where the union is the correct read.

Swift WorkoutRow carries no deviceId, so its resolver takes only source where Kotlin also
takes rowDeviceId — pre-existing, documented in the original #510 note.

NOT field-verified: the bug only manifests on a multi-strap install, which nobody here has.
The two-id path is covered by CI tests against real SQLite and a single-strap control
proving the common case is byte-identical, but no one has confirmed it on two straps.

The Kotlin half is not compiled or run by CI.
ryanbr pushed a commit that referenced this pull request Jul 27, 2026
…lassify a strap that never emits it (#860)

tools/linux-capture/validate_spo2_candidate.py (#814, extended by #819) decides whether
spo2_candidate_82 graduates to a real spo2Pct. Three properties of @82 made its numbers unreliable.

All three established on an 18,650-record v18 corpus: 18,602 from @digitalerdude's public
PacketLogger capture of an official-app overnight sync (posted on #103), plus 48 from a NOOP sync on
a subscription-free strap.

1. @82 IS DUTY-CYCLED, AND THE HARNESS DID NOT KNOW. Nonzero in 450/18,650 records (2.41 %), 24
distinct values in 1..160, falling in 15 contiguous runs of exactly 30 records each, every run
starting at unix % 1200 == 337 with zero phase variance. A 30-second measurement once every 20
minutes. A capture not aligned to that phase reads all zeros and is indistinguishable from a strap
with the feature off — sub-sampling the corpus at one record per minute off-phase reproduces it
exactly: 310 records, zero nonzero @82, 0/16 windows. A plausible contributor to the contradictory
cross-device evidence on #103, where one device correlated +0.99 and another moved opposite; the
second may not have disagreed so much as missed the window.

detect_duty_cycle() now measures period, phase, window length, count and jitter per capture and
classifies it absent / continuous / duty_cycled / irregular. Only duty_cycled changes aggregation,
so a capture it cannot characterise is never silently reweighted. The phase is deliberately NOT a
constant: the robust fact is that a stable periodic window exists, and 337 is one strap's firmware.
Verified independently on four schedules the tests do not use, including phase > period and a
2-minute period — all recovered exactly — and it falls through to irregular on two windows,
scattered firings and phase jitter, with the jitter tolerance scaling to capture cadence rather than
fixed. Robust to 3 of 10 windows missing without the period collapsing to a multiple.

Nightly aggregate is now the mean of per-window means, not of every second: a 30-second burst is one
measurement, and per-second averaging hands the night to whichever window was sampled most densely
(95.92 vs 93.70 on the real corpus). Coverage is reported per night and warned on below
--min-window-coverage.

2. IN-BAND-ONLY SCREENING IS NOT SPECIFIC. Screening on "value in 70-100" is a range, not a
signature: six offsets are in-band on a majority of records on a real strap, including @17 — byte 2
of the u32 unix timestamp at @15 — and the already-decoded cardiac_flags, step_cadence and two aux
thermal channels. A nightly SpO2 cannot have 2 distinct values. The scan now requires >=5 distinct
in-band values and stdev >=0.5 before ranking a correlation, reports rejected offsets rather than
dropping them silently, and holds @82 to the same floor. The regression test plants a 2-valued byte
that correlates BETTER than @82; without the floor it wins the scan and the tool concludes "not
specific to @82" from a byte that cannot be a measurement.

3. A STRAP THAT NEVER EMITS @82 IS CLASSIFIED, NOT FAILED. It is now feature_absent, neither PASS nor
FAIL, gated behind >=100 records and >=3 nominal periods of scored sleep so a short capture that
merely missed the window stays a plain FAIL.

KNOWN GAP, follow-up immediately: that evidence bar measures duration, not phase, so it does not
catch a capture whose cadence aliases with the duty period. The same working strap at 1200/337/30
over six nights of eight hours scored sleep classifies duty_cycled/fail at 1 Hz and
absent/feature_absent at one sample per five minutes — a 300 s cadence occupies only 4 residues mod
1200, so it either always hits the window or never does. This is finding 1 applied to finding 3.
It matters because it is asymmetric: feature_absent removes the device from `eligible`, so all_pass
is computed without it and promotion gets easier. record_interval_s is already computed when the
mode is absent, so the gate is a few lines.

PRIVACY. --postable still carries no raw SpO2 values — verified with distinctive per-night values,
none appear. Added columns are device timing and a classification; the phase is the useful thing to
share, being what another owner needs to align a capture.

TESTS. tools/linux-capture 178 -> 194, validator file 16 -> 32, all 16 pre-existing unchanged and
still passing; confirmed on both branches with unittest discover.

SCOPE. Python capture tooling plus two doc sections. No app code, no decoder, no schema, no
Swift/Kotlin — spo2_candidate_82 remains instrumentation-only and this does not move it. Serves the
#103 promote gate, where until now a device could fail for having captured the wrong 30 seconds out
of every 20 minutes. Related: #845.

Corpus credit to @digitalerdude (#103).
vipulog pushed a commit to vipulog/noop that referenced this pull request Jul 31, 2026
…s no longer export an empty zip (ryanbr#458) (ryanbr#468)

Android (the reported bug, verified at HEAD): exportZip defaulted
deviceId="my-whoop", but a live-BLE install's engine banks computed
scores under "<strapId>-noop" - every read hit ids owning zero rows and
the toast said 'Exported 0 days, 0 sleeps, 0 journal entries' while the
app displayed months of history. It survived the ryanbr#359 sweep because that
grep targeted the hardcoded "my-whoop-noop" string, not DEFAULT
PARAMETERS.

- The default is REMOVED (compiler-enforced decision at every call site);
  Settings threads the registry's active strap id.
- Every read resolves through the ryanbr#814 active-union helpers:
  daysMerged/sleepSessionsMerged already did once handed the active id;
  workouts move to workoutsUnion/detectedWorkoutsUnion; the imported-days
  set, journal, cycles metricSeries and the sidecar union inline with
  active-first dedup on each row's natural key ((day), (day, question),
  (startTs, sport)).
- New reads are a strict SUPERSET of the old ones: nothing that previously
  exported can be lost, and a single-canonical install collapses to one id
  per side, byte-identical to before.

Apple (mirror-image of the same class): CsvExport read repo.deviceId (the
ACTIVE id, ryanbr#814) plus its -noop sibling only, so a strap remove+re-add
orphaned the canonical "my-whoop" rows (prior CSV import + canonical
engine history) from the export. Reads now union
importedReadIds/computedReadIds (de-privatized, same module) with the
same active-first natural-key dedups; workoutSource takes the computed-id
LIST since a detected row can carry either union id.

NOT in this PR (separate concern): the sibling audit of the 7 remaining
'= "my-whoop"' default parameters on WhoopRepository methods.

Verification: Android compileFullDebugKotlin + testFullDebugUnitTest =
2709 tests, 0 failures (union resolvers pinned by ResolverUnionTest; no
Robolectric/in-memory Room in the JVM suite for an exportZip repro).
Swift is app-target: on-demand app-build gates the merge. Reporter
(MasterSMax12) has offered on-device verification on the live-BLE 4.0
setup that confirmed ryanbr#349/ryanbr#359.
@ryanbr
ryanbr deleted the tools/spo2-candidate-harness branch August 2, 2026 07:41
ryanbr pushed a commit that referenced this pull request Aug 2, 2026
…ot stale my-whoop (#836) (#1039)

A manual workout row is always stored under deviceId "my-whoop" (a retroactive-add
placeholder in WorkoutEditing.buildManualRow), never the strap that actually recorded it.
workoutHrDeviceIds previously keyed manual rows on that stored id, so on a re-added or
second WHOOP the live HR trace (banked under the strap's fresh "whoop-<mac>" id) was never
found, leaving Avg HR / Effort blank. Only DETECTED rows (their own source IS the recording
strap's computed id) genuinely need the single-id read; manual rows now join imported rows
on the #814 active-strap-union read, matching what Swift's Repository.workoutHrDeviceIds has
always done. Single-WHOOP installs are unaffected (the union collapses to one id).
ThatsSoTrieu pushed a commit to ThatsSoTrieu/noop that referenced this pull request Aug 13, 2026
)

HealthConnectWriter.write() defaulted deviceId to the legacy "my-whoop", but a strap
paired via the Add-Device wizard banks under whoop-<address> (SPINE/ryanbr#814), so all three
writeback call sites exported 0 records silently (every insert is runCatching-wrapped).

Pass the registry-resolved active id (already in scope at each site: AppViewModel.deviceId
= activeDeviceId; WhoopBleClient.deviceId re-pointed by setActiveDeviceId) into write() so
the writeback reads the same source the UI does. This is the writeback leg of the read-side
fix in ryanbr#175 (ryanbr#172). Legacy single-WHOOP installs no-op. Adds a "HC writeback: N record(s)"
strap-log line so a zero-record export is no longer indistinguishable from success.

Closes ryanbr#178.
ThatsSoTrieu pushed a commit to ThatsSoTrieu/noop that referenced this pull request Aug 13, 2026
…caller (ryanbr#175)

resolvedSeries() defaulted strapDeviceId to the canonical "my-whoop", and no
UI caller overrode it. After a strap re-add (or any install whose active strap
id isn't literally "my-whoop"), IntelligenceEngine banks computed scores under
"<activeStrapId>-noop", so sourceCandidates() probed the wrong pair and the
Today Rest ring fell back to "Calibrating — needs a tracked night" while the
Sleep tab showed fully scored nights.

Pass vm.activeStrapId at all eight call sites (TodayScreen x4, TrendsScreen,
HealthScreen, CompareScreen, LabBookScreen), matching what daysMerged /
FusionDayAdapter already do (ryanbr#814 union model; gap acknowledged in ryanbr#1008).
Single-strap installs resolve activeStrapId == "my-whoop", so sourceCandidates'
uniqued() keeps that path byte-identical.

Fixes ryanbr#172
ThatsSoTrieu pushed a commit to ThatsSoTrieu/noop that referenced this pull request Aug 13, 2026
…, missing HC sport types (ryanbr#210)

Brings Android's Today workout feed up to parity with iOS's Repository.workoutRows():

- Dedup at the shared seam: the Today workout union now collapses cross-source
  duplicates (Health Connect / manual / strap recordings of the same session) with
  the Workouts screen's ryanbr#687 semantics, so the feed no longer shows a workout twice
  under different provenance.
- Single-column newest-4 feed with source pills for imported sessions (was a 2x2
  grid that interleaved sources).
- Union follows the active strap id (ryanbr#814 twin): the read was pinned to the literal
  "my-whoop", stranding a re-paired strap's fresh "whoop-<id>" recordings. The feed,
  footer counts, and HR-graph sport glyphs now use workoutsUnion via the ViewModel's
  deviceId, and the footer LaunchedEffect folds in the 14-day cross-source union so an
  import that doesn't touch Whoop day summaries refreshes the feed immediately. Same
  fix family as ryanbr#175/ryanbr#180.
- Missing Health Connect sport types: EXERCISE_TYPE_VOLLEYBALL and the other
  racket/team/misc types were absent from the sport-name map, so those sessions
  imported as a generic "Workout". Added the full set.
- Header rename "Last Workouts" -> "Latest Workouts" on both platforms (German
  "Neueste Workouts"); iOS is limited to the header string since it already had the
  union/dedup.

Android unit tests green (WorkoutEditingTest 38, LastWorkoutsFeedTest 3). iOS change
is the section-header string only — no BLE or protocol paths touched.
ThatsSoTrieu pushed a commit to ThatsSoTrieu/noop that referenced this pull request Aug 13, 2026
…computed stages (parity with ryanbr#241) (ryanbr#247)

* Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with ryanbr#241)

Re-review of the ryanbr#241/ryanbr#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep
is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors
WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — ryanbr#241 added the
richness exception to the Swift side only, so the shared-logic twins diverged.

Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer
clobbers a computed day that HAS stage data; days where the import carries stages, or where
neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank
= no stages), byte-parity with Swift's hasStages.

Not covered by ryanbr#240: that's a Health-Connect-specific write-gate + heal-purge; it never touches
mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin.

Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin
+ MergeSleepLocalDayTest pass (8/0).

* Android: route the Sleep screen through the richness merge too (re-review)

Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export +
HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins
merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no
richness exception — so a stage-less import would still blank stages on the Android sleep
screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact
user-visible surface the fix is meant to protect.

Extract mergeSleepRichness (the imported-wins + ryanbr#241 richness rule, returned UNSORTED) and
route both SleepScreen sites through it, keeping their effectiveStartTs sort (ryanbr#395) and the
ryanbr#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by
startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation.

compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
The Workouts screen read workouts under the single active strap id
(repository.workouts(deviceId, ...)). HR, sleep, steps and buckets all read the ryanbr#814
UNION of the active strap id AND the canonical 'my-whoop' — workouts never got it. So on
a re-added / newly-paired strap (activeDeviceId = 'whoop-<uuid>' != 'my-whoop'), workouts
banked under 'my-whoop' were invisible; the Workouts screen showed empty while Data Sources
(which queries 'my-whoop') listed them.

Add workoutsUnion / detectedWorkoutsUnion (twins of hrSamplesUnion / sleepSessionsUnion:
read across importedSourceIds / computedSourceIds, dedup on the (startTs, sport) natural
key) and use them in loadWorkouts. Single-WHOOP installs (deviceId == 'my-whoop') resolve
to one id, so behaviour there is byte-identical. iOS already does this in
Repository.workoutRows() — this brings Android to parity.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
)

HealthConnectWriter.write() defaulted deviceId to the legacy "my-whoop", but a strap
paired via the Add-Device wizard banks under whoop-<address> (SPINE/ryanbr#814), so all three
writeback call sites exported 0 records silently (every insert is runCatching-wrapped).

Pass the registry-resolved active id (already in scope at each site: AppViewModel.deviceId
= activeDeviceId; WhoopBleClient.deviceId re-pointed by setActiveDeviceId) into write() so
the writeback reads the same source the UI does. This is the writeback leg of the read-side
fix in ryanbr#175 (ryanbr#172). Legacy single-WHOOP installs no-op. Adds a "HC writeback: N record(s)"
strap-log line so a zero-record export is no longer indistinguishable from success.

Closes ryanbr#178.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…caller (ryanbr#175)

resolvedSeries() defaulted strapDeviceId to the canonical "my-whoop", and no
UI caller overrode it. After a strap re-add (or any install whose active strap
id isn't literally "my-whoop"), IntelligenceEngine banks computed scores under
"<activeStrapId>-noop", so sourceCandidates() probed the wrong pair and the
Today Rest ring fell back to "Calibrating — needs a tracked night" while the
Sleep tab showed fully scored nights.

Pass vm.activeStrapId at all eight call sites (TodayScreen x4, TrendsScreen,
HealthScreen, CompareScreen, LabBookScreen), matching what daysMerged /
FusionDayAdapter already do (ryanbr#814 union model; gap acknowledged in ryanbr#1008).
Single-strap installs resolve activeStrapId == "my-whoop", so sourceCandidates'
uniqued() keeps that path byte-identical.

Fixes ryanbr#172
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…, missing HC sport types (ryanbr#210)

Brings Android's Today workout feed up to parity with iOS's Repository.workoutRows():

- Dedup at the shared seam: the Today workout union now collapses cross-source
  duplicates (Health Connect / manual / strap recordings of the same session) with
  the Workouts screen's ryanbr#687 semantics, so the feed no longer shows a workout twice
  under different provenance.
- Single-column newest-4 feed with source pills for imported sessions (was a 2x2
  grid that interleaved sources).
- Union follows the active strap id (ryanbr#814 twin): the read was pinned to the literal
  "my-whoop", stranding a re-paired strap's fresh "whoop-<id>" recordings. The feed,
  footer counts, and HR-graph sport glyphs now use workoutsUnion via the ViewModel's
  deviceId, and the footer LaunchedEffect folds in the 14-day cross-source union so an
  import that doesn't touch Whoop day summaries refreshes the feed immediately. Same
  fix family as ryanbr#175/ryanbr#180.
- Missing Health Connect sport types: EXERCISE_TYPE_VOLLEYBALL and the other
  racket/team/misc types were absent from the sport-name map, so those sessions
  imported as a generic "Workout". Added the full set.
- Header rename "Last Workouts" -> "Latest Workouts" on both platforms (German
  "Neueste Workouts"); iOS is limited to the header string since it already had the
  union/dedup.

Android unit tests green (WorkoutEditingTest 38, LastWorkoutsFeedTest 3). iOS change
is the section-header string only — no BLE or protocol paths touched.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…computed stages (parity with ryanbr#241) (ryanbr#247)

* Android: mergeSleep richness exception — stage-less import yields to computed stages (parity with ryanbr#241)

Re-review of the ryanbr#241/ryanbr#246 merge surfaced a missed twin. Android's WhoopRepository.mergeSleep
is the direct twin of Swift WhoopStore.SleepMerge.merge (its own comment says "Mirrors
WhoopStore.SleepMerge"), but it still had the plain imported-wins-per-day logic — ryanbr#241 added the
richness exception to the Swift side only, so the shared-logic twins diverged.

Port the exception: a sparse import (no stage data on ANY of its sessions that day) no longer
clobbers a computed day that HAS stage data; days where the import carries stages, or where
neither side does, keep the imported-wins rule unchanged. Adds hasStages() (null/""/"[]"/blank
= no stages), byte-parity with Swift's hasStages.

Not covered by ryanbr#240: that's a Health-Connect-specific write-gate + heal-purge; it never touches
mergeSleep and doesn't protect non-HC sparse imports (Apple/CSV). This is the mergeSleep-level twin.

Mirrors Swift SleepMergeTests' 5 richness cases in MergeSleepLocalDayTest; compileFullDebugKotlin
+ MergeSleepLocalDayTest pass (8/0).

* Android: route the Sleep screen through the richness merge too (re-review)

Re-review of the first commit found it incomplete: mergeSleep feeds only CSV export +
HealthConnectWriter. The Sleep SCREEN builds its night list with its OWN inline imported-wins
merge (SleepScreen.kt, two sites: the main loader + the post-nap-edit reload), which had no
richness exception — so a stage-less import would still blank stages on the Android sleep
screen, while iOS gets richness via repo.sleeps (WhoopStore.SleepMerge). That's the exact
user-visible surface the fix is meant to protect.

Extract mergeSleepRichness (the imported-wins + ryanbr#241 richness rule, returned UNSORTED) and
route both SleepScreen sites through it, keeping their effectiveStartTs sort (ryanbr#395) and the
ryanbr#814 union inputs unchanged. mergeSleep is now that helper keyed by wake-day + sorted by
startTs, so all three consumers (browse, CSV, screen) share ONE richness implementation.

compileFullDebugKotlin + MergeSleepLocalDayTest pass (9/0, +1 mergeSleepRichness case).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…s no longer export an empty zip (ryanbr#458) (ryanbr#468)

Android (the reported bug, verified at HEAD): exportZip defaulted
deviceId="my-whoop", but a live-BLE install's engine banks computed
scores under "<strapId>-noop" - every read hit ids owning zero rows and
the toast said 'Exported 0 days, 0 sleeps, 0 journal entries' while the
app displayed months of history. It survived the ryanbr#359 sweep because that
grep targeted the hardcoded "my-whoop-noop" string, not DEFAULT
PARAMETERS.

- The default is REMOVED (compiler-enforced decision at every call site);
  Settings threads the registry's active strap id.
- Every read resolves through the ryanbr#814 active-union helpers:
  daysMerged/sleepSessionsMerged already did once handed the active id;
  workouts move to workoutsUnion/detectedWorkoutsUnion; the imported-days
  set, journal, cycles metricSeries and the sidecar union inline with
  active-first dedup on each row's natural key ((day), (day, question),
  (startTs, sport)).
- New reads are a strict SUPERSET of the old ones: nothing that previously
  exported can be lost, and a single-canonical install collapses to one id
  per side, byte-identical to before.

Apple (mirror-image of the same class): CsvExport read repo.deviceId (the
ACTIVE id, ryanbr#814) plus its -noop sibling only, so a strap remove+re-add
orphaned the canonical "my-whoop" rows (prior CSV import + canonical
engine history) from the export. Reads now union
importedReadIds/computedReadIds (de-privatized, same module) with the
same active-first natural-key dedups; workoutSource takes the computed-id
LIST since a detected row can carry either union id.

NOT in this PR (separate concern): the sibling audit of the 7 remaining
'= "my-whoop"' default parameters on WhoopRepository methods.

Verification: Android compileFullDebugKotlin + testFullDebugUnitTest =
2709 tests, 0 failures (union resolvers pinned by ResolverUnionTest; no
Robolectric/in-memory Room in the JVM suite for an exportZip repro).
Swift is app-target: on-demand app-build gates the merge. Reporter
(MasterSMax12) has offered on-device verification on the live-BLE 4.0
setup that confirmed ryanbr#349/ryanbr#359.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ded to run (ryanbr#103) (ryanbr#814)

Carries @dnesdan's ryanbr#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 ryanbr#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 ryanbr#812 WERE WRONG

ryanbr#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 ryanbr#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 ryanbr#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>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…r ran (ryanbr#815)

Follow-up to ryanbr#814, which fixed unittest collection for test_whoop_activity.py and
missed the identical instance in the same directory — in a file that PR had moved.

test_whoop_spot_hrv.py is also pytest-style: 8 bare `def test_*`, no
unittest.TestCase. So `python3 -m unittest` collected nothing from it and exited 0,
which is indistinguishable from passing. Its docstring told readers to use pytest,
which is not in requirements.txt and is not what the README documents, so in
practice these eight ran for nobody.

All eight take no fixtures, so the same load_tests hook is enough. All pass.
Docstring corrected to name both runners. Suite 159 -> 167, OK (1 skipped). A
repo-wide sweep now finds no Python test file that unittest silently skips.

Mutation-tested before merging, because a test that has never executed has never
been shown to catch anything. Inverting the local-maximum comparison in find_peaks
fails 2 of the 8, so the collected tests do guard real behaviour.

Two mutations survived and are worth recording as a known coverage gap, not fixed
here: dropping the min_dist spacing filter, and dropping the min_prom prominence
gate, both leave all 8 passing. find_peaks has three behaviours and these tests
pin one. Collecting them is still a strict improvement over running zero.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ty signals (ryanbr#824)

Re-implements the landable parts of ryanbr#807 (@dnesdan) against current main. Their PR
picked up a conflict when ryanbr#814 rewrote the same @82 paragraph; writing fresh avoided
moving a bigger conflict onto them.

KEPT, AND THEIRS — the SpO2 protocol facts, recorded nowhere else:

  - No SpO2 read opcode is known. Our CommandNumber catalogue carries 80 commands
    and none reads oxygen.
  - Computed on-device during sleep — our own decode corroborates it, aux_byte_82
    is nonzero only while the band sleep flag reads asleep.
  - The CSV blood_oxygen_pct is a per-cycle aggregate, read on the
    physiological-cycles row beside recovery_score_pct and day_strain
    (WhoopExportImporter.swift:272), so it will not equal a mean of wire samples.
  - A night with no export value is a real gap, not a decode failure.

DROPPED — their model-id range table (0…37 = MG, 48…85 = 5.0).

Not for provenance: ryanbr#822 settled today that decompile-sourced facts are admissible
with attribution, so the original objection no longer applies. Dropped because we
already resolve identity a better way and use model-ids nowhere. Whoop5Variant reads
the DIS serial prefix (5AM/5AG) and the WG50 hardware-revision token — shipped in
ryanbr#781, sourced from ryanbr#520 — and that is documented here from our own code, including
that contradictory signals return .unknown and that .unknown is not MG.

CHANGED — the v18 field map is a pointer, not a copy. Their §8.1 duplicated the
authoritative table in BLE_REVERSE_ENGINEERING.md, and drift had already started:
theirs predates the dynamic_acceleration@41 row added earlier today. One table, one
place to keep correct.

FOUR REVIEW PASSES, ALL FINDING MY OWN MISTAKES

  - Wrong heading level and wrong parent: ## 7.9/7.10 under a section whose
    subsections are ### 7.1..7.5, and neither topic belongs under "Historical-data
    offload". Moved to top-level 9 and 10, File map renumbered to 11 after checking
    nothing cross-references §8/§9 and that the file has no TOC.
  - Overclaimed negative: "there is no GET_SPO2 opcode" restated as "none is known".
    §6 is explicitly a safe SUBSET and the 98-vs-87 dispute shows the map is
    incomplete; the catalogue is evidence, not proof about the strap.
  - Two inherited claims passed through unverified, now sourced: the per-cycle
    export shape (cited to our importer) and the DIS characteristics (cited to
    BLEManager.swift:473-474).
  - One claim that cannot be checked here — naps/incomplete nights carrying no
    export SpO2 — kept but explicitly marked a contributor observation, so it is not
    later cited back as established.

Every claim in these sections is now either checked against this tree or labelled
unverified. Docs only.

Co-authored-by: Dan Urbanek <abc@dnesdan.cz>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…rm gap (ryanbr#855)

Body Clock has shipped on iOS/macOS all along: AppModel.computeCircadianPhase pools HR by
local hour-of-day as the activity proxy, fits the cosinor, publishes circadianPhase, and
SkinTempCardsView renders it. Android has a byte-for-byte twin of the engine, its own
full test suite and a finished BodyClockCard — and never fed any of it. V5HealthSignals
hardcoded bodyClock = null, so HealthScreen ?.let skipped the card on every device.

The comment guarding it was wrong. It said we lack "per-hour rest-activity bins we do
not bank here". Swift does not bank them either — it derives them from HR buckets
Android also has. The pipe was never built on this side, not missing.

Mirrors the Swift maths exactly: 3600 s buckets over 14 days, the >= 24-bucket floor,
pooling by LOCAL hour, mean bpm per populated hour, distinct local days as daysObserved,
and the >= 6-bin floor before a fit is attempted. The binning lives in AppViewModel
rather than CircadianEngine precisely because Swift keeps it in AppModel — the engine
stays an untouched byte-for-byte mirror on both platforms.

Reads hrBucketsUnion, not hrBuckets: the Swift twin UNIONs the active strap with the
canonical my-whoop (ryanbr#814 read spine). Caught in re-review — the single-id read would
have given Android strictly less data than Apple after a strap re-add, enough to miss
the 24-bucket floor and render no estimate where Swift renders one. A parity bug inside
the parity fix.

The pure half is a top-level circadianBinsFrom, unit-testable with no Context, no
ViewModel and no database. Six tests pin the floors, local-hour pooling, mean-not-sum,
the negative-offset wrap and sparse coverage; every expectation was cross-checked
against an independent implementation before commit.

Both new parameters default, so every existing evaluate() caller is byte-identical. The
synchronous settings-toggle re-evaluate reuses the collector pass cached bins rather
than blanking the estimate. That cache needs no synchronisation and the reasoning is
recorded at the declaration: the write resumes on viewModelScope
(Dispatchers.Main.immediate), the read is a main-thread settings callback, and the Swift
twin gets the same guarantee from @mainactor.

Supersedes ryanbr#853, which proposed DELETING all of this on the mistaken belief there was no
Swift twin. Closed.

KNOWN DIVERGENCE, deliberately not closed here: Swift derives habitualWakeHour from the
most recent banked wake and falls back to 07:00; Kotlin has no such helper, so this uses
the existing 7.0 default, shifting offsetVsScheduleMinutes for anyone who does not wake
at 07:00.

Does NOT settle whether this should ship at all. Without an observedTempMinHour the
estimate derives the CBT minimum as activityAcrophase - 12.0h, a population constant,
and the confidence bands key on data sufficiency rather than on whether that constant
holds for this person. That question is about the ALREADY-SHIPPED Apple card. This lands
first so the platforms are consistent and the decision can be made once, for both.

Not compiled — android.yml is disabled.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… HRR (ryanbr#857)

Four surfaces on one workout card, no two agreeing.

  Swift avg      workoutHrDeviceId (single)  under-reads imported after a strap re-add
  Swift chart    day union                   WRONG STRAP for a bout detected on a 2nd WHOOP
  Swift zones    day union                   same
  Swift HRR      day union                   same
  Kotlin chart   single active id            both failure modes
  Kotlin zones   single active id            both
  Kotlin HRR     day union                   wrong strap for detected

So on iOS the Avg HR and the HR curve on the same card could describe DIFFERENT straps.
Not a Kotlin-vs-Swift divergence — a live self-inconsistency.

Neither existing rule is right on its own. The correct read is source-class-dependent,
which workoutHrDeviceId already encoded; it just returned one id where one case needs two:

  detected          -> [recording strap]   a union mixes in a strap that never recorded it (ryanbr#510)
  manual / imported -> active u canonical  no strap of its own, and the worn strap may bank
                                           under either after a re-add (ryanbr#814)

workoutHrDeviceIds returns that list on both platforms and all FOUR surfaces consume it:
chart, zone minutes, Avg HR / Effort, and heart-rate recovery. HRR reads past the bout,
which is the one reason it might have wanted different treatment; it does not, since the
strap that recorded the session is still on the wrist minutes later.

The aggregate needed real work. A naive `deviceId IN (...)` double-counts a second banked
under both ids — inflating n and skewing avg, silently, because both numbers stay
plausible. GROUP BY ts with MIN(pri) keeps one row per second and takes the primary is,
matching the dedup the chart already did. Fixed 2-arity rather than a bound collection:
importedReadIds is 1 or 2 by construction, and Room @query is static SQL that cannot
express per-id priority through an IN list.

Passing the same id twice is byte-identical to the old single-id read, so a single-WHOOP
install is unchanged and needs no special case. That is the control test.

The aggregate lives in WhoopStore, where swift-packages.yml actually runs its tests — the
placement that made ryanbr#841 Swift half verifiable and its Kotlin half not.

Verified against SQLite before commit, and the Kotlin @query was extracted from source and
executed to confirm it returns the same numbers as the Swift twin:
  A(0..9) + B(5..19), A primary -> n=20 avg=150.0 max=200   (naive IN would say 25)
  B primary                     -> n=20 avg=175.0
  PPG anti-join per id          -> n=15 max=130
  same id twice                 -> identical to the pre-change read

Tests: four added in WhoopStoreTests (overlap, precedence, per-id PPG anti-join, single-id
control); five existing moved to the two-id signature with assertions untouched; both
resolver tests moved to the list API, plus two new Swift cases for the behaviour the rename
exists for — an imported row getting the full union on a multi-namespace install, and a
detected row still resolving to one id on that same install.

Day-level hrSamplesUnion / hrBucketsUnion are untouched and still used by Today, the
full-day chart and the auto-workout nudge, where the union is the correct read.

Swift WorkoutRow carries no deviceId, so its resolver takes only source where Kotlin also
takes rowDeviceId — pre-existing, documented in the original ryanbr#510 note.

NOT field-verified: the bug only manifests on a multi-strap install, which nobody here has.
The two-id path is covered by CI tests against real SQLite and a single-strap control
proving the common case is byte-identical, but no one has confirmed it on two straps.

The Kotlin half is not compiled or run by CI.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…lassify a strap that never emits it (ryanbr#860)

tools/linux-capture/validate_spo2_candidate.py (ryanbr#814, extended by ryanbr#819) decides whether
spo2_candidate_82 graduates to a real spo2Pct. Three properties of @82 made its numbers unreliable.

All three established on an 18,650-record v18 corpus: 18,602 from @digitalerdude's public
PacketLogger capture of an official-app overnight sync (posted on ryanbr#103), plus 48 from a NOOP sync on
a subscription-free strap.

1. @82 IS DUTY-CYCLED, AND THE HARNESS DID NOT KNOW. Nonzero in 450/18,650 records (2.41 %), 24
distinct values in 1..160, falling in 15 contiguous runs of exactly 30 records each, every run
starting at unix % 1200 == 337 with zero phase variance. A 30-second measurement once every 20
minutes. A capture not aligned to that phase reads all zeros and is indistinguishable from a strap
with the feature off — sub-sampling the corpus at one record per minute off-phase reproduces it
exactly: 310 records, zero nonzero @82, 0/16 windows. A plausible contributor to the contradictory
cross-device evidence on ryanbr#103, where one device correlated +0.99 and another moved opposite; the
second may not have disagreed so much as missed the window.

detect_duty_cycle() now measures period, phase, window length, count and jitter per capture and
classifies it absent / continuous / duty_cycled / irregular. Only duty_cycled changes aggregation,
so a capture it cannot characterise is never silently reweighted. The phase is deliberately NOT a
constant: the robust fact is that a stable periodic window exists, and 337 is one strap's firmware.
Verified independently on four schedules the tests do not use, including phase > period and a
2-minute period — all recovered exactly — and it falls through to irregular on two windows,
scattered firings and phase jitter, with the jitter tolerance scaling to capture cadence rather than
fixed. Robust to 3 of 10 windows missing without the period collapsing to a multiple.

Nightly aggregate is now the mean of per-window means, not of every second: a 30-second burst is one
measurement, and per-second averaging hands the night to whichever window was sampled most densely
(95.92 vs 93.70 on the real corpus). Coverage is reported per night and warned on below
--min-window-coverage.

2. IN-BAND-ONLY SCREENING IS NOT SPECIFIC. Screening on "value in 70-100" is a range, not a
signature: six offsets are in-band on a majority of records on a real strap, including @17 — byte 2
of the u32 unix timestamp at @15 — and the already-decoded cardiac_flags, step_cadence and two aux
thermal channels. A nightly SpO2 cannot have 2 distinct values. The scan now requires >=5 distinct
in-band values and stdev >=0.5 before ranking a correlation, reports rejected offsets rather than
dropping them silently, and holds @82 to the same floor. The regression test plants a 2-valued byte
that correlates BETTER than @82; without the floor it wins the scan and the tool concludes "not
specific to @82" from a byte that cannot be a measurement.

3. A STRAP THAT NEVER EMITS @82 IS CLASSIFIED, NOT FAILED. It is now feature_absent, neither PASS nor
FAIL, gated behind >=100 records and >=3 nominal periods of scored sleep so a short capture that
merely missed the window stays a plain FAIL.

KNOWN GAP, follow-up immediately: that evidence bar measures duration, not phase, so it does not
catch a capture whose cadence aliases with the duty period. The same working strap at 1200/337/30
over six nights of eight hours scored sleep classifies duty_cycled/fail at 1 Hz and
absent/feature_absent at one sample per five minutes — a 300 s cadence occupies only 4 residues mod
1200, so it either always hits the window or never does. This is finding 1 applied to finding 3.
It matters because it is asymmetric: feature_absent removes the device from `eligible`, so all_pass
is computed without it and promotion gets easier. record_interval_s is already computed when the
mode is absent, so the gate is a few lines.

PRIVACY. --postable still carries no raw SpO2 values — verified with distinctive per-night values,
none appear. Added columns are device timing and a classification; the phase is the useful thing to
share, being what another owner needs to align a capture.

TESTS. tools/linux-capture 178 -> 194, validator file 16 -> 32, all 16 pre-existing unchanged and
still passing; confirmed on both branches with unittest discover.

SCOPE. Python capture tooling plus two doc sections. No app code, no decoder, no schema, no
Swift/Kotlin — spo2_candidate_82 remains instrumentation-only and this does not move it. Serves the
ryanbr#103 promote gate, where until now a device could fail for having captured the wrong 30 seconds out
of every 20 minutes. Related: ryanbr#845.

Corpus credit to @digitalerdude (ryanbr#103).
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ot stale my-whoop (ryanbr#836) (ryanbr#1039)

A manual workout row is always stored under deviceId "my-whoop" (a retroactive-add
placeholder in WorkoutEditing.buildManualRow), never the strap that actually recorded it.
workoutHrDeviceIds previously keyed manual rows on that stored id, so on a re-added or
second WHOOP the live HR trace (banked under the strap's fresh "whoop-<mac>" id) was never
found, leaving Avg HR / Effort blank. Only DETECTED rows (their own source IS the recording
strap's computed id) genuinely need the single-id read; manual rows now join imported rows
on the ryanbr#814 active-strap-union read, matching what Swift's Repository.workoutHrDeviceIds has
always done. Single-WHOOP installs are unaffected (the union collapses to one id).
ryanbr added a commit that referenced this pull request Aug 25, 2026
Reported by @bartmuskala: three Health Connect cycling sessions rendering three
different pages. Two showed "AVG –" beside a populated HR curve, a PEAK, a LOW, a
full zone split labelled "From strap HR", and — on one — a complete HR-recovery
block. Every one of those is derived from the samples the average claimed not to
have.

The chart, zones and recovery resolve their read key through
`workoutHrDeviceIds(source, rowDeviceId, activeStrapId = vm.deviceId)`, which for
an imported row is `importedSourceIdsFor(active)` = active ∪ canonical.
`fillWorkoutHrFromStrap` took a `strapDeviceId` defaulting to the canonical id and
BOTH UI call sites left it defaulted, so the fill resolved the canonical id ALONE.
On an install whose strap banks under a non-canonical id — a re-add, or a 5/MG's
transient CB-UUID pairing (#1193) — the graph found the trace and the fill did not.

The history names the regression precisely. `strapDeviceId` and its canonical
default came from #77, the original imported-HR feature. The union resolver came
later, in #857 — "one HR device-id rule for the chart, zones, Avg HR and HRR".
That commit wired the fill to the new resolver but kept passing it the #77-era
parameter, so the one rule ran on two inputs and Avg HR was the one surface that
never got it. It is also #836/#928 for the same reporter: that fix made the
resolver return the active union precisely so a re-added strap stops leaving Avg
HR and Effort blank, and this call site handed it a constant instead.

Both call sites now pass the active strap, and the parameter is REQUIRED — nothing
depended on the default once they were corrected, and a caller that cannot name
the strap it means should not be quietly handed the canonical one. Swift has no
such parameter at all (`reconcileWorkoutHrWithTrace` reads the Repository's active
deviceId directly), so this brings Android to the reference implementation rather
than changing shared behaviour.

The parameter doc claimed the canonical default was deliberate, on the grounds
that which strap was worn during an imported session is undetermined on a MULTI-
strap install. True, and unresolved by this — but it made the common SINGLE-strap
case wrong, and the union with its first-wins merge is exactly what the chart
already applies to the same window. Rewritten to say that rather than the opposite.

Test pins the asymmetry rather than the call site, which is a ViewModel hop and
unreachable from a Robolectric-free suite: the canonical constant resolves to a
STRICT subset that cannot see a non-canonical strap's samples.

Verified: Android suite 4308 green, assembleFullDebug, doc lint.

Two limits worth recording. The reporter attached no strap log, so their active id
being non-canonical is likely (a 5.0, given #1193) but unconfirmed — the fix
restores the documented display == graph == zones == effort invariant regardless
and can only widen the id set, never narrow it. And `AppViewModel.deviceId`
snapshots a `by lazy` value, so a strap switch needs an app restart before reads
follow; that is pre-existing, applies identically to the chart, and diverges from
Swift, whose read id is re-pointed on every registry change (#814).
@ccchaney04 ccchaney04 mentioned this pull request Sep 11, 2026
5 tasks done
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.

2 participants