diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 4a30728dcb..8a5b5b237a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -472,6 +472,78 @@ public enum SleepStager { return periods } + /// Epoch for the HR-only spine, in seconds. + public static let hrOnlyEpochS: Int = 60 + + /// Sleep/active runs built from HEART RATE ALONE, for a strap that streams HR but banks no motion. + /// + /// Stage 0 is normally a gravity-stillness spine (Cole-Kripke) that HR only CONFIRMS, via + /// `hrSleepBandAcross` and `confirmSleepWithHR`. A WHOOP 5/MG that cannot bond never banks motion at + /// all — `SET_CLOCK` rides a handshake it never completes — so `grav` is empty, there is no spine, and + /// no quantity of HR can stage the night (#1801). This builds the spine from the one signal such a + /// strap does provide. + /// + /// Deliberately the SAME rule the confirm path already trusts: per-epoch MEDIAN bpm against + /// `baseline * hrSleepBandMult`, median for the reason `hrSleepBandAcross` spells out. The run + /// construction follows `buildRuns` — close on a class change or a gap over `maxGapMin` — so the two + /// spines segment alike once flags exist, and only the flag SOURCE differs. + /// + /// With ONE branch deliberately absent, and it is not an oversight. `buildRuns` can forgive a gap when + /// `hrSleepBandAcross` vouches that HR stayed in band across it, which rescues a night whose GRAVITY + /// dropped out. Here the gap IS in the heart rate, so there is nothing left to vouch with and no + /// analogue to port. A long HR dropout therefore breaks an HR-only run where it would not break a + /// motion-backed one, and a night fragmented that way is dropped by the caller's minimum-duration + /// gate rather than bridged. + /// + /// WEAKER THAN THE MOTION SPINE, by construction rather than by tuning. The file already notes that a + /// long still daytime stretch is gravity-indistinguishable from a nap and that HR is what saves it; + /// with motion gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A + /// caller must treat these runs as lower-confidence than a motion-backed night and must not let one + /// reach a baseline it cannot be unwound from. + /// + /// Bucket order does not depend on sort stability: samples are grouped by epoch and reduced with a + /// median, so their order within an epoch cannot change the result. + static func hrOnlySleepRuns(_ hr: [HRSample], baseline: Double?, + epochS: Int = hrOnlyEpochS, + maxGapMinutes: Int = maxGapMin) -> [Period] { + guard let baseline = baseline, baseline > 0 else { return [] } + if hr.isEmpty || epochS <= 0 { return [] } + var byEpoch: [Int: [Double]] = [:] + var lastTs: [Int: Int] = [:] + for s in hr { + let k = s.ts / epochS + byEpoch[k, default: []].append(Double(s.bpm)) + lastTs[k] = max(lastTs[k] ?? Int.min, s.ts) + } + let keys = byEpoch.keys.sorted() + // Two axes, deliberately. A gap and a run's START use the epoch's own start, so a gap is measured + // between epochs rather than between whichever samples sat at their edges. A run's END is the last + // SAMPLE observed in its final epoch, which is what `buildRuns` means by `end` — reading the epoch + // start there would report every run one whole epoch shorter than the data it covers, and that + // understatement would then be weighed against the caller's minimum-duration gate. + let times = keys.map { $0 * epochS } + let ends = keys.map { lastTs[$0]! } + let flags = keys.map { HRVAnalyzer.median(byEpoch[$0]!) <= baseline * hrSleepBandMult } + let maxGapS = maxGapMinutes * 60 + var periods: [Period] = [] + var runStart = 0 + for i in 1...keys.count { + let atEnd = (i == keys.count) + let close: Bool + if atEnd { + close = true + } else { + close = flags[i] != flags[runStart] || (times[i] - times[i - 1]) > maxGapS + } + if close { + periods.append(Period(stage: flags[runStart] ? "sleep" : "active", + start: times[runStart], end: ends[i - 1])) + runStart = i + } + } + return periods + } + /// Absorb runs shorter than mergeMin minutes into their neighbours. static func mergePeriods(_ periods: [Period], mergeMinutes: Int = mergeMin) -> [Period] { if periods.isEmpty { return [] } diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift new file mode 100644 index 0000000000..5278d38cdf --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerHrOnlySpineTests.swift @@ -0,0 +1,81 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +/// The HR-only Stage-0 spine (#1801), for a strap that streams heart rate but banks no motion. +/// +/// These cases are the ORACLE for the Kotlin twin `SleepStagerHrOnlySpineTest`: the same inputs and the +/// same expected runs are asserted on both sides, so a divergence in the flag rule, the epoch bucketing +/// or the run construction fails on one platform and not the other. +/// +/// Baseline 60 throughout, so the sleep band is `60 * hrSleepBandMult` = 63.0 bpm inclusive. Samples are +/// laid down every 10 s, six to a 60 s epoch. +final class SleepStagerHrOnlySpineTests: XCTestCase { + + /// Six samples in each epoch, `bpm` per epoch index. + private func hr(from epoch: Int, _ bpms: [Int]) -> [HRSample] { + var out: [HRSample] = [] + for (i, bpm) in bpms.enumerated() { + let base = (epoch + i) * 60 + for k in 0..<6 { out.append(HRSample(ts: base + k * 10, bpm: bpm)) } + } + return out + } + + private func tuples(_ p: [SleepStager.Period]) -> [String] { + p.map { "\($0.stage) \($0.start)-\($0.end)" } + } + + func testNoBaselineOrNoHrYieldsNothing() { + XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: nil).isEmpty) + XCTAssertTrue(SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]), baseline: 0).isEmpty) + XCTAssertTrue(SleepStager.hrOnlySleepRuns([], baseline: 60).isEmpty) + } + + func testAllInBandIsOneSleepRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60170"]) + } + + func testAllOutOfBandIsOneActiveRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [90, 90, 90]), baseline: 60) + XCTAssertEqual(tuples(runs), ["active 60000-60170"]) + } + + func testSleepActiveSleepSegmentsIntoThree() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 90, 55, 55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60110", "active 60120-60170", "sleep 60180-60290"]) + } + + /// A gap wider than `maxGapMin` breaks a run even when the class never changes — the same rule + /// `buildRuns` applies to a gravity gap. + func testGapWiderThanMaxGapBreaksASameClassRun() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55]) + hr(from: 1030, [55]), baseline: 60) + XCTAssertEqual(tuples(runs), ["sleep 60000-60050", "sleep 61800-61850"]) + } + + /// The band test is a MEDIAN, so one arousal spike inside an epoch cannot flip it to active — the + /// property `hrSleepBandAcross` documents, asserted here on the epoch reduction. + func testOneSpikeInAnEpochDoesNotFlipIt() { + var samples = hr(from: 1000, [55]) + samples[5] = HRSample(ts: samples[5].ts, bpm: 190) + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(samples, baseline: 60)), ["sleep 60000-60050"]) + } + + /// A run's `end` is the last SAMPLE seen, not the final epoch's start. Pinned because reading the + /// epoch start reports every run one epoch short of the data it covers — silently, and straight into + /// the caller's minimum-duration gate. Three 60 s epochs of samples laid every 10 s end at 60170. + func testRunEndIsTheLastSampleNotTheEpochStart() { + let runs = SleepStager.hrOnlySleepRuns(hr(from: 1000, [55, 55, 55]), baseline: 60) + XCTAssertEqual(runs.first?.end, 60170) + XCTAssertEqual((runs.first!.end - runs.first!.start), 170) + } + + /// The band is inclusive: 63 is `60 * 1.05` exactly. + func testBandBoundaryIsInclusive() { + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [63]), baseline: 60)), + ["sleep 60000-60050"]) + XCTAssertEqual(tuples(SleepStager.hrOnlySleepRuns(hr(from: 1000, [64]), baseline: 60)), + ["active 60000-60050"]) + } +} diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index 9b3fd068fc..c2c9ff5efe 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -524,6 +524,87 @@ object SleepStager { return medianHR <= baseline * hrSleepBandMult } + /** Epoch for the HR-only spine, in seconds. */ + const val hrOnlyEpochS: Long = 60 + + /** + * Sleep/active runs built from HEART RATE ALONE, for a strap that streams HR but banks no motion. + * + * Stage 0 is normally a gravity-stillness spine (Cole-Kripke) that HR only CONFIRMS, via + * [hrSleepBandAcross] and `confirmSleepWithHR`. A WHOOP 5/MG that cannot bond never banks motion at + * all — `SET_CLOCK` rides a handshake it never completes — so `grav` is empty, there is no spine, and + * no quantity of HR can stage the night (#1801). This builds the spine from the one signal such a + * strap does provide. + * + * Deliberately the SAME rule the confirm path already trusts: per-epoch MEDIAN bpm against + * `baseline * `[hrSleepBandMult], median for the reason [hrSleepBandAcross] spells out. The run + * construction follows [buildRuns] — close on a class change or a gap over [maxGapMin] — so the two + * spines segment alike once flags exist, and only the flag SOURCE differs. + * + * With ONE branch deliberately absent, and it is not an oversight. [buildRuns] can forgive a gap when + * [hrSleepBandAcross] vouches that HR stayed in band across it, which rescues a night whose GRAVITY + * dropped out. Here the gap IS in the heart rate, so there is nothing left to vouch with and no + * analogue to port. A long HR dropout therefore breaks an HR-only run where it would not break a + * motion-backed one, and a night fragmented that way is dropped by the caller's minimum-duration + * gate rather than bridged. + * + * WEAKER THAN THE MOTION SPINE, by construction rather than by tuning. The file already notes that a + * long still daytime stretch is gravity-indistinguishable from a nap and that HR is what saves it; + * with motion gone the inverse is exposed, and a quiet evening at rest can sit in the sleep band. A + * caller must treat these runs as lower-confidence than a motion-backed night and must not let one + * reach a baseline it cannot be unwound from. + * + * Bucket order does not depend on sort stability: samples are grouped by epoch and reduced with a + * median, so their order within an epoch cannot change the result. + */ + internal fun hrOnlySleepRuns( + hr: List, + baseline: Double?, + epochS: Long = hrOnlyEpochS, + maxGapMinutes: Int = maxGapMin, + ): List { + if (baseline == null || baseline <= 0.0) return emptyList() + if (hr.isEmpty() || epochS <= 0L) return emptyList() + val byEpoch = HashMap>() + val lastTs = HashMap() + for (s in hr) { + val k = s.ts / epochS + byEpoch.getOrPut(k) { ArrayList() }.add(s.bpm.toDouble()) + lastTs[k] = maxOf(lastTs[k] ?: Long.MIN_VALUE, s.ts) + } + val keys = byEpoch.keys.sorted() + // Two axes, deliberately. A gap and a run's START use the epoch's own start, so a gap is measured + // between epochs rather than between whichever samples sat at their edges. A run's END is the last + // SAMPLE observed in its final epoch, which is what [buildRuns] means by `end` — reading the epoch + // start there would report every run one whole epoch shorter than the data it covers, and that + // understatement would then be weighed against the caller's minimum-duration gate. + val times = keys.map { it * epochS } + val ends = keys.map { lastTs.getValue(it) } + val flags = keys.map { HrvAnalyzer.median(byEpoch.getValue(it)) <= baseline * hrSleepBandMult } + val maxGapS = (maxGapMinutes * 60).toLong() + val periods = ArrayList() + var runStart = 0 + for (i in 1..keys.size) { + val atEnd = (i == keys.size) + val close = if (atEnd) { + true + } else { + flags[i] != flags[runStart] || (times[i] - times[i - 1]) > maxGapS + } + if (close) { + periods.add( + Period( + stage = if (flags[runStart]) "sleep" else "active", + start = times[runStart], + end = ends[i - 1], + ) + ) + runStart = i + } + } + return periods + } + /** Per-record sleep flags from a rolling fraction of "still" samples. */ internal fun classifyStill(grav: List, deltas: List): List { val n = grav.size diff --git a/android/app/src/test/java/com/noop/analytics/SleepStagerHrOnlySpineTest.kt b/android/app/src/test/java/com/noop/analytics/SleepStagerHrOnlySpineTest.kt new file mode 100644 index 0000000000..6c759806ec --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerHrOnlySpineTest.kt @@ -0,0 +1,115 @@ +package com.noop.analytics + +import com.noop.data.HrSample +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The HR-only Stage-0 spine (#1801), for a strap that streams heart rate but banks no motion. + * + * Every expectation below is the Swift twin's OWN output, produced by compiling + * `SleepStager.hrOnlySleepRuns` standalone (`swiftc -O twin.swift main.swift`) over this exact case + * list and pasted verbatim — not read off the Kotlin implementation. The package's XCTest twin + * `SleepStagerHrOnlySpineTests` asserts the same values on Apple; the harness exists because the test + * binary cannot link GRDB's sqlite3 on Linux, so the assertions could not otherwise be run here. + * + * Baseline 60 throughout, so the band is `60 * hrSleepBandMult` = 63.0 bpm inclusive. Samples land every + * 10 s, six to a 60 s epoch. + */ +class SleepStagerHrOnlySpineTest { + + private fun hr(epoch: Int, bpms: List): List { + val out = ArrayList() + bpms.forEachIndexed { i, bpm -> + val base = (epoch + i).toLong() * 60L + repeat(6) { k -> out.add(HrSample(deviceId = "d", ts = base + k * 10L, bpm = bpm)) } + } + return out + } + + private fun tuples(p: List): List = + p.map { "${it.stage} ${it.start}-${it.end}" } + + @Test + fun `no baseline or no hr yields nothing`() { + assertTrue(SleepStager.hrOnlySleepRuns(hr(1000, listOf(55)), null).isEmpty()) + assertTrue(SleepStager.hrOnlySleepRuns(hr(1000, listOf(55)), 0.0).isEmpty()) + assertTrue(SleepStager.hrOnlySleepRuns(emptyList(), 60.0).isEmpty()) + } + + @Test + fun `all in band is one sleep run`() { + assertEquals( + listOf("sleep 60000-60170"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(55, 55, 55)), 60.0)), + ) + } + + @Test + fun `all out of band is one active run`() { + assertEquals( + listOf("active 60000-60170"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(90, 90, 90)), 60.0)), + ) + } + + @Test + fun `sleep active sleep segments into three`() { + assertEquals( + listOf("sleep 60000-60110", "active 60120-60170", "sleep 60180-60290"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(55, 55, 90, 55, 55)), 60.0)), + ) + } + + /** + * A gap wider than [SleepStager.maxGapMin] breaks a run even when the class never changes — the same + * rule [SleepStager.buildRuns] applies to a gravity gap. + */ + @Test + fun `gap wider than maxGap breaks a same-class run`() { + assertEquals( + listOf("sleep 60000-60050", "sleep 61800-61850"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(55)) + hr(1030, listOf(55)), 60.0)), + ) + } + + /** + * The band test is a MEDIAN, so one arousal spike inside an epoch cannot flip it to active — the + * property [SleepStager.hrSleepBandAcross] documents, asserted here on the epoch reduction. + */ + @Test + fun `one spike in an epoch does not flip it`() { + val samples = hr(1000, listOf(55)).toMutableList() + samples[5] = samples[5].copy(bpm = 190) + assertEquals( + listOf("sleep 60000-60050"), + tuples(SleepStager.hrOnlySleepRuns(samples, 60.0)), + ) + } + + /** + * A run's `end` is the last SAMPLE seen, not the final epoch's start. Pinned because reading the + * epoch start reports every run one epoch short of the data it covers — silently, and straight into + * the caller's minimum-duration gate. Three 60 s epochs of samples laid every 10 s end at 60170. + */ + @Test + fun `run end is the last sample not the epoch start`() { + val runs = SleepStager.hrOnlySleepRuns(hr(1000, listOf(55, 55, 55)), 60.0) + assertEquals(60170L, runs.first().end) + assertEquals(170L, runs.first().end - runs.first().start) + } + + /** The band is inclusive: 63 is `60 * 1.05` exactly. */ + @Test + fun `band boundary is inclusive`() { + assertEquals( + listOf("sleep 60000-60050"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(63)), 60.0)), + ) + assertEquals( + listOf("active 60000-60050"), + tuples(SleepStager.hrOnlySleepRuns(hr(1000, listOf(64)), 60.0)), + ) + } +}