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
72 changes: 72 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 [] }
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
}
}
81 changes: 81 additions & 0 deletions android/app/src/main/java/com/noop/analytics/SleepStager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<HrSample>,
baseline: Double?,
epochS: Long = hrOnlyEpochS,
maxGapMinutes: Int = maxGapMin,
): List<Period> {
if (baseline == null || baseline <= 0.0) return emptyList()
if (hr.isEmpty() || epochS <= 0L) return emptyList()
val byEpoch = HashMap<Long, MutableList<Double>>()
val lastTs = HashMap<Long, Long>()
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<Period>()
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<GravitySample>, deltas: List<Double>): List<Boolean> {
val n = grav.size
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int>): List<HrSample> {
val out = ArrayList<HrSample>()
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<SleepStager.Period>): List<String> =
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)),
)
}
}
Loading