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 @@ -723,7 +723,21 @@ public enum AnalyticsEngine {
}
return deep.isEmpty ? nil : deep.reduce(0, +) / Double(deep.count)
}
let pairs = physiologySessions.compactMap { s -> (Double, Double)? in
// A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The
// day-wide pool below rests on "the main overnight dominates" (see `physiologySessions`), which
// holds only while that night has a value: once the gate makes it nil it carries zero weight,
// and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a
// night. So when a main-group session's HRV is nil BECAUSE the gate refused it, only the main
// group is pooled, and the day holds (nil) unless another fragment of that night measured
// cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the
// day's other sessions is unchanged. Byte-parity twin of Kotlin `avgHRVDaily`.
let mainNightRefused = mainGroup.contains { s in
s.avgHRV == nil && SleepStager.sessionHrvOverCounted(start: s.start, end: s.end, rr: rr)
}
let hrvPool = mainNightRefused
? physiologySessions.filter { p in mainGroup.contains { $0.start == p.start && $0.end == p.end } }
: physiologySessions
let pairs = hrvPool.compactMap { s -> (Double, Double)? in
s.avgHRV.map { ($0, Double(s.end - s.start)) }
}
guard !pairs.isEmpty else { return nil }
Expand Down
13 changes: 11 additions & 2 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2968,6 +2968,16 @@ public enum SleepStager {
// Classified over the SAME beats the value was built from, windowed [start, end] exactly as
// `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number
// it is gating.
guard !sessionHrvOverCounted(start: start, end: end, rr: rr) else { return nil }
return vals.reduce(0, +) / Double(vals.count)
}

/// Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly
/// as `sessionAvgHRV` windows it, banks more beat-time than the wall clock allows. Pure. The ONE
/// definition of "refused": `sessionAvgHRV` gates on it, and `AnalyticsEngine` asks it whether a main
/// night's missing HRV was refused rather than never measured, so the two cannot disagree about which
/// nights were refused. Byte-parity twin of Kotlin `SleepStager.sessionHrvOverCounted`.
static func sessionHrvOverCounted(start: Int, end: Int, rr: [RRInterval]) -> Bool {
let seg = rr.filter { $0.ts >= start && $0.ts <= end }
let segTs = seg.map { $0.ts }
let segMs = seg.map { Double($0.rrMs) }
Expand All @@ -2981,8 +2991,7 @@ public enum SleepStager {
// buying a distinction the caller discards would hand that back. `rrCoverage` is a single O(n)
// pass. If a future gate ever needs the two over-count cases apart, compute it then.
let verdict = HRVAnalyzer.classifyCoverage(coverage: coverage, collapsed: coverage)
guard HRVAnalyzer.successiveDiffIsTrustworthy(verdict) else { return nil }
return vals.reduce(0, +) / Double(vals.count)
return !HRVAnalyzer.successiveDiffIsTrustworthy(verdict)
}

/// Per-5-min-window RMSSD across a session, each window tagged with the sleep stage at its CENTER
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import XCTest
@testable import StrandAnalytics
import WhoopProtocol

/// A main night whose HRV the #1118 over-count gate REFUSED must not be replaced by the day's naps.
///
/// Field case: a ring night read 1.31 coverage, the gate made its HRV nil, and the day's HRV became the
/// mean of two 26-minute naps, which the baseline then folded as a full night. The three cases pin the
/// rule and its boundary: refused ⇒ naps excluded; merely unmeasured (no R-R at all) ⇒ #1884's fill-in
/// from the other sessions is unchanged; clean ⇒ pooled as before.
/// Byte-parity twin of Kotlin `AnalyticsEngineRefusedMainNightHrvTest`: same fixtures, same expected values.
final class AnalyticsEngineRefusedMainNightHrvTests: XCTestCase {
private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male")
private let day = "2026-07-27"

private var dayStart: Int { AnalyticsEngine.dayStartUtcSeconds(day) }
private var nightStart: Int { dayStart - 4 * 3600 } // 20:00 the evening before
private var nightEnd: Int { nightStart + 600 * 60 } // 06:00 on `day`
private var napStart: Int { dayStart + 13 * 3600 } // 13:00 on `day`
private var napEnd: Int { napStart + 40 * 60 } // 13:40

private func hr(_ start: Int, _ end: Int) -> [HRSample] {
stride(from: start, to: end, by: 30).map { HRSample(ts: $0, bpm: 52 + ($0 / 300) % 4) }
}

/// One beat per second, alternating around 1000 ms by `swing`: coverage ~1.0, RMSSD = 2 * swing.
/// The night uses 20 (RMSSD 40) and the nap 30 (RMSSD 60), so a pooled day and a nap-only day read
/// differently.
private func cleanRR(_ start: Int, _ end: Int, swing: Int) -> [RRInterval] {
(start..<end).map { RRInterval(ts: $0, rrMs: ($0 - start) % 2 == 0 ? 1_000 - swing : 1_000 + swing) }
}

/// Two beats in every second (880 + 960 ms): ~1.84x the wall clock, refused by the gate, while its
/// windows DO yield an RMSSD — the shape of a ring night scored from two beat channels at once.
private func overCountedRR(_ start: Int, _ end: Int) -> [RRInterval] {
(start..<end).flatMap { [RRInterval(ts: $0, rrMs: 880), RRInterval(ts: $0, rrMs: 960)] }
}

private func sessions() -> [SleepSession] {
[SleepSession(start: nightStart, end: nightEnd, efficiency: 0.9,
stages: [StageSegment(start: nightStart, end: nightEnd, stage: "light")],
restingHR: nil, avgHRV: nil),
SleepSession(start: napStart, end: napEnd, efficiency: 0.9,
stages: [StageSegment(start: napStart, end: napEnd, stage: "light")],
restingHR: nil, avgHRV: nil)]
}

private func analyze(nightRR: [RRInterval]) -> AnalyticsEngine.DayResult {
AnalyticsEngine.analyzeDay(day: day, hr: hr(nightStart, nightEnd) + hr(napStart, napEnd),
rr: nightRR + cleanRR(napStart, napEnd, swing: 30), profile: profile,
providedSleep: sessions())
}

func testARefusedMainNightIsNotReplacedByTheNap() {
let night = overCountedRR(nightStart, nightEnd)
XCTAssertTrue(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: night),
"precondition: the gate refuses this night")
XCTAssertNotNil(SleepStager.sessionAvgHRV(start: napStart, end: napEnd, rr: cleanRR(napStart, napEnd, swing: 30)),
"precondition: the nap on its own measures an HRV")
XCTAssertNil(analyze(nightRR: night).daily.avgHrv,
"the day holds rather than reporting the nap as the night")
}

func testAMainNightWithNoRrStillFallsBackToTheNap() {
let res = analyze(nightRR: [])
XCTAssertEqual(res.daily.avgHrv ?? -1, 60, accuracy: 1e-9,
"not refused, only unmeasured: #1884's fill-in from the nap is unchanged")
}

func testACleanMainNightIsPooledWithTheNapAsBefore() {
let res = analyze(nightRR: cleanRR(nightStart, nightEnd, swing: 20))
// In-bed-weighted: (40 * 600 min + 60 * 40 min) / 640 min.
XCTAssertEqual(res.daily.avgHrv ?? -1, 41.25, accuracy: 1e-9)
}

/// The helper is the gate: whatever it says, `sessionAvgHRV` does, on the same beats.
func testTheHelperAgreesWithTheValueGate() {
for rr in [overCountedRR(nightStart, nightEnd), cleanRR(nightStart, nightEnd, swing: 20)] {
XCTAssertEqual(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: rr),
SleepStager.sessionAvgHRV(start: nightStart, end: nightEnd, rr: rr) == nil)
}
}
}
18 changes: 17 additions & 1 deletion android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,23 @@ object AnalyticsEngine {
}
if (deep.isEmpty()) null else deep.sum() / deep.size
} else run {
val pairs = physiologySessions.mapNotNull { s ->
// A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The
// day-wide pool below rests on "the main overnight dominates" (see physiologySessions), which
// holds only while that night has a value: once the gate makes it null it carries zero weight,
// and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a
// night. So when a main-group session's HRV is null BECAUSE the gate refused it, only the main
// group is pooled, and the day holds (null) unless another fragment of that night measured
// cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the
// day's other sessions is unchanged. Byte-parity twin of Swift `avgHRVDaily`.
val mainNightRefused = mainGroup.any { s ->
s.avgHRV == null && SleepStager.sessionHrvOverCounted(s.start, s.end, rr)
}
val hrvPool = if (mainNightRefused) {
physiologySessions.filter { p -> mainGroup.any { it.start == p.start && it.end == p.end } }
} else {
physiologySessions
}
val pairs = hrvPool.mapNotNull { s ->
s.avgHRV?.let { it to (s.end - s.start).toDouble() }
}
if (pairs.isEmpty()) {
Expand Down
15 changes: 13 additions & 2 deletions android/app/src/main/java/com/noop/analytics/SleepStager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3296,6 +3296,18 @@ object SleepStager {
// Classified over the SAME beats the value was built from, windowed [start, end] exactly as
// `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number
// it is gating.
if (sessionHrvOverCounted(start, end, rr)) return null
return vals.sum() / vals.size.toDouble()
}

/**
* Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly as
* [sessionAvgHRV] windows it, banks more beat-time than the wall clock allows. Pure. The ONE definition
* of "refused": [sessionAvgHRV] gates on it, and AnalyticsEngine asks it whether a main night's missing
* HRV was refused rather than never measured, so the two cannot disagree about which nights were
* refused. Byte-parity twin of Swift `SleepStager.sessionHrvOverCounted`.
*/
internal fun sessionHrvOverCounted(start: Long, end: Long, rr: List<RrInterval>): Boolean {
val seg = rr.filter { it.ts in start..end }
val segTs = seg.map { it.ts }
val segMs = seg.map { it.rrMs.toDouble() }
Expand All @@ -3309,8 +3321,7 @@ object SleepStager {
// night to two, and buying a distinction the caller discards would hand that back. `rrCoverage` is
// a single O(n) pass. If a future gate ever needs the two over-count cases apart, compute it then.
val verdict = HrvAnalyzer.classifyCoverage(coverage, coverage)
if (!HrvAnalyzer.successiveDiffIsTrustworthy(verdict)) return null
return vals.sum() / vals.size.toDouble()
return !HrvAnalyzer.successiveDiffIsTrustworthy(verdict)
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.noop.analytics

import com.noop.data.HrSample
import com.noop.data.RrInterval
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneOffset

/**
* A main night whose HRV the #1118 over-count gate REFUSED must not be replaced by the day's naps.
*
* Field case: a ring night read 1.31 coverage, the gate made its HRV null, and the day's HRV became the mean
* of two 26-minute naps, which the baseline then folded as a full night. The cases pin the rule and its
* boundary: refused ⇒ naps excluded; merely unmeasured (no R-R at all) ⇒ #1884's fill-in from the other
* sessions is unchanged; clean ⇒ pooled as before.
* Byte-parity twin of Swift `AnalyticsEngineRefusedMainNightHrvTests`: same fixtures, same expected values.
*/
class AnalyticsEngineRefusedMainNightHrvTest {
private val profile = UserProfile(weightKg = 75.0, heightCm = 178.0, age = 30.0, sex = "male")
private val day = "2026-07-27"
private val dayStart = LocalDate.parse(day).atStartOfDay(ZoneOffset.UTC).toEpochSecond()
private val nightStart = dayStart - 4 * 3600 // 20:00 the evening before
private val nightEnd = nightStart + 600 * 60 // 06:00 on `day`
private val napStart = dayStart + 13 * 3600 // 13:00 on `day`
private val napEnd = napStart + 40 * 60 // 13:40

private fun hr(start: Long, end: Long) =
(start until end step 30).map { HrSample("t", it, 52 + ((it / 300) % 4).toInt()) }

/** One beat per second, alternating around 1000 ms by [swing]: coverage ~1.0, RMSSD = 2 * swing. */
private fun cleanRR(start: Long, end: Long, swing: Int) =
(start until end).map { RrInterval("t", it, if ((it - start) % 2 == 0L) 1000 - swing else 1000 + swing) }

/** Two beats in every second (880 + 960 ms): ~1.84x the wall clock, refused by the gate. */
private fun overCountedRR(start: Long, end: Long) =
(start until end).flatMap { listOf(RrInterval("t", it, 880), RrInterval("t", it, 960)) }

private fun sessions() = listOf(
DetectedSleep(nightStart, nightEnd, 0.9, listOf(StageSegment(nightStart, nightEnd, "light")),
restingHR = null, avgHRV = null),
DetectedSleep(napStart, napEnd, 0.9, listOf(StageSegment(napStart, napEnd, "light")),
restingHR = null, avgHRV = null),
)

private fun analyze(nightRR: List<RrInterval>) = AnalyticsEngine.analyzeDay(
day = day, hr = hr(nightStart, nightEnd) + hr(napStart, napEnd),
rr = nightRR + cleanRR(napStart, napEnd, swing = 30), profile = profile, providedSleep = sessions(),
)

@Test fun aRefusedMainNightIsNotReplacedByTheNap() {
val night = overCountedRR(nightStart, nightEnd)
assertTrue("precondition: the gate refuses this night",
SleepStager.sessionHrvOverCounted(nightStart, nightEnd, night))
assertNotNull("precondition: the nap on its own measures an HRV",
SleepStager.sessionAvgHRV(napStart, napEnd, cleanRR(napStart, napEnd, swing = 30)))
assertNull("the day holds rather than reporting the nap as the night", analyze(night).daily.avgHrv)
}

@Test fun aMainNightWithNoRrStillFallsBackToTheNap() {
assertEquals(60.0, analyze(emptyList()).daily.avgHrv!!, 1e-9)
}

@Test fun aCleanMainNightIsPooledWithTheNapAsBefore() {
// In-bed-weighted: (40 * 600 min + 60 * 40 min) / 640 min.
assertEquals(41.25, analyze(cleanRR(nightStart, nightEnd, swing = 20)).daily.avgHrv!!, 1e-9)
}

@Test fun theHelperAgreesWithTheValueGate() {
for (rr in listOf(overCountedRR(nightStart, nightEnd), cleanRR(nightStart, nightEnd, swing = 20))) {
assertEquals(SleepStager.sessionHrvOverCounted(nightStart, nightEnd, rr),
SleepStager.sessionAvgHRV(nightStart, nightEnd, rr) == null)
}
}
}
Loading