diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 94752f48a1..90abae7778 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -499,6 +499,41 @@ 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`). + /// + /// 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: """ + 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..e1b828978e 100644 --- a/Strand/System/DebugDataDiagnostics.swift +++ b/Strand/System/DebugDataDiagnostics.swift @@ -281,7 +281,12 @@ 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`). + // 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))" + 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..fe861d0b72 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,12 @@ 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`). + // 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( "Night ${dayStamp(session.startTs)}" +