Skip to content
Closed
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 @@ -170,6 +170,7 @@ public func isEmptyRecordFrame(_ frame: [UInt8]) -> Bool {
/// CRC-failed and non-ok frames are skipped.
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 +299,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,15 +70,18 @@ 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) }
}

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
36 changes: 30 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 @@ -279,6 +281,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 @@ -296,7 +300,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 @@ -305,7 +310,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 @@ -441,11 +447,28 @@ 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.
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))
""" : "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))
""" : "1")
return try Row.fetchAll(db, sql: """
SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t
Expand All @@ -454,7 +477,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
9 changes: 9 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,10 @@ extension WhoopStore {
try promote.execute(arguments: ["source": source.rawValue, "ord": ord,
"device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq])
}
if inserted == 0, r.srcChannel == .whoop4Historical {
try promoteWhoop4History.execute(arguments: ["source": r.srcChannel!.rawValue, "ord": ord,
"device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq])
}
}
}
if !streams.events.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import GRDB
import WhoopProtocol

extension WhoopStore {
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
9 changes: 5 additions & 4 deletions Strand/Collect/Backfiller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class Backfiller {
/// (parsed frames, deviceClockRef, wallClockRef, sessionOldestUnix?, sessionNewestUnix?) → Streams.
/// The trailing session-range markers are the strap's GET_DATA_RANGE oldest/newest for THIS sync
/// (#547 session-relative gate); nil when the range isn't known yet (the absolute-only floor applies).
typealias Extractor = ([ParsedFrame], Int, Int, Int?, Int?) -> Streams
typealias Extractor = ([ParsedFrame], Int, Int, DeviceFamily, Int?, Int?) -> Streams

private let store: BackfillStoreWriting
/// Device id offloaded chunks persist under. MUTABLE so a WHOOP↔WHOOP switch
Expand Down Expand Up @@ -289,8 +289,9 @@ final class Backfiller {
// The default (prod) Extractor reads the opt-in HR-from-PPG sub-lag interpolation flag (Test Centre →
// Experimental algorithms) at decode time and threads it into the pure decoder, so the pure package
// never reaches for UserDefaults. Default OFF = byte-identical to today. Tests inject their own seam.
extract: @escaping Extractor = { extractHistoricalStreams($0, deviceClockRef: $1, wallClockRef: $2,
sessionOldestUnix: $3, sessionNewestUnix: $4,
extract: @escaping Extractor = { extractHistoricalStreams($0, deviceClockRef: $1, wallClockRef: $2,
family: $3,
sessionOldestUnix: $4, sessionNewestUnix: $5,
subLagInterp: PuffinExperiment.ppgHrSubLagInterpEnabled) }) {
self.store = store
self.deviceId = deviceId
Expand Down Expand Up @@ -626,7 +627,7 @@ final class Backfiller {
let extractFn = extract // keep the injected Extractor seam (tests override it); prod == extractHistoricalStreams
let d = await Task.detached(priority: .utility) { () -> DecodedChunk in
let parsed = frames.map { parseFrame($0, family: fam) }
let decoded = extractFn(parsed, dev, wall, oldest, newest)
let decoded = extractFn(parsed, dev, wall, fam, oldest, newest)
let rejected = rejectedHistoricalRecords(frames, family: fam)
return DecodedChunk(parsed: parsed, decoded: decoded, rejected: rejected)
}.value
Expand Down
1 change: 1 addition & 0 deletions Strand/Collect/RawHistoryArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ struct RawHistoryArchive {
// the opt-in HR-from-PPG sub-lag interpolation flag (Test Centre → Experimental algorithms) so the
// archive replay re-derives v26 HR with the same variant the live offload uses. Default OFF.
let streams = extractHistoricalStreams(parsed, deviceClockRef: 0, wallClockRef: 0,
family: family,
subLagInterp: PuffinExperiment.ppgHrSubLagInterpEnabled)
// Count rows ACTUALLY inserted, not decoded: under the per-app-version gate the archive
// replays every release, and dedupe makes those re-runs insert 0 — counting decoded rows
Expand Down
2 changes: 1 addition & 1 deletion Strand/Data/Repository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ final class Repository: ObservableObject {
out.append(beat)
}
}
if out.contains(where: { $0.srcChannel?.isWhoop5Transport == true }) {
if out.contains(where: { $0.srcChannel?.isWhoop5Transport == true || $0.srcChannel == .whoop4Historical }) {
// Retain the selected stream's emission order within a second. Value sorting corrupts
// successive differences; original offsets also keep owner precedence deterministic.
return out.enumerated().sorted {
Expand Down
Loading
Loading