Skip to content

Read WHOOP physiology across all registered straps - #1730

Merged
ryanbr merged 3 commits into
ryanbr:mainfrom
bhelm:fix/multi-strap-raw-reads-pr
Aug 29, 2026
Merged

ryanbr merged 3 commits into
ryanbr:mainfrom
bhelm:fix/multi-strap-raw-reads-pr

Conversation

@bhelm

@bhelm bhelm commented Aug 29, 2026 •

Copy link
Copy Markdown

Why

Raw physiology is currently stored under the device ID that produced or imported it. Some readers treated that storage provenance as the boundary of the user's timeline and queried only the currently active device or the legacy my-whoop source.

That breaks as soon as a user replaces, removes, archives, or alternates WHOOP straps: data remains in the database but disappears from Stress, Sleep, AI Coach, and timeline views. It also makes correctness depend on every caller remembering which device namespace happens to contain a given period.

Device IDs should remain precise write provenance, but they should not fragment reads of the user's own WHOOP physiology. The read contract therefore resolves one deterministic source set everywhere:

  1. the explicitly active source first (preserving existing explicit-device behavior),
  2. every registered WHOOP strap, including archived straps, in stable registry order,
  3. the canonical legacy/import source my-whoop last,
  4. duplicates removed while preserving that precedence.

Archived devices are deliberately included: archiving stops future connection attempts; it must not orphan retained history. Non-WHOOP devices are not implicitly folded into this WHOOP timeline.

What changes

  • Unifies Android raw HR, R-R, gravity, HR buckets, sleep sessions, computed sleep, and habitual-midsleep reads across registered WHOOP straps.
  • Applies the same source-resolution contract on Apple platforms.
  • Routes Stress, Sleep, AI Coach, Full Day/Deep Timeline, and Rhythm consumers through the device-aware repository facades.
  • Removes Android's ambiguous device-scoped repository names. Legitimate owner/export/diagnostic reads now require explicit *ForDevice APIs, while personal timeline consumers use only union APIs; an architecture test prevents the old names from returning.
  • Preserves exact R-R identity as (timestamp, rrMs, sequence) and aligns deterministic ordering across Kotlin and Swift.
  • Keeps stored canonical daily stress unchanged; only its raw intraday inputs and dependent views become device-aware.
  • Retains single-device and legacy my-whoop behavior.

Parity guard

A shared JSON oracle defines source ordering, legacy collapse, non-WHOOP handling, and R-R identity/order. Native Android and Swift tests consume the same contract, while source-wiring guards prevent consumers from regressing to direct current-device reads.

The clean PR branch passed all Android parity/wiring suites (22 tests, 0 failures). Source-level Apple parity review found no divergence. Apple tests cannot execute on the Linux build host because Xcode/Swift is unavailable.

Verification

  • Android full unit suite: 4,788 tests, 0 failures, 0 errors, 6 skipped.
  • assembleFullDebug: successful.
  • Independent final review: no open P0/P1 findings.
  • Verified on the staging build: the Stress screen displays the intraday chart from the unified raw timeline.

Known boundary

Alternating straps is supported: each strap's disjoint retained periods form one timeline. If two straps are genuinely worn at the same time, distinct same-time R-R beats can coexist because the current schema has no wear-interval/device-era ownership signal. Exact duplicates are removed deterministically; selecting a single owner for simultaneous wear is intentionally outside this change.

Related issues and PRs

@ryanbr

ryanbr commented Aug 29, 2026

Copy link
Copy Markdown
Owner

@bhelm — the read contract is right, and the architecture test that blocks the old device-scoped names from returning is a good touch. One stream worries me, and I think it needs treating differently from the others.

R-R interleaving changes the number, not the volume

The boundary is stated as benign:

Exact duplicate beats are removed with the active source winning; distinct beats sharing a timestamp remain intact.

For HR buckets, gravity, sleep sessions and habitual midsleep that reasoning holds — averaging and interval data tolerate extra rows. R-R does not, because of where it lands:

rrIntervalsUnion → StressScreen / AiCoach / FullDayChartScreen → DaytimeStress, which buckets every beat by hour and hands it to HrvAnalyzer.analyzeRaw(...) for RMSSD (DaytimeStress.kt:334-351).

RMSSD is the root mean square of SUCCESSIVE DIFFERENCES. Interleaving two straps' beats does not duplicate it — the successive differences start alternating between two independent measurements of the same heart, so they carry the inter-device offset instead of real beat-to-beat variability. The result is not a doubled number that a reader can discount; it is a wrong number that looks normal.

This is reachable today, not theoretical: I have a period of genuine simultaneous 4.0 + 5.0 wear with both straps registered, so both feed the union across that window.

It also stacks on an open defect. #1008 / #1118 have 4.0 R-R already over-counting roughly 2x via crossSecondOverCount; this would put a second, larger multiplier on top of one that is not yet fixed.

What I would suggest

Keep the union for every other stream, and hold R-R to the active strap (or exclude it) until there is a wear-interval / device-era ownership signal. That keeps the whole benefit of the change — Stress, Sleep, Coach and timeline stop losing history at a strap swap — without letting one stream's semantics change silently for anyone who has ever worn two straps at once.

Alternating straps, which is the case the PR is really aimed at, is unaffected either way: disjoint periods never interleave.

Two smaller things

@ryanbr

ryanbr commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Dispatched app-build against this PR: both legs green — macOS (Strand + StrandTests) and iOS.

That covers the gap you flagged. DeviceRawSourceParityTests and RawPhysiologyUnionTests run only under that workflow, which is disabled by default, so they had not executed anywhere — not on your Linux host and not in the 15 normal checks. They compile and pass.

The R-R question above is the only thing still open from my side.

@ryanbr
ryanbr merged commit 9a91bad into ryanbr:main Aug 29, 2026
15 checks passed
@bhelm
bhelm deleted the fix/multi-strap-raw-reads-pr branch September 15, 2026 22:35
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