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 @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 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 @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading