Skip to content
Open
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 @@ -519,7 +519,8 @@ public enum AnalyticsEngine {
// that measured a resting HR but no HRV (no R-R banked) would take the short-circuit and
// skip the fill every other session gets. The rule is uniform: fill what is missing.
guard s.restingHR == nil || s.avgHRV == nil else { return s }
let rhr = s.restingHR ?? SleepStager.sessionRestingHR(start: s.start, end: s.end, hr: hr)
let rhr = s.restingHR ?? SleepStager.sessionDeepSleepRestingHR(start: s.start, end: s.end,
hr: hr, stages: s.stages)
let hrv = s.avgHRV ?? SleepStager.sessionAvgHRV(start: s.start, end: s.end, rr: rrSorted)
// `hrOnly` carried explicitly: unlike Kotlin's `copy`, this rebuilds the struct field by
// field, so a new flag is dropped by DEFAULT unless named here. #1884 removed the guard
Expand Down
86 changes: 71 additions & 15 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ public struct SleepSession: Equatable, Sendable {
/// asleep / in-bed in [0, 1] (AASM TST/TIB; asleep = in-bed − wake).
public let efficiency: Double
public let stages: [StageSegment]
/// Lowest 5-min rolling-mean HR during the session (bpm), or nil.
/// The session's resting HR (bpm): mean HR across its deep-sleep segments, or nil. See
/// `SleepStager.sessionDeepSleepRestingHR`.
public let restingHR: Int?
/// Mean RMSSD over 5-min windows across the session (ms), or nil.
public let avgHRV: Double?
Expand Down Expand Up @@ -81,6 +82,9 @@ public enum SleepStager {
/// Minimum plausible mean HR (bpm) for a bin to qualify. A dropout-driven sub-physiological
/// dip cannot become the floor.
public static let rhrMinPlausibleBpm: Double = 25
/// Minimum plausible HR samples inside a session's deep-sleep segments (~5 min at the strap's 1 Hz)
/// for their mean to be the night's resting HR. Fewer falls back to `sessionLowQuartileRestingHR`.
public static let rhrMinDeepSleepSamples: Int = 300

// MARK: - Stage 0 constants (sleep.py)

Expand Down Expand Up @@ -697,7 +701,8 @@ public enum SleepStager {
out.append(SleepSession(start: p.start, end: p.end,
efficiency: efficiency(start: p.start, end: p.end, stages: stages),
stages: stages,
restingHR: sessionRestingHR(start: p.start, end: p.end, hr: hrS),
restingHR: sessionDeepSleepRestingHR(start: p.start, end: p.end,
hr: hrS, stages: stages),
avgHRV: sessionAvgHRV(start: p.start, end: p.end, rr: rrS),
hrOnly: true))
}
Expand Down Expand Up @@ -1588,11 +1593,17 @@ public enum SleepStager {
bandSleepState: bandSleepState)
let eff = efficiency(start: p.start, end: p.end, stages: stages)
let avgHrv = sessionAvgHRV(start: p.start, end: p.end, rr: rrS)
let reportedRestingHR = sessionDeepSleepRestingHR(start: p.start, end: p.end, hr: hrS, stages: stages)
sessions.append(SleepSession(start: p.start, end: p.end, efficiency: eff,
stages: stages, restingHR: resting, avgHRV: avgHrv))
stages: stages,
restingHR: reportedRestingHR,
avgHRV: avgHrv))
// `restingHR` is what the session reports (the deep-sleep mean); `floor` is the lowest bin the
// daytime guard above tested, the same naming as the `rhr day=` line.
traceSink?(GateTrace.runLine(index: runIndex, startTs: p.start, endTs: p.end,
verdict: .kept, gate: "accepted",
detail: "spanMin=\(spanMin) eff=\(round2(eff)) restingHR=\(resting ?? -1) daytime=\(isDaytime)"))
detail: "spanMin=\(spanMin) eff=\(round2(eff)) restingHR=\(reportedRestingHR ?? -1) "
+ "floor=\(resting ?? -1) daytime=\(isDaytime)"))
// #1210 shadow: the band wake-veto is dormant (default-off), but its recovered-vs-reverse ratio
// can only come from banded nights. When a band stream is present, compute what the veto WOULD
// recover and trace it — OUTPUT-NEUTRAL: `stages`/`eff` persisted above are the flag-gated
Expand Down Expand Up @@ -2901,16 +2912,64 @@ public enum SleepStager {
+ "gated=\(gatedFloor.map(String.init) ?? "nil") shipped=\(shippedFloor) gateMoved=\(moved)"
}

static func sessionRestingHR(start: Int, end: Int, hr: [HRSample]) -> Int? {
/// The lowest well-populated 5-min bin mean in the session — its "lowest sustained" HR. Not the night's
/// resting HR any more (see `sessionDeepSleepRestingHR`); it remains the daytime false-sleep guard's
/// "real resting-HR dip" test, whose thresholds were tuned against this statistic.
public static func sessionRestingHR(start: Int, end: Int, hr: [HRSample]) -> Int? {
// #1943: a bin qualifies to WIN the floor only when it is well-populated (≥ rhrMinBinSamples)
// and its mean is physiologically plausible (≥ rhrMinPlausibleBpm). A one-sample bin at the
// edge of a wear gap, or a dropout-driven sub-physiological dip, cannot become the floor. If no
// bin qualifies, fall back to the lowest of ALL bin means (ungated), then the all-sample mean —
// preserving the never-null-on-data behaviour.
guard let bins = restingBinMeans(start: start, end: end, hr: hr) else { return nil }
if let m = bins.gated.min() { return Int(m.rounded()) }
if let m = bins.all.min() { return Int(m.rounded()) }
return Int(bins.sampleMean.rounded())
}

/// The night's resting HR: the mean of the plausible HR samples inside its deep-sleep segments.
///
/// WHOOP measures resting HR during slow-wave sleep, the window NOOP already pools its WHOOP-style HRV
/// over (#141). The previous statistic, the lowest 5-min bin, is the single calmest stretch of the
/// night rather than a resting level: on one WHOOP 5.0 wearer's 23 nights it averaged 48.9 bpm against
/// the 55.5 bpm WHOOP reported for the same wearer the month before, where this averaged 54.9 with a
/// night-to-night spread (SD 3.1) close to WHOOP's (3.6). The number is displayed, stored on the
/// daily row, exported to Apple Health, and fed to the recovery baseline — whose imported WHOOP history
/// sat ~8 bpm above every computed night, reading each as an unusually low resting HR.
///
/// Fewer than `rhrMinDeepSleepSamples` deep-sleep samples (no deep staging, or a short nap) falls back to
/// `sessionLowQuartileRestingHR` rather than the floor, so the fallback stays on the same level.
static func sessionDeepSleepRestingHR(start: Int, end: Int, hr: [HRSample], stages: [StageSegment]) -> Int? {
let deep = stages.filter { $0.stage == "deep" && $0.end > $0.start }
var sum = 0, n = 0
if !deep.isEmpty {
for s in hr where s.ts >= start && s.ts <= end && Double(s.bpm) >= rhrMinPlausibleBpm
&& deep.contains(where: { s.ts >= $0.start && s.ts < $0.end }) {
sum += s.bpm; n += 1
}
}
if n >= rhrMinDeepSleepSamples { return Int((Double(sum) / Double(n)).rounded()) }
return sessionLowQuartileRestingHR(start: start, end: end, hr: hr)
}

/// The lower quartile of the session's qualifying 5-min bin means — the deep-sleep resting HR's fallback
/// for a session with too little deep sleep. On the nights above it sat 0.6 bpm under the deep-sleep mean,
/// where the lowest bin sat 6. No qualifying bin falls back to `sessionRestingHR`.
static func sessionLowQuartileRestingHR(start: Int, end: Int, hr: [HRSample]) -> Int? {
guard let bins = restingBinMeans(start: start, end: end, hr: hr) else { return nil }
let gated = bins.gated.sorted()
guard !gated.isEmpty else { return sessionRestingHR(start: start, end: end, hr: hr) }
return Int(gated[gated.count / 4].rounded())
}

/// 5-min tumbling bin means of the session's HR, split into the bins that qualify
/// (≥ `rhrMinBinSamples`, mean ≥ `rhrMinPlausibleBpm`) and all of them, plus the all-sample mean.
/// nil when the session holds no samples.
private static func restingBinMeans(start: Int, end: Int, hr: [HRSample])
-> (gated: [Double], all: [Double], sampleMean: Double)? {
let seg = hr.filter { $0.ts >= start && $0.ts <= end }
guard !seg.isEmpty else { return nil }
let windowS = 5 * 60
// #1943: a bin qualifies to WIN the floor only when it is well-populated (≥ rhrMinBinSamples)
// and its mean is physiologically plausible (≥ rhrMinPlausibleBpm). A one-sample bin at the
// edge of a wear gap, or a dropout-driven sub-physiological dip, cannot become the night's
// resting HR — that number is displayed, stored on the daily row, and fed to the baseline
// later nights are scored against. If no bin qualifies, fall back to the lowest of ALL bin
// means (ungated), then the all-sample mean — preserving the never-null-on-data behaviour.
var gatedMeans: [Double] = []
var allMeans: [Double] = []
var t = start
Expand All @@ -2928,10 +2987,7 @@ public enum SleepStager {
}
t += windowS
} while t < end
if let m = gatedMeans.min() { return Int(m.rounded()) }
if let m = allMeans.min() { return Int(m.rounded()) }
let all = Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count)
return Int(all.rounded())
return (gatedMeans, allMeans, Double(seg.reduce(0) { $0 + $1.bpm }) / Double(seg.count))
}

/// One 5-min HRV window: its start ts, the sleep stage at its center, the clean-beat count, and the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import XCTest
import WhoopProtocol
@testable import StrandAnalytics

/// The night's resting HR is the mean HR across its deep-sleep segments, not its single calmest 5-min bin.
final class SleepStagerDeepSleepRestingHRTests: XCTestCase {

private let start = 1_000_000

/// 1 Hz samples over [from, to) at `bpm`.
private func samples(_ from: Int, _ to: Int, bpm: Int) -> [HRSample] {
(from..<to).map { HRSample(ts: $0, bpm: bpm) }
}

/// A 40-min night: 10 min light at 60, 20 min deep at 55, 10 min light holding a 5-min dip at 48.
private var night: (hr: [HRSample], stages: [StageSegment], end: Int) {
let hr = samples(start, start + 600, bpm: 60)
+ samples(start + 600, start + 1_800, bpm: 55)
+ samples(start + 1_800, start + 2_100, bpm: 48)
+ samples(start + 2_100, start + 2_400, bpm: 60)
let stages = [StageSegment(start: start, end: start + 600, stage: "light"),
StageSegment(start: start + 600, end: start + 1_800, stage: "deep"),
StageSegment(start: start + 1_800, end: start + 2_400, stage: "light")]
return (hr, stages, start + 2_400)
}

func testTheRestingHRIsTheDeepSleepMeanNotTheLowestBin() {
let n = night
XCTAssertEqual(SleepStager.sessionDeepSleepRestingHR(start: start, end: n.end, hr: n.hr, stages: n.stages), 55)
XCTAssertEqual(SleepStager.sessionRestingHR(start: start, end: n.end, hr: n.hr), 48,
"the lowest-bin statistic the daytime guard reads is unchanged")
}

func testImplausibleSamplesDoNotPullTheDeepSleepMeanDown() {
let n = night
let dropouts = samples(start + 600, start + 660, bpm: 0)
XCTAssertEqual(SleepStager.sessionDeepSleepRestingHR(start: start, end: n.end, hr: dropouts + n.hr,
stages: n.stages), 55)
}

/// Under `rhrMinDeepSleepSamples` of deep sleep the lower quartile of the 5-min bins stands in, which
/// stays on the resting level rather than dropping to the dip.
func testTooLittleDeepSleepFallsBackToTheLowerQuartileBin() {
let n = night
let briefDeep = [StageSegment(start: start + 600, end: start + 840, stage: "deep")]
// Bins: 60, 60, 55, 55, 55, 55, 48, 60 → sorted [48, 55, 55, 55, 55, 60, 60, 60], index 8/4 = 2 → 55.
XCTAssertEqual(SleepStager.sessionDeepSleepRestingHR(start: start, end: n.end, hr: n.hr, stages: briefDeep), 55)
XCTAssertEqual(SleepStager.sessionDeepSleepRestingHR(start: start, end: n.end, hr: n.hr, stages: []), 55)
}

func testASessionWithNoSamplesHasNoRestingHR() {
XCTAssertNil(SleepStager.sessionDeepSleepRestingHR(start: start, end: start + 600, hr: [], stages: night.stages))
}
}
6 changes: 6 additions & 0 deletions Strand/App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,12 @@ final class AppModel: ObservableObject {
// history once, so any deep-history rows an older build left on the 0–21 axis regenerate on
// the 0–100 axis. Guarded by a persisted flag, so this is a no-op on every subsequent launch.
await self.intelligence.runEffortRescoreIfNeeded()
// One-shot resting-HR rescore: every computed night's resting HR moved from the lowest 5-min bin to
// the deep-sleep mean, so recompute the full history once and have Apple Health replace what it was
// given from the old statistic.
if await self.intelligence.runEffortRescoreIfNeeded(flagKey: IntelligenceEngine.restingHRRescoreFlagKey) {
UserDefaults.standard.set(true, forKey: IntelligenceEngine.restingHRHealthRewriteOwedKey)
}
while !Task.isCancelled {
// #547 RE-POLLUTION: a sync since the last tick may have armed a re-heal (its ingest gate
// dropped bad-clock records). `runTimestampHealIfNeeded` honours the pending flag even after
Expand Down
Loading
Loading