diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index dfd7b9f04b..60235fefde 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -699,7 +699,15 @@ public enum AnalyticsEngine { // call site" a scattered filter invites. let physiologyOnly = matched.filter { !$0.hrOnly } let physiologySessions = physiologyOnly.isEmpty ? matched : physiologyOnly - let restingHRDaily = physiologySessions.compactMap { $0.restingHR }.min() + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. + // Cleanly falls back to physiologySessions.compactMap { $0.restingHR }.min() when coverage is sparse. + let providedPrimaryRHR = physiologySessions.max(by: { ($0.end - $0.start) < ($1.end - $1.start) }) + .flatMap { p in providedSleep.first(where: { $0.start == p.start && $0.end == p.end })?.restingHR } + let restingHRDaily: Int? = providedPrimaryRHR + ?? primarySessionRestingHR(sessions: physiologySessions, hr: hr).map { Int($0.rounded()) } + ?? physiologySessions.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. let avgHRVDaily: Double? = { if deepHrvWindow { diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift index 5eac24c4e1..c64eac27fe 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift @@ -10,12 +10,19 @@ import Foundation /// references, a pre-declared dev/holdout split, no fitted offset) found the primary-session sample mean /// tracked both references far better: rounded MAE vs the official target 6.0→2.0 (dev) / 7.5→0.8 (holdout). /// -/// ## Deliberately PURE and UNWIRED -/// This computes the metric and is unit-tested, but **nothing consumes it yet**. The shipped headline AND the -/// recovery / strain / workout-detection / energy inputs all read `restingHRDaily` (the floor) in -/// `AnalyticsEngine`, so switching them is a re-baselining of core scores — which the issue itself says needs -/// a larger multi-participant, pre-declared holdout first. That is out of scope here; this lands the -/// transparent, testable definition so that validation and any later wiring have something concrete to use. +/// ## WIRED as of #2358 — this sets the shipped daily resting HR +/// It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of core +/// scores and that the issue asks for a larger multi-participant holdout first. #2358 made the switch anyway, +/// as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided primary-session value, +/// then THIS mean, and falls back to the old `restingHR.min()` floor only when coverage is sparse. So the +/// headline resting HR and everything reading it — recovery, strain, workout detection, energy — come from +/// here on any day with a covered primary session. +/// +/// What that means for the evidence below: the MAE figures are from ONE participant over five nights against +/// a pre-declared split, which is the holdout the issue says is not yet large enough. They justified building +/// the metric; they are thinner than the change they now carry. #2284 separately replaces what a session's +/// `restingHR` IS (deep-sleep mean rather than lowest 5-minute bin), which moves the `.min()` fallback under +/// this, so the comparison baseline those numbers were measured against no longer exists unchanged. /// /// ## Definition (documented per the issue) /// - **Primary session**: the LONGEST session by duration; ties resolve to the FIRST (stable). A shorter nap diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 863de4de39..30917ecbb0 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -576,7 +576,15 @@ object AnalyticsEngine { // only way to scope them is through the session set itself — which is precisely the "one forgotten // call site" a scattered filter invites. val physiologySessions = matched.filter { !it.hrOnly }.ifEmpty { matched } - val restingHRDaily: Int? = physiologySessions.mapNotNull { it.restingHR }.minOrNull() + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. + // Cleanly falls back to physiologySessions.mapNotNull { it.restingHR }.minOrNull() when coverage is sparse. + val providedPrimaryRHR = physiologySessions.maxByOrNull { it.end - it.start } + ?.let { p -> providedSleep.firstOrNull { it.start == p.start && it.end == p.end }?.restingHR } + val restingHRDaily: Int? = providedPrimaryRHR + ?: primarySessionRestingHR(physiologySessions, hr)?.roundToInt() + ?: physiologySessions.mapNotNull { it.restingHR }.minOrNull() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. val avgHRVDaily: Double? = if (deepHrvWindow) { // #141: WHOOP-style HRV — pool RMSSD over DEEP-stage 5-min windows only (slow-wave sleep), diff --git a/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt index 95c5c0f5e6..20e8d98e56 100644 --- a/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt +++ b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt @@ -11,11 +11,17 @@ package com.noop.analytics * split, no fitted offset) found the primary-session sample mean tracked both far better: rounded MAE vs the * official target 6.0->2.0 (dev) / 7.5->0.8 (holdout). * - * ## Deliberately PURE and UNWIRED - * This computes the metric and is unit-tested, but nothing consumes it yet. The shipped headline AND the - * recovery / strain / workout-detection / energy inputs all read the floor `restingHRDaily`, so switching - * them is a re-baselining of core scores that the issue itself says needs a larger multi-participant, - * pre-declared holdout first. Out of scope here; this lands the transparent, testable definition. + * ## WIRED as of #2358 — this sets the shipped daily resting HR + * It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of + * core scores and that the issue asks for a larger multi-participant holdout first. #2358 made the + * switch anyway, as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided + * primary-session value, then THIS mean, and falls back to the old `restingHR.min()` floor only when + * coverage is sparse. So the headline resting HR and everything reading it — recovery, strain, workout + * detection, energy — come from here on any day with a covered primary session. + * + * What that means for the evidence below: the MAE figures are from ONE participant over five nights + * against a pre-declared split, which is the holdout the issue says is not yet large enough. #2284 + * separately replaces what a session's `restingHR` IS, which moves the fallback under this. * * ## Definition * - **Primary session**: the LONGEST by duration; ties resolve to the FIRST. A nap never replaces the night.