Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) }
Expand Down
7 changes: 6 additions & 1 deletion Strand/System/DebugDataDiagnostics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}" +
Expand Down
Loading