From ebf5f57d9f986d3a28ccc2c55509bac583a1ea91 Mon Sep 17 00:00:00 2001 From: Pipiche Date: Thu, 24 Sep 2026 08:58:08 +0200 Subject: [PATCH 1/2] fix(diag): keep the Night line's rr= counting beats banked, on both platforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-Oura-channel selection changed what `rr=` in the strap log's `Night …: grav=N hr=N rr=N resp=N skin=N` line counts, from beats banked to beats scored, on BOTH platforms: Swift DebugDataDiagnostics reads WhoopStore.rrIntervals, and Kotlin AndroidDiagnostics reads rrIntervalsForDevice, which routes to the same selecting SQL. Add WhoopStore.rawRrIntervals(deviceId:from:to:limit:), the twin of Kotlin WhoopDao.rawRrIntervals / RAW_RR_INTERVALS_SQL (0x6E and tsSuspect excluded, no selection, same ORDER BY), and point both Night lines at the raw read, so `rr=` means what it meant in every log filed before and the two platforms still agree. This also pairs the Kotlin rawRrIntervals in the parity ledger. Test: RrSourceChannelTests.testTheRawDiagnosticReadKeepsBothBeatChannels, on the same fixture and expected values as Kotlin OuraOneChannelReadSqliteTest.theRawExportReadKeepsBothBeatChannels. Refs #2424 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y9RCdbENSxgnGFTikJPbBq --- .../WhoopStore/Sources/WhoopStore/Reads.swift | 22 +++++++++++++++++++ .../RrSourceChannelTests.swift | 11 ++++++++++ Strand/System/DebugDataDiagnostics.swift | 4 +++- .../com/noop/testcentre/AndroidDiagnostics.kt | 4 +++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 94752f48a1..5f7c91137d 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -499,6 +499,28 @@ extension WhoopStore { } } + /// The diagnostic read: every beat channel except the `spo2Ibi` (0x6E) duplicate, with NO scoring + /// selection, so a strap-log count or an export still carries both Oura beat channels as each other's + /// cross-check. This is what `rrIntervals` returned before the one-Oura-channel selection. Quarantined + /// (`tsSuspect`) beats stay excluded. Twin of Kotlin `WhoopDao.rawRrIntervals` (`RAW_RR_INTERVALS_SQL`). + public func rawRrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval + WHERE deviceId = :d AND ts >= :f AND ts <= :t + AND (srcChannel IS NULL OR srcChannel <> :rrx) + AND (tsSuspect IS NULL OR tsSuspect <> 1) + ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :lim + """, arguments: ["d": deviceId, "f": from, "t": to, + "rrx": RRSourceChannel.spo2Ibi.rawValue, "lim": limit]) + .map { row in + RRInterval(ts: row["ts"], rrMs: row["rrMs"], + srcChannel: (row["srcChannel"] as Int?).flatMap(RRSourceChannel.init(rawValue:)), + ord: row["ord"] as Int?, seq: row["seq"]) + } + } + } + public func events(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [WhoopEvent] { try syncRead { db in try Row.fetchAll(db, sql: """ diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift index 7f8961d82b..e7547a80b3 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift @@ -297,6 +297,17 @@ final class RrSourceChannelTests: XCTestCase { /// 12 s of 0x60 (the full train), 4 overlapping 0x80 beats (the partial copy), 0x6E over the same /// seconds (excluded by name), and one unlabelled row. Mirrored exactly in the Kotlin test. + /// The diagnostic read makes no selection: both Oura beat channels come back, 0x6E still excluded. + /// Same fixture and expected values as Kotlin `OuraOneChannelReadSqliteTest.theRawExportReadKeepsBothBeatChannels`. + func testTheRawDiagnosticReadKeepsBothBeatChannels() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "ring", mac: nil, name: nil) + _ = try await store.insert(Streams(rr: Self.oneChannelFixture(ts: ts)), deviceId: "ring") + let read = try await store.rawRrIntervals(deviceId: "ring", from: ts, to: ts + 200, limit: 1_000) + XCTAssertEqual(Set(read.compactMap { $0.srcChannel?.rawValue }), [1, 3]) + XCTAssertEqual(read.count, 12 + 4 + 1) + } + static func oneChannelFixture(ts: Int) -> [RRInterval] { var rows: [RRInterval] = [] for i in 0..<12 { rows.append(RRInterval(ts: ts + i, rrMs: 1000 + i, srcChannel: .ibiAmplitude)) } diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 58a6af6645..64588daacb 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -281,7 +281,9 @@ enum DebugDataDiagnostics { } let grav = (try? await store.gravitySamples(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] let hr = await repo.hrSamples(from: cs.startTs, to: cs.endTs, limit: 200_000) - let rr = (try? await store.rrIntervals(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] + // Beats BANKED, not beats scored: the raw read, so `rr=` keeps the meaning it had in every log filed + // before the one-Oura-channel selection, on both platforms (Kotlin `AndroidDiagnostics`). + let rr = (try? await store.rawRrIntervals(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] let resp = (try? await store.respSamples(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] lines.append("Night \(dayStamp(cs.startTs))" + funnelFallbackNote(chosenDay: dayStamp(cs.startTs), diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index 948ca2d222..4f73d98a2c 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -382,7 +382,9 @@ object AndroidDiagnostics { } val grav = repo.gravitySamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) val hr = repo.hrSamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) - val rr = repo.rrIntervalsForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) + // Beats BANKED, not beats scored: the raw read, so `rr=` keeps the meaning it had in every log + // filed before the one-Oura-channel selection, on both platforms (Swift `DebugDataDiagnostics`). + val rr = repo.rawRrIntervalsForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) val resp = repo.respSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) add( "Night ${dayStamp(session.startTs)}" + From 10f1aa49aeee10009deee4188a8705b10cc941cf Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Fri, 25 Sep 2026 23:02:56 +1200 Subject: [PATCH 2/2] diag: say what the banked rr= count does to a WHOOP 5, and to a redrained night Two consequences of pointing the Night line at the raw read, documented rather than discovered. A WHOOP 5's `rr=` does not go back to what it was; it steps UP. The strict single-transport selection in `rrIntervals` predates the one-Oura-channel change, so a 5/MG's `rr=` was already the scored transport in every older log. The raw read drops that too, and now counts every banked transport. That is the right answer for a line whose job is to say what the strap banked, since a legacy row that cannot be spliced into a scored beat train was still banked, and one number meaning "banked on a ring, scored on a strap" would be worse. But it is a change, on a device this PR's own summary describes as unaffected, so it is written down where the number is produced. The raw read also does not collapse a record the drain served twice, where `rrIntervals` now does (#2456). On a redrained night `rr=` therefore counts the duplicates while the `hrv diag` line does not, and the two together are what shows a redrain happened at all. A strap log and the app disagreeing on beat counts is the signal rather than a fault, which is worth stating before the next report reads it as one. --- Packages/WhoopStore/Sources/WhoopStore/Reads.swift | 13 +++++++++++++ Strand/System/DebugDataDiagnostics.swift | 3 +++ .../java/com/noop/testcentre/AndroidDiagnostics.kt | 3 +++ 3 files changed, 19 insertions(+) diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 5f7c91137d..90abae7778 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -503,6 +503,19 @@ extension WhoopStore { /// selection, so a strap-log count or an export still carries both Oura beat channels as each other's /// cross-check. This is what `rrIntervals` returned before the one-Oura-channel selection. Quarantined /// (`tsSuspect`) beats stay excluded. Twin of Kotlin `WhoopDao.rawRrIntervals` (`RAW_RR_INTERVALS_SQL`). + /// + /// BANKED, on every device, which is not the same as "unchanged" everywhere. For a ring this restores + /// what `rr=` counted in every log filed before #2423. For a WHOOP 5 it CHANGES that number, because + /// the strict single-transport selection in `rrIntervals` predates #2423: a 5/MG's `rr=` now counts + /// every stored transport rather than the one scored. That is deliberate. This read answers what the + /// strap banked, and a legacy WHOOP 5 row that cannot be spliced into a scored beat train was still + /// banked; one number meaning "banked on a ring, scored on a strap" would be the worse answer. Expect + /// a 5/MG's `rr=` to step up once, and to sit above the scored count from then on. + /// + /// It also does NOT collapse a record the drain served twice (#2456), where `rrIntervals` does. So on + /// a redrained night this counts the duplicates and the `hrv diag` line does not, and the two together + /// are what shows a redrain happened at all. A strap log and the app disagreeing on beat counts is the + /// signal, not a fault. public func rawRrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] { try syncRead { db in try Row.fetchAll(db, sql: """ diff --git a/Strand/System/DebugDataDiagnostics.swift b/Strand/System/DebugDataDiagnostics.swift index 64588daacb..e1b828978e 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -283,6 +283,9 @@ enum DebugDataDiagnostics { let hr = await repo.hrSamples(from: cs.startTs, to: cs.endTs, limit: 200_000) // Beats BANKED, not beats scored: the raw read, so `rr=` keeps the meaning it had in every log filed // before the one-Oura-channel selection, on both platforms (Kotlin `AndroidDiagnostics`). + // On a WHOOP 5 this steps the number UP rather than restoring it: the strict transport + // selection predates that change, so `rr=` now counts every banked transport. Banked is what + // this line has always meant. See `rawRrIntervals` for why, and for the #2456 interaction. let rr = (try? await store.rawRrIntervals(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] let resp = (try? await store.respSamples(deviceId: did, from: cs.startTs, to: cs.endTs, limit: 200_000)) ?? [] lines.append("Night \(dayStamp(cs.startTs))" diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index 4f73d98a2c..fe861d0b72 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -384,6 +384,9 @@ object AndroidDiagnostics { val hr = repo.hrSamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) // Beats BANKED, not beats scored: the raw read, so `rr=` keeps the meaning it had in every log // filed before the one-Oura-channel selection, on both platforms (Swift `DebugDataDiagnostics`). + // On a WHOOP 5 this steps the number UP rather than restoring it: the strict transport + // selection predates that change, so `rr=` now counts every banked transport. Banked is what + // this line has always meant. See `rawRrIntervals` for why, and for the #2456 interaction. val rr = repo.rawRrIntervalsForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) val resp = repo.respSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) add(