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
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,12 @@ public func isEmptyRecordFrame(_ frame: [UInt8]) -> Bool {
/// during a historical backfill, where type-40 frames are absent.
/// EVENT and COMMAND_RESPONSE handling is identical to extractStreams.
/// CRC-failed and non-ok frames are skipped.
///
/// Twin of Kotlin `extractHistoricalStreams`. Named here because adding `family` re-identified this
/// function for the parity inventory, which keys on arity: the pair is unchanged, only its key moved.
public func extractHistoricalStreams(_ parsed: [ParsedFrame],
deviceClockRef: Int, wallClockRef: Int,
family: DeviceFamily? = nil,
// SESSION-RELATIVE bounds (#547): the strap's own GET_DATA_RANGE
// oldest/newest markers for THIS sync. nil on the replay/import/no-range
// paths — the gate then falls back to the absolute-only floor (unchanged).
Expand Down Expand Up @@ -298,6 +302,7 @@ public func extractHistoricalStreams(_ parsed: [ParsedFrame],
}
if let rrs = p["rr_intervals"]?.intArrayValue {
let source = p["rr_source_channel"]?.intValue.flatMap(RRSourceChannel.init(rawValue:))
?? (family == .whoop4 ? .whoop4Historical : nil)
for rr in rrs { out.rr.append(RRInterval(ts: ts, rrMs: rr, srcChannel: source)) }
}
if let red = p["spo2_red"]?.intValue {
Expand Down
9 changes: 6 additions & 3 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public enum StandardHRContact: String, Equatable, Codable, Sendable {
/// The sensor channel or transport that produced an R-R interval.
///
/// WHOOP 5 exposes one beat train over multiple transports; codes 5–7 distinguish those observations.
/// WHOOP 4 and unlabelled legacy rows keep nil. An Oura ring has more than one optical channel:
/// WHOOP 4 standard-BLE rows and legacy data keep nil; its type-47 historical rows use code 8. An Oura ring has more than one optical channel:
/// the green-quality tag (0x80) and the SpO2 tag (0x6E) both
/// decode to R-R and both were stored, so the table held roughly TWO complete copies of every night —
/// not duplicate rows to de-duplicate, but the SAME heartbeats measured twice. Labelling the channel is
Expand Down Expand Up @@ -70,6 +70,9 @@ public enum RRSourceChannel: Int, Equatable, Codable, Sendable, CaseIterable {
case whoop5Realtime = 6
/// WHOOP 5 standard BLE 0x2A37 live transport, already converted to milliseconds.
case whoop5Standard = 7
/// WHOOP 4.0 type-47 historical R-R intervals. The 4.0 standard BLE feed has no source label,
/// so this distinguishes the clock-anchored offload from the overlapping live feed at read time.
case whoop4Historical = 8

public var isWhoop5Transport: Bool { (5...7).contains(rawValue) }

Expand All @@ -80,8 +83,8 @@ public enum RRSourceChannel: Int, Equatable, Codable, Sendable, CaseIterable {
public struct RRInterval: Equatable, Codable {
public let ts: Int // wall-clock unix seconds
public let rrMs: Int
/// The sensor channel this beat came from, or nil when the source does not distinguish one (every
/// WHOOP 4 row, and legacy unlabelled rows). See `RRSourceChannel`.
/// The sensor channel this beat came from, or nil when the source does not distinguish one (WHOOP 4
/// standard BLE and legacy unlabelled rows). See `RRSourceChannel`.
public let srcChannel: RRSourceChannel?
/// #1008 diagnostics: this beat's EMISSION ORDER within the batch that delivered it, as stored in
/// `rrInterval.ord`. nil on a decode (nothing has been stored yet) and on rows written before the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ final class Whoop5RRTests: XCTestCase {
}

func testTransportCodes() {
XCTAssertEqual(RRSourceChannel.allCases.map(\.rawValue), Array(1...7))
XCTAssertEqual(RRSourceChannel.allCases.map(\.rawValue), Array(1...8))
}

/// The table below is the parity contract for the "this night cannot be scored" explanation. The
Expand Down
38 changes: 32 additions & 6 deletions Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ extension WhoopStore {
(SELECT COUNT(*) FROM rrInterval WHERE srcChannel = 7
AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w7,
(SELECT COUNT(*) FROM rrInterval WHERE srcChannel IN (5, 6, 7)) AS w5tagged,
(SELECT COUNT(*) FROM rrInterval WHERE srcChannel = 8) AS w4history,
(SELECT COALESCE(GROUP_CONCAT(identity, ';'), '') FROM
(SELECT QUOTE(id) || ':' || QUOTE(brand) || ':' || QUOTE(model) || ':' || QUOTE(status) AS identity
FROM pairedDevice ORDER BY id)) AS registry,
Expand All @@ -230,8 +231,9 @@ extension WhoopStore {
}
let historyCount: Int = row["w5"]
let registry: String = row["registry"]
return "v3|h\(hc):\(hm)|" + tails.joined(separator: "|")
+ "|w5\(historyCount)|w7\(row["w7"] as Int)|tagged\(row["w5tagged"] as Int)|registry\(registry)"
return "v4|h\(hc):\(hm)|" + tails.joined(separator: "|")
+ "|w5\(historyCount)|w7\(row["w7"] as Int)|tagged\(row["w5tagged"] as Int)"
+ "|w4history\(row["w4history"] as Int)|registry\(registry)"
}
}

Expand Down Expand Up @@ -280,6 +282,8 @@ extension WhoopStore {
(SELECT COUNT(*) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel = 7 AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w7,
EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :d AND srcChannel IN (5, 6, 7)) AS w5owner,
(SELECT COUNT(*) FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel = :whoop4Historical AND (tsSuspect IS NULL OR tsSuspect <> 1)) AS w4h,
COALESCE((SELECT QUOTE(brand) || ':' || QUOTE(model) FROM pairedDevice
WHERE id = :d), 'absent') AS registry,
(SELECT COUNT(*) FROM respSample WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS xc,
Expand All @@ -297,7 +301,8 @@ extension WhoopStore {
(SELECT COUNT(*) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS ec,
(SELECT COALESCE(MAX(ts), 0) FROM event WHERE deviceId = :d AND ts >= :f AND ts <= :t) AS em
""", arguments: ["d": deviceId, "f": from, "t": to,
"rrx": RRSourceChannel.spo2Ibi.rawValue]) else { return "" }
"rrx": RRSourceChannel.spo2Ibi.rawValue,
"whoop4Historical": RRSourceChannel.whoop4Historical.rawValue]) else { return "" }
let keys = ["p", "r", "x", "o", "g", "z", "t", "b", "e"]
let parts = keys.map { key -> String in
let count: Int = row[key + "c"], maxTs: Int = row[key + "m"]
Expand All @@ -306,7 +311,8 @@ extension WhoopStore {
let historyCount: Int = row["w5"]
let registry: String = row["registry"]
let strictRR = try Self.isWhoop5RRSource(db: db, deviceId: deviceId)
return "s2|" + parts.joined(separator: "|") + "|w5\(historyCount)|w7\(row["w7"] as Int)|ownerTagged\(row["w5owner"] as Int)|registry\(registry)|rr5=\(strictRR)"
return "s3|" + parts.joined(separator: "|") + "|w5\(historyCount)|w7\(row["w7"] as Int)"
+ "|w4h\(row["w4h"] as Int)|ownerTagged\(row["w5owner"] as Int)|registry\(registry)|rr5=\(strictRR)"
}
}

Expand Down Expand Up @@ -460,18 +466,37 @@ extension WhoopStore {
// One transport for the complete requested interval. Legacy WHOOP 5 rows mix units and
// origins, so they remain stored but cannot be converted or spliced into a scored beat train.
// This subquery uses the SAME time/suspect predicates as the outer read, before LIMIT.
// A WHOOP 4 takes the history branch below: its type-47 offload is labelled, its standard-BLE
// feed is not, and the two overlap, so one or the other is scored for the whole interval.
// Every other source takes the Oura branch: one beat channel for the interval, the fuller one
// (see the doc comment on `rrIntervals`), with NULL and non-Oura codes passing untouched.
let strictWhoop4History: Bool
if strictWhoop5 { strictWhoop4History = false }
else {
if try Self.isWhoop4RRSource(db: db, deviceId: deviceId) { strictWhoop4History = true }
else {
strictWhoop4History = try Bool.fetchOne(db,
sql: "SELECT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = ? AND srcChannel = ?)",
arguments: [deviceId, RRSourceChannel.whoop4Historical.rawValue]) ?? false
}
}
let sourcePredicate = strictWhoop5 ? """
srcChannel = (SELECT MIN(srcChannel) FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t AND srcChannel IN \(Self.scorableWhoop5Channels)
AND (tsSuspect IS NULL OR tsSuspect <> 1))
""" : (strictWhoop4History ? """
((EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel = :whoop4Historical AND (tsSuspect IS NULL OR tsSuspect <> 1))
AND srcChannel = :whoop4Historical)
OR (NOT EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel = :whoop4Historical AND (tsSuspect IS NULL OR tsSuspect <> 1))
AND srcChannel IS NULL))
""" : """
(srcChannel IS NULL OR srcChannel NOT IN \(Self.scorableOuraChannels) OR (srcChannel = 1) = (
SELECT SUM(srcChannel = 1) > SUM(srcChannel <> 1) FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t AND srcChannel IN \(Self.scorableOuraChannels)
AND (tsSuspect IS NULL OR tsSuspect <> 1)))
"""
""")
let rows = try Row.fetchAll(db, sql: """
SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t
Expand All @@ -480,7 +505,8 @@ extension WhoopStore {
AND (tsSuspect IS NULL OR tsSuspect <> 1) -- #1073: exclude future-stamped beats
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])
"rrx": RRSourceChannel.spo2Ibi.rawValue,
"whoop4Historical": RRSourceChannel.whoop4Historical.rawValue, "lim": limit])
.map { row in
RRInterval(ts: row["ts"], rrMs: row["rrMs"],
srcChannel: (row["srcChannel"] as Int?).flatMap(RRSourceChannel.init(rawValue:)),
Expand Down
12 changes: 12 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ extension WhoopStore {
AND ((:source = 5 AND (srcChannel IS NULL OR srcChannel IN (6, 7)))
OR (:source = 7 AND (srcChannel IS NULL OR srcChannel = 6)))
""")
let promoteWhoop4History = try db.cachedStatement(sql: """
UPDATE rrInterval SET srcChannel = :source, ord = :ord
WHERE deviceId = :device AND ts = :ts AND rrMs = :rr AND seq = :seq
AND :source = 8 AND srcChannel IS NULL
""")
var seqByTsRr: [RRBatchSecond: [Int: Int]] = [:]
var ordByTs: [RRBatchSecond: Int] = [:]
for r in streams.rr {
Expand All @@ -258,6 +263,13 @@ extension WhoopStore {
try promote.execute(arguments: ["source": source.rawValue, "ord": ord,
"device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq])
}
// The label is the CASE just matched, so name it rather than force-unwrapping the
// optional the match already proved non-nil.
if inserted == 0, r.srcChannel == .whoop4Historical {
try promoteWhoop4History.execute(arguments: [
"source": RRSourceChannel.whoop4Historical.rawValue, "ord": ord,
"device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq])
}
}
}
if !streams.events.isEmpty {
Expand Down
13 changes: 13 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ import GRDB
import WhoopProtocol

extension WhoopStore {
/// Whether the device registry CONFIRMS this owner is a WHOOP 4, so its labelled type-47 history is
/// the stream `rrIntervals` scores for the interval.
///
/// Registry-CONFIRMED, not inferred: `confirmedRegistryFamily` returns nil for an unknown brand or an
/// unrecognised model spelling, and nil must NOT be read as "a WHOOP 4". An unregistered device falls
/// through to the row-level check instead (does any channel-8 row exist at all), so the policy never
/// depends on the registry being populated.
///
/// Twin of the Kotlin `WhoopRepository.isWhoop4RrSource`.
static func isWhoop4RRSource(db: Database, deviceId: String) throws -> Bool {
let row = try Row.fetchOne(db, sql: "SELECT model, brand FROM pairedDevice WHERE id = ?", arguments: [deviceId])
return DeviceFamily.confirmedRegistryFamily(model: row?["model"], brand: row?["brand"]) == .whoop4
}
/// The transports a WHOOP 5 window may be SCORED through, as a SQL list.
///
/// One constant rather than a literal per query. `rrIntervals` pins a window to the lowest of these
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,53 @@ import OuraProtocol
final class RrSourceChannelTests: XCTestCase {
private let ts = 1_750_000_000

func testWhoop4HistoricalSourceWinsAndPromotesLegacyLiveRows() async throws {
let store = try await WhoopStore.inMemory()
try await store.upsertDevice(id: "strap", mac: nil, name: nil)
try DeviceRegistryStore(dbQueue: store.registryWriter).add(PairedDevice(
id: "strap", brand: "WHOOP", model: "4.0", sourceKind: .liveBLE,
capabilities: [.hr, .hrv], status: .active, addedAt: ts, lastSeenAt: ts))

_ = try await store.insert(Streams(rr: [RRInterval(ts: ts, rrMs: 800),
RRInterval(ts: ts + 1, rrMs: 810)]), deviceId: "strap")
_ = try await store.insert(Streams(rr: [RRInterval(ts: ts, rrMs: 800, srcChannel: .whoop4Historical),
RRInterval(ts: ts + 1, rrMs: 820, srcChannel: .whoop4Historical)]),
deviceId: "strap")

let selected = try await store.rrIntervals(deviceId: "strap", from: ts, to: ts + 1, limit: 100)
XCTAssertEqual(selected.map(\.rrMs), [800, 820])
XCTAssertEqual(selected.map(\.srcChannel), [.whoop4Historical, .whoop4Historical])
}

func testWhoop4FallsBackToUnlabelledRowsWhenNoHistoricalDataExists() async throws {
let store = try await WhoopStore.inMemory()
try await store.upsertDevice(id: "strap", mac: nil, name: nil)
try DeviceRegistryStore(dbQueue: store.registryWriter).add(PairedDevice(
id: "strap", brand: "WHOOP", model: "4.0", sourceKind: .liveBLE,
capabilities: [.hr, .hrv], status: .active, addedAt: ts, lastSeenAt: ts))
_ = try await store.insert(Streams(rr: [RRInterval(ts: ts, rrMs: 800)]), deviceId: "strap")

let selected = try await store.rrIntervals(deviceId: "strap", from: ts, to: ts, limit: 100)
XCTAssertEqual(selected.map(\.rrMs), [800])
XCTAssertNil(selected.first?.srcChannel)
}

func testWhoop4UsesOneHistoricalTransportForTheWholeWindow() async throws {
let store = try await WhoopStore.inMemory()
try await store.upsertDevice(id: "strap", mac: nil, name: nil)
try DeviceRegistryStore(dbQueue: store.registryWriter).add(PairedDevice(
id: "strap", brand: "WHOOP", model: "4.0", sourceKind: .liveBLE,
capabilities: [.hr, .hrv], status: .active, addedAt: ts, lastSeenAt: ts))
_ = try await store.insert(Streams(rr: [RRInterval(ts: ts, rrMs: 800),
RRInterval(ts: ts + 1, rrMs: 810)]), deviceId: "strap")
_ = try await store.insert(Streams(rr: [RRInterval(ts: ts, rrMs: 805, srcChannel: .whoop4Historical)]),
deviceId: "strap")

let selected = try await store.rrIntervals(deviceId: "strap", from: ts, to: ts + 1, limit: 100)
XCTAssertEqual(selected.map(\.rrMs), [805])
XCTAssertEqual(selected.map(\.srcChannel), [.whoop4Historical])
}

// MARK: - The label survives the mapping

/// A 0x6E record and a 0x80 record covering the same interval must produce rows with DISTINCT
Expand Down Expand Up @@ -54,7 +101,8 @@ final class RrSourceChannelTests: XCTestCase {
/// The two enums are pinned to the same raw values on purpose: they are one durable storage code
/// split across two packages only because `OuraProtocol` does not depend on `WhoopProtocol`.
func testTheTwoChannelEnumsAgreeCaseForCaseAndCodeForCode() {
XCTAssertEqual(OuraIBIChannel.allCases.count, RRSourceChannel.allCases.filter { !$0.isWhoop5Transport }.count)
XCTAssertEqual(OuraIBIChannel.allCases.count,
RRSourceChannel.allCases.filter { !$0.isWhoop5Transport && $0.rawValue <= 4 }.count)
for c in OuraIBIChannel.allCases {
let mapped = OuraStreamMapping.rrChannel(c)
XCTAssertEqual(mapped?.rawValue, c.rawValue,
Expand Down
Loading
Loading