Skip to content

[master] Bug report #1331

Open
Open

Description

@cybermicky0

Screenshot_20260814-212515.png

Screenshot_20260814-212847.png

Respiratory data not showing up. Pls fixed the issue.

Activity

  1. ryanbr commented on Aug 15, 2026

    @ryanbr
    Owner

    Thanks for the report! Your respiratory data isn't lost — it's all still there in the Respiratory screen (your last reading was 22 Jul). What's happening is that recent nights aren't producing a new number: respiratory rate is worked out from your sleep, so it needs a good night of data to show up.

    To help me see why your recent nights aren't giving one, could you send a diagnostic export? Settings → Advanced → (strap log / diagnostics), then paste or attach it here. That'll show whether it's a data thing or something on our side.

    A couple of quick questions too: which strap (4.0 / 5.0 / Oura), and are you wearing it for full nights? Leaving this open until we get the log.

  2. znkandrei commented on Aug 15, 2026

    @znkandrei

    Hello, i have the same issue. Whoop4 and respiratory stopped showing me data since 11 aug. the moment i switch to V10.

    noop-strap-log-260815-1005.txt

  3. ryanbr commented on Aug 15, 2026

    @ryanbr
    Owner

    Thanks for the log, @znkandrei — it pins the cause, and it's not a data/wear problem (so my earlier "needs a good night of data" was off the mark; apologies).

    What the log shows

    Your recent nights are fully captured and sleep IS detected:

    • Night 2026-08-15: hr=26380 rr=75874 resp=26380 — HR, R-R and resp samples all present.
    • [sleep] sleepProvenance provenance=measured hoursAsleep=364 — the night staged fine.

    What fails is the respiratory rate derivation: REM-funnel: … resp=ABSENT … noRespBar=313, and recovery drops it (nilTerm dropped=[resp,…]). Sleep staging itself is fine — the funnel has a no-resp fallback and still produced a hypnogram; only the respiratory number is missing.

    Root cause — a V10 regression, tied to the WHOOP 4.0 R-R over-count

    Respiration is derived by RSA from the R-R stream. In V10, #1127 added a gate that returns no-data for any R-R stream that isn't beat-accurate (written for Oura's banked overnight IBI). But WHOOP 4.0's R-R is over-counted — the #1008 artifact stamps several R-R in one coarse second — which is visible right in your log: rr=75874 against hr=26380, ~2.9× more R-R than heartbeats. That over-count is timing-indistinguishable from a banked stream, so #1127's gate catches it → RSA returns no-data → resp=ABSENT → the blank you're seeing. That's exactly why it started "the moment I switched to V10."

    This is not a bug in #1127 — its gate is correct for Oura, and it correctly refuses a stream RSA can't read. The catch is that WHOOP 4.0's over-count trips the same wire. (cc the #1127 gate — /ref #1127.)

    The fix

    This is the same over-count behind #1008 (HRV ≠ WHOOP) and #1118 (the HRV "unverified" caveat) — and the correct fix is the same one: the two-channel R-R de-dup. De-duped beats are beat-accurate, so they pass #1127's gate legitimately and RSA runs, restoring respiratory rate. So the de-dup now fixes three symptoms at once (HRV mismatch, HRV over-count, respiratory blank), which makes it the clear next priority; it'll be validated against ground-truth R-R (there's a WHOOP-4.0-plus-Polar-H10 dataset on #1118) rather than shipped on a guess.

    A note on the interim: I'd steer away from just un-gating 4.0 — RSA rebuilds beat-time from the R-R values, so the duplicated beats would read the rate low, i.e. a wrong number rather than a blank. #1332's "carry last-known respiratory" is the honest stopgap until the de-dup lands.

    Leaving open to track the de-dup fix.

  4. znkandrei commented on Aug 15, 2026

    @znkandrei

    Thanks for your explication, let me know if i can help you with other logs for Whoop 4 and iOS. Thanks!

  5. ryanbr commented on Aug 15, 2026

    @ryanbr
    Owner

    If you can get update logs (or multiple log files of the issue), in the 10.1.0 newest staging builds. It'll help debug it further

  6. 5 remaining items

  7. ryanbr commented on Aug 17, 2026

    @ryanbr
    Owner

    The latest 10.1 log (thanks @znkandrei) confirms the open facet is unchanged, and pins down why the current shadow de-dup can't close it.

    Same-second collapse is structurally insufficient — the over-count is cross-second. Every night reads crossSecondOverCount, and #1352's shadow (exact 0 ms + 40 ms same-second collapses) leaves the heavy nights over-counted: cov40 stays ~1.7–2.0, beatAccurate ~0.46, so respiration stays blanked by #1127's 0.5 gate. The hrv rrsample dump shows the duplicate twins straddling the second boundary (-1s[500,500,1048] 0s[477,477,477,500,500,…]), which a same-second collapse can't reach by construction.

    Per-night, the 40 ms same-second collapse restores the milder nights but not the heavy ones:

    night coverage → cov40 beatAccurate → beatAcc40 resp
    08-08 1.79 → 1.48 0.50 → 0.60 ✅ clears 0.5
    08-14 1.80 → 1.35 0.46 → 0.62 ✅
    08-13 2.04 → 1.51 0.43 → 0.55 ✅
    08-15 2.70 → 1.95 0.36 → 0.46 ❌ still blank
    08-12 2.49 → 1.72 0.33 → 0.46 ❌ still blank

    "Two-channel de-dup" doesn't apply to WHOOP — worth flagging before it's built. RRSourceChannel is an Oura-only concept (0x80 green / 0x6E spo2 / …). The WHOOP R-R decode reads a single rr_intervals array per record and writes srcChannel = nil (the log's src=none) — both optical channels' IBIs are already merged into that one field and aren't separable at decode. So there's no channel to drop; the de-dup has to be value/time-based.

    #1399 sizes the cross-second component. It adds a third shadow candidate, xsec — the 40 ms collapse widened to a 1-second window — logged beside the others (xsecN/covXsec/beatAccXsec), so a capture shows whether a cross-second collapse drives coverage → ~1.0 and clears the 0.5 gate. Instrumentation only, and a strict upper bound — deliberately not shippable: a steady real HR has ~identical intervals one second apart, so it over-merges real beats (the test pins a 10-beat steady stream dropping to 5 at window 1).

    The real fix is density/timeline-based (keep beats so each ts-gap matches its own interval) and needs validating against ground truth (@artemc's H10, #1118) before it becomes the read path — xsec gives that validation its target.

  8. gdorgian commented on Aug 27, 2026

    @gdorgian

    The WHOOP over-count looks like two transports of one beat train, not two optical channels

    Following up on the 5.0/MG numbers I posted above, and on this from 17 Aug:

    "Two-channel de-dup" doesn't apply to WHOOP … RRSourceChannel is an Oura-only concept (0x80 green / 0x6E spo2 / …). The WHOOP R-R decode reads a single rr_intervals array per record … both optical channels' IBIs are already merged into that one field and aren't separable at decode.

    That is right about optical channels, and I am not disputing it — a WHOOP merges those before we ever see them. But there is a second axis. The strap emits the beat train live over 0x2A37 and replays the same beats inside its historical records, and those two arrive on different code paths. That is separable at decode, because the writer knows which path it is on.

    What the field data shows

    WHOOP 5.0/MG, firmware 50.35.1.0, iOS, continuous wear. A transport tag (whoopStandardBLE / whoopRealtime / whoopHistorical) was added on 21 Aug, and the scoring read now prefers history where it exists and falls back to live where it does not.

    night coverage collapsedCov beatAccurate rrIntegrity SDNN
    08-12 1.84 1.36 0.46 crossSecondOverCount withheld
    08-16 1.84 1.23 0.47 crossSecondOverCount withheld
    08-17 1.81 1.28 0.46 crossSecondOverCount withheld
    08-21 1.78 1.23 0.44 crossSecondOverCount withheld
    08-22 0.82 0.78 0.88 underCovered 73 ms
    08-23 0.92 0.84 0.85 plausible 65 ms

    08-22 is the first night scored with tagged rows.

    Three things in the same log that point at transport rather than value

    1. Every stored second carries exactly two rows. The delivery histogram for 08-13:

    rr deliveries secs[1/2/3/4+]=1435/26235/58/0 multiSec=95% multiRows=97% multiMs=97% maxDeliv=3
    

    95% of occupied seconds hold two R-R rows at meanNN=898 ms (~67 bpm), where ~1.1 is the physical ceiling. multiMs=97% says the two values differ, which is why exact de-dup only removes 99 of 60,611 rows.

    2. The historical path alone is not doubling. Its own emission line stays at roughly one interval per stamped second:

    rr emit path=historical offered=1171 inserted=1171 secs=1010 … perSec[1/2/3/4+]=853/153/4/0
    Backfill: hist clock chunk=1 offset=+1s … pack=1.00 max=1 span=12s dens=1.00
    

    So the second copy comes from somewhere else.

    3. src=none is doing more work than it looks. In densestSecondWindowSample that field is computed over the whole night's stream, not the sampled second — it prints none only when no beat anywhere in the night carries a channel code. So on every over-counted night, every row was untagged, and the scoring read's deliberate "keep NULL" branch passed both copies straight through. That is the mechanism, and it also explains why the fix could only ever help nights banked after the tagging landed.

    Why it presents as cross-second

    History is stamped from the record's embedded strap second; live is stamped at phone receive time. A public HCI capture (verified in #1505) puts the same beat 1 or 2 s apart, and a later field log shows +3 s. So the twins land in different seconds by construction — which is exactly the shape you described:

    -1s[500,500,1048] 0s[477,477,477,500,500,…]

    A same-second collapse cannot reach them, and a 1-second value collapse (xsec) over-merges real beats because a steady HR has near-identical intervals one second apart. Both of those follow from the twins being time-shifted rather than value-duplicated.

    What I have NOT established

    The before/after is real, but the log cannot yet prove the cause, and I would rather say so than claim the fix:

    • The build that produced it tags rows but does not print a per-night census, so "suppression removed the second copy" is inference, not measurement.
    • 08-19 is a clean night in the untagged era — coverage 1.05, beatAccurate 0.81, plausible, and only 34,052 beats against 44–60k on its neighbours. A night where the phone banked few live rows reads clean without any fix. So thin live coverage is a live alternative explanation for 08-22/23 that this log does not exclude.

    What settles it is a per-night count of stored rows by transport plus how many live rows the selector suppressed. That is cheap and I will post one from a night's wear.

    If it holds

    It is a decode-side change rather than a de-dup heuristic, it needs no ground truth to validate, and it should make both generations beat-accurate from one change — which would clear #1127's 0.5 gate and restore respiration. Happy to be wrong about the mechanism; the census will say.

  9. gdorgian commented on Aug 28, 2026

    @gdorgian

    Correction to my comment above: that mechanism is iOS-only

    @Zebsi235's data on #1505 sent me back to the storage paths, and I scoped my last comment too broadly. The transport duplication I described is real, but it cannot be what most of this thread is seeing.

    The standard 0x2A37 copy reaches the durable store on iOS and not on Android.

    • iOS: Collector.ingestStandardHR buffers an RRInterval for every plausible standard R-R, and flushStandardHR persists it via store.insert(Streams(hr:rr:), deviceId:). So the live copy and the historical replay of the same beats both land.
    • Android: WhoopBleClient.kt:6631 sends it to _state.update { it.withRRIntervals(rr) }, and withRRIntervals (:234) writes rrRecent — a bounded 60-item rolling buffer for the Live console. insertRr has one caller, WhoopRepository.kt:501, fed from streams.rr, which the standard profile is not part of.

    (The custom REALTIME_DATA type-40 frames do feed streams.rr on both platforms when realtime streaming is on. The asymmetry is specifically the standard profile.)

    What this means for the fix

    Transport tagging cannot help Android. There is no second transport in the store to prefer between. @znkandrei's 4.0 numbers, @Zebsi235's 70k pairs and the original report are all Android, so the mechanism I posted does not explain them.

    It is still the right fix for iOS, and the before/after I posted stands as an iOS result — with the caveat I already gave, that thin live coverage is not yet excluded as an alternative reading of those two nights.

    The two platforms may be two different bugs wearing one symptom. 2.9× on Android 4.0, 1.8× on iOS 5.0/MG. I had been reading those as one cause at different severities. That was an assumption, and the storage paths do not support it.

    Where the Android half probably lives

    @Zebsi235's pairs are historical-against-historical by construction, which makes them a clean measurement. Against the public capture measured on #1451 — 14,171 of 18,602 v18 records carry exactly one interval, only 365 carry two or more — a second holding two ord 0 rows is two records stamped onto the same timestamp, not one record decoding into two intervals.

    So on Android the over-count looks like a timestamp-assignment problem rather than a duplication one, which also fits the crossSecondOverCount verdict and your finding that a same-second collapse cannot reach it. The measurement I would want is: for seconds holding two ord 0 rows, how far apart were the two records' embedded timestamps before rounding put them in the same second.

    Sorry for the over-claim. The iOS result is unchanged; the generalisation was mine and it was wrong.

  10. ryanbr commented on Aug 28, 2026

    @ryanbr
    Owner

    Field data for the open facet here — the blank respiration, not the display-carry half that #1362 settled. From a strap log on Android (app 10.6.1, WHOOP 4.0, fw 41.17.6.0), 21 days.

    @gdorgian — this may be useful against your 28 Aug correction, because it is on the platform your narrowing leaves unexplained.

    The gate refuses essentially every night

    Of the 21 days with a verdict, 3 are simply dataless (that phone's 5.0 has not synced in 2 days) and never reach the gate. Of the 18 that do reach it, 17 are refused:

    beatAccurate days
    0.44 4
    0.45 5
    0.46 5
    0.47 2
    0.48 1

    The gate is 0.50. The whole population sits underneath it — this is not a tail of poor nights being correctly rejected, it is the distribution living below the threshold. Every refusal carries the same reason: rrIntegrity=crossSecondOverCount.

    The over-count, quantified

    The interesting number is not beatAccurate, it is coverage, and it is remarkably stable:

    coverage=1.58 collapsedCov=1.31 dupBeats=246 beatAccurate=0.45
    coverage=1.67 collapsedCov=1.42 dupBeats=214 beatAccurate=0.48
    coverage=1.77 collapsedCov=1.48 dupBeats=194 beatAccurate=0.44
    coverage=1.81 collapsedCov=1.62 dupBeats=215 beatAccurate=0.44
    coverage=1.83 collapsedCov=1.47 dupBeats=118 beatAccurate=0.46
    

    Across all 18 nights coverage runs 1.58 – 1.83 — 58% to 83% more R-R intervals than there are seconds of recording — and rejected=0% throughout, so nothing is being discarded before the count. That is the over-count in one number, and it never once lands near 1.0.

    Why this is Android-specific evidence

    Your 28 Aug comment established that the standard 0x2A37 copy reaches the durable store on iOS but not on Android — withRRIntervals writes only the bounded rrRecent ring for the Live console. This log is Android, and the over-count is still 1.58–1.83 every night. So whatever mechanism is doubling beats here, it is not the live/historical transport duplication, which supports your narrowing rather than contradicting it.

    One anomaly worth a second look

    The single night that passed did not have the lowest coverage:

    rr=43489/43333 rejected=0% coverage=1.61 collapsedCov=1.41 dupBeats=134 beatAccurate=0.52
    

    coverage=1.61 is mid-range, and nights at 1.58 and 1.60 scored 0.45. What that night does have is the lowest dupBeats (134 against 199–276 elsewhere). So beatAccurate is not tracking coverage alone, and dupBeats looks like the stronger lever. If the over-count has more than one component, that is where the split may show.

    Not proposing a threshold change

    Worth stating plainly since the numbers invite it: I do not think the answer is moving 0.50. The gate is behaving correctly on the input it is given — the input is wrong, and lowering the bar would ship a respiration number computed from a beat train we know is over-counted. This belongs with #1118.

    Happy to pull anything else out of the log — per-night rr=offered/inserted, the full dupBeats series, or the same for the 5.0 once it syncs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions