From fe209570a99357747c349ea89b0416b2124994ef Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 20:15:03 +0200 Subject: [PATCH] fix(oura): score one R-R beat channel per window, not 0x80 on top of 0x60 The scoring read excluded only 0x6E (#1071) and kept both remaining Oura beat channels. On two rings, 0x60 (ibiAmplitude) is the complete beat train and 0x80 (greenQuality) a partial second copy of the same beats over the same time: on one of them 0x60 alone covers 0.91-0.99 of the wall clock and the pair reads 1.01-1.31. The #1118 coverage gate then refused whichever nights banked more 0x80, so HRV and Charge calibration stalled for reasons unrelated to the night. The read now keeps 0x80 alone when it holds more beats in the requested window than the amplitude family (0x60 + 0x44, one decoder, split for labelling only), and the amplitude family otherwise, ties included. A ring with only one of them keeps it. NULL rows and non-Oura codes are untouched, so WHOOP and legacy rows read as before. Same shape as the WHOOP 5 transport selection: an uncorrelated subquery over the same time/suspect predicates, evaluated before LIMIT. Android: RR_INTERVALS_SQL carries the same predicate through a shared SCORABLE_OURA_CHANNELS constant; the diagnostic export moves to its own RAW_RR_INTERVALS_SQL so it still carries both beat channels. Fingerprint comments on both platforms now say they count the superset the selection chooses from. Verified with the exact SQL against two real backups: 5 of 6 refused nights pass on the reporting ring and 4 on the second, no night that passed regresses. The nights still refused are 0x60 over-covering on its own, a separate problem. Tests: WhoopStore 616/0 (5 new in RrSourceChannelTests); Kotlin OuraOneChannelReadSqliteTest 6/0 runs the production SQL on real SQLite with the Swift fixtures and expected values verbatim; RrSourceChannelTest 14/0, Whoop5RRSqliteTest 19/0. Full Android suite: the same 8 failures as untouched upstream/main (RecoveryDrivers, StandardHrSensorFormat, StepsDetailIntegration, TodayExplainability), none in touched code. Refs #1071, #1118 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvMtCweTWqqM9iyDBz88XX --- .../WhoopStore/Sources/WhoopStore/Reads.swift | 40 +++++-- .../RrSourceChannelTests.swift | 93 +++++++++++++++ .../src/main/java/com/noop/data/WhoopDao.kt | 50 ++++++-- .../java/com/noop/data/WhoopRepository.kt | 6 +- .../noop/data/OuraOneChannelReadSqliteTest.kt | 107 ++++++++++++++++++ .../java/com/noop/data/Whoop5RRSqliteTest.kt | 8 +- 6 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 android/app/src/test/java/com/noop/data/OuraOneChannelReadSqliteTest.kt diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index c435d133c9..94752f48a1 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -251,8 +251,9 @@ extension WhoopStore { /// The streams are exactly the ones the per-day loop reads and hands to `analyzeDay`: /// - `ppgHrSample`: the day's HR read is measured ∪ PPG-derived ([hrSamples]), so a PPG row for a second /// with no measured HR changes the scored series while `hrSample` stays put. - /// - `rrInterval`: filtered exactly as [rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and - /// future-stamped beats are excluded), so the witness counts the beats that are actually scored. + /// - `rrInterval`: filtered as [rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and + /// future-stamped beats are excluded), but WITHOUT its one-Oura-channel selection: the witness counts + /// every beat that selection chooses from, since a new row on either channel can change which is scored. /// - `sleepStateSample`: appended from the SAME v18 record at the same `ts` as HR, so a re-offloaded /// record whose HR row is dropped on conflict still lands a new band row that `analyzeDay` scores. /// Its letter is `b` (band), not `s`, which is reserved for the version prefix. @@ -399,6 +400,13 @@ extension WhoopStore { } } + /// The Oura beat channels `rrIntervals` chooses between, as a SQL list: `greenQuality` (0x80) on one + /// side, and the amplitude family on the other, `ibiAmplitude` (0x60) + `ibiBare` (0x44). Those two + /// share one decoder and were split for labelling only, so they are scored together, never against + /// each other. `spo2Ibi` (0x6E) is not listed because `rrIntervals` excludes it outright. Twin of + /// Kotlin `SCORABLE_OURA_CHANNELS`, so the channel set cannot drift between the two scoring reads. + static let scorableOuraChannels = "(1, 3, 4)" + /// R-R intervals in EMISSION order (#823). `ord` leads the sort: ordering by `rrMs` returned a /// second's beats sorted by VALUE, which makes successive beats similar by construction and biases /// RMSSD — all successive differences — downward. Pre-v30 rows have `ord` NULL and SQLite sorts NULL @@ -415,14 +423,25 @@ extension WhoopStore { /// The predicate EXCLUDES the one channel proven redundant (`spo2Ibi`, 0x6E) rather than whitelisting /// the one preferred (`greenQuality`, 0x80), which matters for what it does NOT drop: /// - Outside strict WHOOP 5 policy, NULL is kept for WHOOP 4 and unlabelled legacy rows. - /// - `ibiAmplitude` (0x60/0x44) is kept. It does not fire on the Gen-3 hardware this was measured - /// on, so there is no evidence it duplicates green — and dropping a ring's ONLY beat source on an - /// untested assumption is the more expensive mistake. If a capture ever shows 0x60 and 0x80 firing - /// together, that is a second exclusion here, decided on that evidence. + /// - `ibiAmplitude` (0x60/0x44) is kept, because dropping a ring's ONLY beat source on an untested + /// assumption is the more expensive mistake. /// 0x6E is the one excluded because it is the demonstrated duplicate AND the worse measurement of the /// two: it is quantised to an 8 ms grid, applies no quality gate, and runs only while an SpO2 /// measurement is on — so scoring off it would make HRV coverage a function of the SpO2 duty cycle. /// + /// ONE Oura channel per window, not merely one excluded. Captures since have shown 0x60 and 0x80 + /// firing TOGETHER over the same nights, on two rings. On one, 0x60 alone covers 0.91-0.99 of the + /// wall clock and 0x80 adds a partial second copy of 8-33 % of it, so the pair read 1.01-1.31 and the + /// #1118 coverage gate refused whichever nights happened to bank more 0x80. Neither channel is a + /// duplicate to exclude by name. Which one is complete is a property of the capture, not of the tag, + /// so the read keeps green alone when it holds MORE beats in the requested window than the amplitude + /// family (0x60 + 0x44, see `scorableOuraChannels`), and the amplitude family alone otherwise, ties + /// included. A ring with only one of them keeps it, which is the guarantee the exclusion above was + /// protecting. NULL rows and every + /// non-Oura code are untouched, so WHOOP and pre-v32 rows read exactly as before. Same shape as the + /// WHOOP 5 transport selection below: an uncorrelated subquery over the SAME time/suspect predicates + /// as the outer read, evaluated before LIMIT. + /// /// Rows are FILTERED, never deleted: the 0x6E stream stays on disk as the cross-check on green. /// Every R-R consumer reads through this one function, so the `hrv diag` trace moves with the scores /// rather than reporting a coverage nobody can reproduce. @@ -441,11 +460,18 @@ 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. + // 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 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" + """ : """ + (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 diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift index f7be3d54f5..7f8961d82b 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/RrSourceChannelTests.swift @@ -212,4 +212,97 @@ final class RrSourceChannelTests: XCTestCase { XCTAssertEqual(scored.count, trueBeats) XCTAssertEqual(Set(scored.compactMap(\.srcChannel)), [.greenQuality]) } + + // MARK: - One Oura beat channel per window + + /// 0x60 and 0x80 over the SAME night: 0x60 is the complete train, 0x80 a partial second copy of the + /// same beats. Read together they over-cover the wall clock and the #1118 gate refuses the night, so + /// the read keeps the fuller channel alone. NULL rows pass untouched; 0x6E stays excluded. + /// Fixture shared byte-for-byte with Kotlin `OuraOneChannelReadSqliteTest`. + func testTwoOuraBeatChannelsOverOneNightScoreOnlyTheFullerOne() 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.rrIntervals(deviceId: "ring", from: ts, to: ts + 200, limit: 1000) + XCTAssertEqual(read.map(\.rrMs), [1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, + 1010, 1011, 777]) + XCTAssertEqual(Set(read.compactMap(\.srcChannel)), [.ibiAmplitude]) + XCTAssertEqual(read.filter { $0.srcChannel == nil }.count, 1) + } + + /// Which channel is complete is a property of the capture, not the tag. Where 0x80 is the fuller + /// stream it is the one scored — the #1071 guarantee that a ring's only full source is never dropped. + func testWhenGreenIsTheFullerChannelItIsTheOneScored() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "ring", mac: nil, name: nil) + var rows: [RRInterval] = [] + for i in 0..<10 { rows.append(RRInterval(ts: ts + i, rrMs: 950 + i, srcChannel: .greenQuality)) } + for i in 0..<3 { rows.append(RRInterval(ts: ts + 3 * i, rrMs: 1100 + i, srcChannel: .ibiAmplitude)) } + _ = try await store.insert(Streams(rr: rows), deviceId: "ring") + + let read = try await store.rrIntervals(deviceId: "ring", from: ts, to: ts + 100, limit: 1000) + XCTAssertEqual(read.map(\.rrMs), Array(950...959)) + XCTAssertEqual(Set(read.compactMap(\.srcChannel)), [.greenQuality]) + } + + /// The choice is made over the REQUESTED window, not the device's whole history: a window where 0x80 + /// is the only channel present still reads it, even though 0x60 dominates the night around it. + func testTheChannelIsChosenWithinTheRequestedWindow() 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") + // A window after the 0x60 train ends, holding only the late 0x80 tail. + _ = try await store.insert(Streams(rr: [ + RRInterval(ts: ts + 300, rrMs: 880, srcChannel: .greenQuality), + RRInterval(ts: ts + 301, rrMs: 881, srcChannel: .greenQuality), + ]), deviceId: "ring") + + let read = try await store.rrIntervals(deviceId: "ring", from: ts + 250, to: ts + 350, limit: 1000) + XCTAssertEqual(read.map(\.rrMs), [880, 881]) + } + + /// Equal counts keep the amplitude family, so a tie has one defined answer on both platforms. + func testAChannelTieResolvesToTheAmplitudeFamily() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "ring", mac: nil, name: nil) + var rows: [RRInterval] = [] + for i in 0..<3 { + rows.append(RRInterval(ts: ts + i, rrMs: 900 + i, srcChannel: .greenQuality)) + rows.append(RRInterval(ts: ts + i, rrMs: 1200 + i, srcChannel: .ibiAmplitude)) + } + _ = try await store.insert(Streams(rr: rows), deviceId: "ring") + + let read = try await store.rrIntervals(deviceId: "ring", from: ts, to: ts + 100, limit: 1000) + XCTAssertEqual(read.map(\.rrMs), [1200, 1201, 1202]) + } + + /// 0x60 and 0x44 are ONE family (one decoder, split for labelling only), so they are counted together + /// against green and kept together: 4 + 4 amplitude beats outweigh 6 green, though neither tag would + /// alone. + func testTheAmplitudeFamilyIsCountedAndKeptAsOne() async throws { + let store = try await WhoopStore.inMemory() + try await store.upsertDevice(id: "ring", mac: nil, name: nil) + var rows: [RRInterval] = [] + for i in 0..<6 { rows.append(RRInterval(ts: ts + i, rrMs: 900 + i, srcChannel: .greenQuality)) } + for i in 0..<8 { + rows.append(RRInterval(ts: ts + i, rrMs: 1000 + i, srcChannel: i % 2 == 0 ? .ibiAmplitude : .ibiBare)) + } + _ = try await store.insert(Streams(rr: rows), deviceId: "ring") + + let read = try await store.rrIntervals(deviceId: "ring", from: ts, to: ts + 100, limit: 1000) + XCTAssertEqual(read.map(\.rrMs), Array(1000...1007)) + XCTAssertEqual(Set(read.compactMap(\.srcChannel)), [.ibiAmplitude, .ibiBare]) + } + + /// 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. + 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)) } + for i in 0..<4 { rows.append(RRInterval(ts: ts + 2 * i, rrMs: 900 + i, srcChannel: .greenQuality)) } + for i in 0..<12 { rows.append(RRInterval(ts: ts + i, rrMs: 1000 + 8 * i, srcChannel: .spo2Ibi)) } + rows.append(RRInterval(ts: ts + 100, rrMs: 777)) + return rows + } } diff --git a/android/app/src/main/java/com/noop/data/WhoopDao.kt b/android/app/src/main/java/com/noop/data/WhoopDao.kt index 17815abaf8..457e181650 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDao.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDao.kt @@ -42,8 +42,9 @@ internal const val ANALYSIS_FINGERPRINT_SQL = * which MAX(ts) alone would miss. Every arm is an index range walk over the same (deviceId, ts) key the HR * fingerprint uses, materializing no rows. * - * rrInterval is filtered exactly as [WhoopDao.rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and - * future-stamped beats), so the witness counts the beats that are actually scored. The literal 2 is + * rrInterval is filtered as [WhoopDao.rrIntervals] filters at read (the 0x6E SpO2-IBI duplicate and + * future-stamped beats), but WITHOUT its one-Oura-channel selection: the witness counts every beat that + * selection chooses from, since a new row on either channel can change which one is scored. The literal 2 is * RrSourceChannel.SPO2_IBI.code, pinned to the enum by RrChannelTest, for the same reason it is a literal * there: a Room @Query is a compile-time constant string. * @@ -84,8 +85,29 @@ internal const val DAY_STREAM_FINGERPRINT_SQL = "'|ownerTagged' || EXISTS(SELECT 1 FROM rrInterval WHERE deviceId = :deviceId AND srcChannel IN (5, 6, 7)) || " + "'|registry' || COALESCE((SELECT QUOTE(brand) || ':' || QUOTE(model) FROM pairedDevice WHERE id = :deviceId), 'absent')" -/** Shared production SQL used by Room and the SQLite repository contract tests. */ +/** The Oura beat channels [RR_INTERVALS_SQL] chooses between, as a SQL list: GREEN_QUALITY (0x80) on + * one side, and the amplitude family on the other, IBI_AMPLITUDE (0x60) + IBI_BARE (0x44). Those two + * share one decoder and were split for labelling only, so they are scored together, never against each + * other. SPO2_IBI (0x6E) is not listed because the read excludes it outright. Twin of Swift + * `WhoopStore.scorableOuraChannels`, so the channel set cannot drift between the two scoring reads. */ +internal const val SCORABLE_OURA_CHANNELS = "(1, 3, 4)" + +/** Shared production SQL used by Room and the SQLite repository contract tests. The SCORING read: see + * [WhoopDao.rrIntervals] for the one-Oura-channel selection. Twin of Swift `WhoopStore.rrIntervals`. */ internal const val RR_INTERVALS_SQL = + "SELECT * FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + + "AND (srcChannel IS NULL OR srcChannel <> 2) " + + "AND (srcChannel IS NULL OR srcChannel NOT IN " + SCORABLE_OURA_CHANNELS + " OR (srcChannel = 1) = (" + + "SELECT SUM(srcChannel = 1) > SUM(srcChannel <> 1) FROM rrInterval " + + "WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to AND srcChannel IN " + SCORABLE_OURA_CHANNELS + " " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1))) " + + "AND (tsSuspect IS NULL OR tsSuspect <> 1) " + + "ORDER BY ts ASC, ord ASC, rrMs ASC, seq ASC LIMIT :limit" + +/** The diagnostic export's read: every beat channel except the 0x6E duplicate, with NO scoring + * selection, so an exported night still carries both Oura beat channels as each other's cross-check. + * This is what [RR_INTERVALS_SQL] was before the one-channel selection. */ +internal const val RAW_RR_INTERVALS_SQL = "SELECT * FROM rrInterval WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " + "AND (srcChannel IS NULL OR srcChannel <> 2) " + "AND (tsSuspect IS NULL OR tsSuspect <> 1) " + @@ -583,14 +605,24 @@ interface WhoopDao : DeviceRegistryDao { // whitelisting the one preferred (GREEN_QUALITY, 0x80), which matters for what it does NOT drop: // - NULL is kept for WHOOP 4 and unlabelled legacy owners. The repository routes strict // WHOOP 5 owners to whoop5RrIntervals instead. - // - IBI_AMPLITUDE (0x60/0x44) is kept. It does not fire on the Gen-3 hardware this was measured - // on, so there is no evidence it duplicates green — and dropping a ring's ONLY beat source on - // an untested assumption is the more expensive mistake. If a capture ever shows 0x60 and 0x80 - // firing together, that is a second exclusion here, decided on that evidence. + // - IBI_AMPLITUDE (0x60/0x44) is kept, because dropping a ring's ONLY beat source on an + // untested assumption is the more expensive mistake. // 0x6E is the one excluded because it is the demonstrated duplicate AND the worse measurement of // the two: quantised to an 8 ms grid, no quality gate, and running only while an SpO2 measurement // is on — so scoring off it would make HRV coverage a function of the SpO2 duty cycle. // + // ONE Oura channel per window, not merely one excluded. Captures since have shown 0x60 and 0x80 + // firing TOGETHER over the same nights, on two rings. On one, 0x60 alone covers 0.91-0.99 of the + // wall clock and 0x80 adds a partial second copy of 8-33 % of it, so the pair read 1.01-1.31 and + // the #1118 coverage gate refused whichever nights happened to bank more 0x80. Neither channel is + // a duplicate to exclude by name. Which one is complete is a property of the capture, not of the + // tag, so the read keeps green alone when it holds MORE beats in the requested window than the + // amplitude family (0x60 + 0x44, see SCORABLE_OURA_CHANNELS), and the amplitude family alone + // otherwise, ties included. A ring with only one of them keeps it, which is the guarantee the + // exclusion above was protecting. NULL rows and every non-Oura code are untouched, so WHOOP and + // pre-v26 rows read exactly as before. The diagnostic export reads [rawRrIntervals] instead, which + // makes no selection. + // // Rows are FILTERED, never deleted: the 0x6E stream stays on disk as the cross-check on green. // Legacy and non-WHOOP5 readers use this query. Strict WHOOP 5 uses the source-selected query below. The literal 2 is RrSourceChannel.SPO2_IBI // .code — a Room @Query is a compile-time constant string and cannot reference it; RrChannelTest @@ -598,6 +630,10 @@ interface WhoopDao : DeviceRegistryDao { @Query(RR_INTERVALS_SQL) suspend fun rrIntervals(deviceId: String, from: Long, to: Long, limit: Int): List + /** Diagnostic export only: [RAW_RR_INTERVALS_SQL], no one-channel selection. */ + @Query(RAW_RR_INTERVALS_SQL) + suspend fun rawRrIntervals(deviceId: String, from: Long, to: Long, limit: Int): List + /** WHOOP 5: one eligible transport over the entire requested interval, selected before LIMIT. */ @Query(WHOOP5_RR_INTERVALS_SQL) suspend fun whoop5RrIntervals(deviceId: String, from: Long, to: Long, limit: Int): List diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index 09eb5ff784..c470faaa28 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -1372,11 +1372,11 @@ class WhoopRepository( dao.legacyWhoop5RrWithheld(deviceId, from, to) } - /** Diagnostic export keeps all WHOOP transports and legacy values without scoring selection. - * Existing quarantine and Oura SpO2-IBI exclusions still apply. */ + /** Diagnostic export keeps all WHOOP transports, both Oura beat channels and legacy values without + * scoring selection. Existing quarantine and Oura SpO2-IBI exclusions still apply. */ suspend fun rawRrIntervalsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = - dao.rrIntervals(deviceId, from, to, limit) + dao.rawRrIntervals(deviceId, from, to, limit) suspend fun rrIntervalsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT, diff --git a/android/app/src/test/java/com/noop/data/OuraOneChannelReadSqliteTest.kt b/android/app/src/test/java/com/noop/data/OuraOneChannelReadSqliteTest.kt new file mode 100644 index 0000000000..fa798e43a6 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/OuraOneChannelReadSqliteTest.kt @@ -0,0 +1,107 @@ +package com.noop.data + +import java.sql.Connection +import java.sql.DriverManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** The one-Oura-channel scoring read ([RR_INTERVALS_SQL]) run against a real SQLite, so the selection is + * checked by behaviour and not only as source text. Every fixture and expected list is the Swift twin's + * (`RrSourceChannelTests`, "One Oura beat channel per window") verbatim; the channel codes are the durable + * storage values (1 green 0x80, 2 SpO2 0x6E, 3 amplitude 0x60, 4 bare 0x44). */ +class OuraOneChannelReadSqliteTest { + private lateinit var db: Connection + private val ts = 1_750_000_000L + + @Before fun open() { + db = DriverManager.getConnection("jdbc:sqlite::memory:") + db.createStatement().use { + it.execute("CREATE TABLE rrInterval(deviceId TEXT NOT NULL, ts INTEGER NOT NULL, rrMs INTEGER NOT NULL, " + + "seq INTEGER NOT NULL DEFAULT 0, synced INTEGER NOT NULL DEFAULT 0, ord INTEGER, srcChannel INTEGER, " + + "tsSuspect INTEGER, PRIMARY KEY(deviceId, ts, rrMs, seq))") + } + } + + @After fun close() = db.close() + + /** OR IGNORE, as the store inserts: a 0x6E beat landing on a 0x60 row's (ts, rrMs) key is dropped, + * exactly as it is in the Swift twin's fixture. */ + private fun insert(t: Long, rrMs: Int, channel: Int?) { + db.prepareStatement("INSERT OR IGNORE INTO rrInterval(deviceId, ts, rrMs, srcChannel) VALUES('ring', ?, ?, ?)").use { + it.setLong(1, t); it.setInt(2, rrMs); it.setObject(3, channel); it.executeUpdate() + } + } + + /** (rrMs, srcChannel) in read order, binding the production statement's named parameters. */ + private fun read(sql: String, from: Long, to: Long): List> { + val names = mutableListOf() + val bound = Regex(":([A-Za-z][A-Za-z0-9]*)").replace(sql) { names += it.groupValues[1]; "?" } + val values = mapOf("deviceId" to "ring", "from" to from, "to" to to, "limit" to 1000) + return db.prepareStatement(bound).use { stmt -> + names.forEachIndexed { i, n -> stmt.setObject(i + 1, values.getValue(n)) } + stmt.executeQuery().use { r -> + buildList { + while (r.next()) add(r.getInt("rrMs") to r.getInt("srcChannel").let { if (r.wasNull()) null else it }) + } + } + } + } + + /** 12 s of 0x60, 4 overlapping 0x80 beats, 0x6E over the same seconds, one unlabelled row. */ + private fun oneChannelFixture() { + for (i in 0 until 12) insert(ts + i, 1000 + i, 3) + for (i in 0 until 4) insert(ts + 2 * i, 900 + i, 1) + for (i in 0 until 12) insert(ts + i, 1000 + 8 * i, 2) + insert(ts + 100, 777, null) + } + + @Test fun twoOuraBeatChannelsOverOneNightScoreOnlyTheFullerOne() { + oneChannelFixture() + val read = read(RR_INTERVALS_SQL, ts, ts + 200) + assertEquals(listOf(1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 777), + read.map { it.first }) + assertEquals(setOf(3), read.mapNotNull { it.second }.toSet()) + assertEquals(1, read.count { it.second == null }) + } + + @Test fun whenGreenIsTheFullerChannelItIsTheOneScored() { + for (i in 0 until 10) insert(ts + i, 950 + i, 1) + for (i in 0 until 3) insert(ts + 3 * i, 1100 + i, 3) + val read = read(RR_INTERVALS_SQL, ts, ts + 100) + assertEquals((950..959).toList(), read.map { it.first }) + assertEquals(setOf(1), read.mapNotNull { it.second }.toSet()) + } + + @Test fun theChannelIsChosenWithinTheRequestedWindow() { + oneChannelFixture() + insert(ts + 300, 880, 1) + insert(ts + 301, 881, 1) + assertEquals(listOf(880, 881), read(RR_INTERVALS_SQL, ts + 250, ts + 350).map { it.first }) + } + + @Test fun aChannelTieResolvesToTheAmplitudeFamily() { + for (i in 0 until 3) { + insert(ts + i, 900 + i, 1) + insert(ts + i, 1200 + i, 3) + } + assertEquals(listOf(1200, 1201, 1202), read(RR_INTERVALS_SQL, ts, ts + 100).map { it.first }) + } + + @Test fun theAmplitudeFamilyIsCountedAndKeptAsOne() { + for (i in 0 until 6) insert(ts + i, 900 + i, 1) + for (i in 0 until 8) insert(ts + i, 1000 + i, if (i % 2 == 0) 3 else 4) + val read = read(RR_INTERVALS_SQL, ts, ts + 100) + assertEquals((1000..1007).toList(), read.map { it.first }) + assertEquals(setOf(3, 4), read.mapNotNull { it.second }.toSet()) + } + + /** The export makes no selection: both Oura beat channels come back, 0x6E still excluded. */ + @Test fun theRawExportReadKeepsBothBeatChannels() { + oneChannelFixture() + val read = read(RAW_RR_INTERVALS_SQL, ts, ts + 200) + assertEquals(setOf(1, 3), read.mapNotNull { it.second }.toSet()) + assertEquals(12 + 4 + 1, read.size) + } +} diff --git a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt index 3b0b12a562..d2e6459652 100644 --- a/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt +++ b/android/app/src/test/java/com/noop/data/Whoop5RRSqliteTest.kt @@ -119,8 +119,12 @@ class Whoop5RRSqliteTest { .use { it.executeUpdate() } Unit } - "rrIntervals", "whoop5RrIntervals" -> query( - if (method.name == "whoop5RrIntervals") WHOOP5_RR_INTERVALS_SQL else RR_INTERVALS_SQL, + "rrIntervals", "whoop5RrIntervals", "rawRrIntervals" -> query( + when (method.name) { + "whoop5RrIntervals" -> WHOOP5_RR_INTERVALS_SQL + "rawRrIntervals" -> RAW_RR_INTERVALS_SQL + else -> RR_INTERVALS_SQL + }, listOf("deviceId", "from", "to", "limit").zip(args.take(4)).toMap(), ) { r -> fun optional(column: String) = r.getInt(column).let { if (r.wasNull()) null else it }