diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index dfd7b9f04b..4fcd89a22b 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -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 diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 1d8c6bf8bc..0eed082a2d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -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? @@ -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) @@ -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)) } @@ -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 @@ -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 @@ -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 diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerDeepSleepRestingHRTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerDeepSleepRestingHRTests.swift new file mode 100644 index 0000000000..af1e48a031 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerDeepSleepRestingHRTests.swift @@ -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..=\(gate) rrIntegrity=\(integrity) — gate passed, cause is elsewhere" } - nonisolated static func rhrFloorMeanLogLine(day: String, floor: Int, inBedBpms: [Int]) -> String { + nonisolated static func rhrFloorMeanLogLine(day: String, restingHr: Int, floor: Int?, inBedBpms: [Int]) -> String { let meanLog: String = inBedBpms.isEmpty ? "nil" : String(Int((Double(inBedBpms.reduce(0, +)) / Double(inBedBpms.count)).rounded())) - return "rhr day=\(day) floor=\(floor) nightMean=\(meanLog) inBedSamples=\(inBedBpms.count) " - + "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)" + return "rhr day=\(day) rhr=\(restingHr) floor=\(floor.map(String.init) ?? "nil") nightMean=\(meanLog) " + + "inBedSamples=\(inBedBpms.count) " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)" } /// #1244: one line for a day that CLEARED the ≥200-HR gate yet detected NO in-bed session, so the @@ -597,6 +602,16 @@ final class IntelligenceEngine: ObservableObject { /// pass completes so it never re-runs. static let effortRescoreFlagKey = "intelligence.effortRescore.v313.done" + /// UserDefaults flag guarding the one-shot full-history rescore that moved every computed night's resting + /// HR from the lowest 5-min bin to the deep-sleep mean (`SleepStager.sessionDeepSleepRestingHR`). Without + /// it, nights older than the rolling window would keep the old value on the daily row, in the recovery + /// baseline, and in Apple Health. + static let restingHRRescoreFlagKey = "intelligence.restingHRDeepSleepRescore.v1.done" + + /// Set once the resting-HR rescore above completes; the Apple Health write-back reads it to replace the + /// resting HR it wrote from the old statistic, then clears it. + static let restingHRHealthRewriteOwedKey = "noop.health.restingHRRewriteOwed.v1" + /// One-shot, on-upgrade FULL-history Effort rescore (#313 PART B). The Effort hero gauge + numbers /// moved from the old 0–21 axis to NOOP's own 0–100 axis. On-device computed rows since v2.6.1 /// already store 0–100, but rows the engine computed on an OLDER build (capped at `maxDays` per run, @@ -609,14 +624,31 @@ final class IntelligenceEngine: ObservableObject { /// runs exactly once. IMPORTED rows are never rewritten here (the engine only ever writes under the /// "-noop" computed source) , those are handled by re-import. A day already on 0–100 is recomputed /// from the same raw HR and lands on 0–100 again: UNCHANGED axis (verified by test). - func runEffortRescoreIfNeeded(historyDays: Int = 4000) async { - guard !UserDefaults.standard.bool(forKey: Self.effortRescoreFlagKey) else { return } - await analyzeRecent(maxDays: historyDays) - // Only mark done if the pass actually completed (wasn't skipped because another tick held the - // `computing` lock). `computing` is false here once analyzeRecent's `defer` has run; a skipped - // call returns with `note` unset by it. Use the lock state: if a concurrent run was in progress - // the flag stays unset so the next launch retries , cheap, and correctness over a one-time cost. - if !computing { UserDefaults.standard.set(true, forKey: Self.effortRescoreFlagKey) } + /// + /// `flagKey` lets another one-shot full-history recompute reuse this pass (`restingHRRescoreFlagKey`). + /// Returns whether this call ran the pass to completion. + @discardableResult + func runEffortRescoreIfNeeded(historyDays: Int = 4000, flagKey: String = effortRescoreFlagKey) async -> Bool { + guard !UserDefaults.standard.bool(forKey: flagKey) else { return false } + // Wait out a pass that already holds the lock rather than give up until the next launch. This runs + // at launch, which is exactly when a strap reconnect starts a post-offload pass, and a busy install + // re-arms those back to back: a field log showed the resting-HR rescore still unrun a day after + // install, having lost the race on every launch in between. + while !Task.isCancelled { + while computing && !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + let passesBefore = completedPasses + await analyzeRecent(maxDays: historyDays) + // Another trigger can still take the lock between the wait and the call, and then this call + // returns without scoring anything. Only a pass at least this wide finishing proves the work + // was done. + if completedPasses > passesBefore, lastCompletedPassDays >= historyDays { + UserDefaults.standard.set(true, forKey: flagKey) + return true + } + } + return false } /// UserDefaults flag guarding the one-shot #547 implausible-timestamp DB heal (below). Set once the @@ -1659,17 +1691,25 @@ final class IntelligenceEngine: ObservableObject { // `diagnosticSink` in the SAME per-day order , the sink is a MainActor-bound closure. var rhrLine: String? var rhrBinLine: String? - if let floor = res.daily.restingHr { + if let restingHr = res.daily.restingHr { let inBedBpms = hr.filter { s in res.cachedSleep.contains { s.ts >= $0.startTs && s.ts < $0.endTs } }.map { $0.bpm } - rhrLine = Self.rhrFloorMeanLogLine(day: res.daily.day, floor: floor, inBedBpms: inBedBpms) + // The lowest-bin floor, taken the way the day took its resting HR before it became the + // deep-sleep mean (min across the night's sessions), so the two sit side by side. + let floor = res.cachedSleep.compactMap { + SleepStager.sessionRestingHR(start: $0.startTs, end: $0.endTs, hr: hr) + }.min() + rhrLine = Self.rhrFloorMeanLogLine(day: res.daily.day, restingHr: restingHr, floor: floor, + inBedBpms: inBedBpms) // #1943: conformance check that the gate `sessionRestingHR` now applies agrees with - // the shipped floor. Silent when the gate is correctly applied. - rhrBinLine = SleepStager.rhrBinGateLogLine( - day: res.daily.day, - sessions: res.cachedSleep.map { ($0.startTs, $0.endTs) }, - hr: hr, shippedFloor: floor) + // the floor it produced. Silent when the gate is correctly applied. + if let floor { + rhrBinLine = SleepStager.rhrBinGateLogLine( + day: res.daily.day, + sessions: res.cachedSleep.map { ($0.startTs, $0.endTs) }, + hr: hr, shippedFloor: floor) + } } // #1331 respiratory diagnostic — a run of nil nights localises when it stopped. Same // pure-compute-here / replay-on-main-actor path as rhrLine. @@ -2899,6 +2939,8 @@ final class IntelligenceEngine: ObservableObject { elapsedSeconds: elapsed, assertionExpiries: RescoreBackgroundScheduler.assertionExpiries - reScoreExpiriesAtStart, backgroundedAtEnd: RescoreBackgroundScheduler.isBackgrounded), nil) + completedPasses += 1 + lastCompletedPassDays = maxDays // #1681: a pass that completes while leaving the mark SET looks identical in a capture to one that // cleared it. Rare-event evidence, so always-on: it costs a line only when it actually happens, // and it is exactly what is missing when someone reports the app re-scoring on every launch. diff --git a/StrandTests/IntelligenceOneShotRescoreTests.swift b/StrandTests/IntelligenceOneShotRescoreTests.swift new file mode 100644 index 0000000000..587230a7be --- /dev/null +++ b/StrandTests/IntelligenceOneShotRescoreTests.swift @@ -0,0 +1,28 @@ +import XCTest +import WhoopStore +@testable import Strand + +/// A one-shot full-history rescore must wait for a pass that holds the lock, not give up until next launch. +@MainActor +final class IntelligenceOneShotRescoreTests: XCTestCase { + func testTheOneShotWaitsForARunningPassAndThenRunsItsOwn() async throws { + let flagKey = "test.oneShotRescore.\(UUID().uuidString)" + defer { UserDefaults.standard.removeObject(forKey: flagKey) } + let store = try await WhoopStore.inMemory() + let repo = Repository(deviceId: "my-whoop") + repo.setStoreForTesting(store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "my-whoop") + + engine.computing = true + let oneShot = Task { await engine.runEffortRescoreIfNeeded(historyDays: 2, flagKey: flagKey) } + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertFalse(UserDefaults.standard.bool(forKey: flagKey), "must not mark done while another pass holds the lock") + engine.computing = false + + let ran = await oneShot.value + XCTAssertTrue(ran) + XCTAssertTrue(UserDefaults.standard.bool(forKey: flagKey)) + let again = await engine.runEffortRescoreIfNeeded(historyDays: 2, flagKey: flagKey) + XCTAssertFalse(again, "a done flag is a no-op") + } +} diff --git a/StrandTests/IntelligenceRhrFloorMeanTests.swift b/StrandTests/IntelligenceRhrFloorMeanTests.swift index 3992264e34..8ff8909f25 100644 --- a/StrandTests/IntelligenceRhrFloorMeanTests.swift +++ b/StrandTests/IntelligenceRhrFloorMeanTests.swift @@ -1,54 +1,43 @@ import XCTest @testable import Strand -/// Pins the RHR floor-vs-mean strap-log line (#691). The recurring "NOOP's resting HR reads LOWER than -/// my sleeping-HR app" reports are NOT a bug: NOOP's `restingHr` is the WHOOP-style FLOOR (the lowest -/// sustained 5-min in-bed level), whereas a "sleeping HR" app reports the night MEAN over the whole -/// asleep span. The mean always sits at-or-above the floor, so NOOP looking lower is by design. The -/// engine now logs BOTH per scored night so a report carries the proof. `rhrFloorMeanLogLine` is the -/// pure formatter the loop calls; it's tested directly (no store). Mirrors the Android -/// `IntelligenceRhrFloorMeanTest` so the two platforms log byte-identical lines. +/// Pins the resting-HR strap-log line (#691). NOOP's `restingHr` is the deep-sleep mean HR; the line carries +/// it beside the lowest 5-min bin (`floor`, what NOOP reported until it read ~6 bpm under WHOOP's) and the +/// whole in-bed mean a "sleeping HR" app reports, so a "NOOP reads differently from my other app" report is +/// explainable from the log. `rhrFloorMeanLogLine` is the pure formatter the loop calls; it's tested +/// directly (no store). Mirrors the Android `IntelligenceRhrFloorMeanTest` so the two platforms log +/// byte-identical lines. @MainActor final class IntelligenceRhrFloorMeanTests: XCTestCase { private typealias IE = IntelligenceEngine - func testFloorBelowMean_theReportedDiscrepancy() { - // The exact shape of the reports: an in-bed stretch that dips to a 48 bpm floor but averages 55. - // Both numbers ship so a "NOOP is lower than my other app" report is explainable from the log. + func testAllThreeStatisticsShipOnOneLine() { let bpms = [48, 50, 52, 55, 58, 60, 62] // mean = 55.0 → "55" - let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", floor: 48, inBedBpms: bpms) + let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", restingHr: 53, floor: 48, inBedBpms: bpms) XCTAssertEqual(line, - "rhr day=2026-06-12 floor=48 nightMean=55 inBedSamples=7 " - + "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)") + "rhr day=2026-06-12 rhr=53 floor=48 nightMean=55 inBedSamples=7 " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)") } func testMeanRoundsToNearest() { // 50,51,52,54 → 207/4 = 51.75 → rounds to 52 (banker-free .rounded()), matching Kotlin Math.round. - let line = IE.rhrFloorMeanLogLine(day: "2026-06-13", floor: 50, inBedBpms: [50, 51, 52, 54]) - XCTAssertTrue(line.contains("floor=50 nightMean=52 inBedSamples=4"), line) + let line = IE.rhrFloorMeanLogLine(day: "2026-06-13", restingHr: 51, floor: 50, inBedBpms: [50, 51, 52, 54]) + XCTAssertTrue(line.contains("rhr=51 floor=50 nightMean=52 inBedSamples=4"), line) } - func testEmptyInBed_meanIsNil() { - // A banked floor but no HR sample fell inside a matched session (edge): mean reads "nil", not 0, + func testEmptyInBedAndMissingFloorReadNil() { + // A resting HR but no HR sample inside a matched session (edge): mean and floor read "nil", not 0, // and the line is still emitted so the night stays visible in the log. - let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", floor: 47, inBedBpms: []) + let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", restingHr: 47, floor: nil, inBedBpms: []) XCTAssertEqual(line, - "rhr day=2026-06-12 floor=47 nightMean=nil inBedSamples=0 " - + "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)") - } - - func testFloorNeverExceedsMean_byConstruction() { - // Sanity on the framing itself: across any in-bed set the floor (a min over the same span) is - // <= the mean, so NOOP's RHR can only read at-or-below a sleeping-HR-app's night mean. - let bpms = [44, 46, 49, 53, 57, 61] - let mean = Double(bpms.reduce(0, +)) / Double(bpms.count) - XCTAssertLessThanOrEqual(Double(bpms.min()!), mean) + "rhr day=2026-06-12 rhr=47 floor=nil nightMean=nil inBedSamples=0 " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)") } func testLineCarriesNoEmDash() { // House style: never an em-dash in shared text. - let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", floor: 48, inBedBpms: [48, 60]) + let line = IE.rhrFloorMeanLogLine(day: "2026-06-12", restingHr: 50, floor: 48, inBedBpms: [48, 60]) XCTAssertFalse(line.contains("—")) } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 8d3e0cb27b..9432b5dfad 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -813,7 +813,15 @@ final class HealthKitBridge: ObservableObject { // the HR path uses), then the normal writes re-add them under the new keys. Runs once, // gated by a UserDefaults flag, BEFORE the new-key writes so nothing is lost. await attempt { try await migrateStrandedHealthRecords(fromTs: fromTs, nowTs: nowTs) } - await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions, holdingDays: openDays) } + if UserDefaults.standard.bool(forKey: IntelligenceEngine.restingHRHealthRewriteOwedKey) { + await attempt { + try await rewriteRestingHR(whoopStore: whoopStore, minDays: days, sessions: sessions, + holdingDays: openDays) + UserDefaults.standard.removeObject(forKey: IntelligenceEngine.restingHRHealthRewriteOwedKey) + } + } else { + await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions, holdingDays: openDays) } + } await attempt { try await writeSleep(sessions: sessions) } await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) } await attempt { try await writeWorkouts(whoopStore: whoopStore, fromTs: fromTs, toTs: nowTs) } @@ -889,6 +897,38 @@ final class HealthKitBridge: ObservableObject { } } + /// Replace every resting HR this app wrote to Apple Health since NOOP's first computed night, once the + /// resting-HR rescore has recomputed them (`IntelligenceEngine.restingHRRescoreFlagKey`). The rolling + /// write-back only reaches `minDays` back, so older nights would keep the lowest-5-min-bin value they were + /// written with. Our own resting-HR samples over the span are deleted first — scoped to `HKSource.default()`, + /// so a WHOOP or Apple Watch value is never touched — which also clears any written under an older key + /// scheme; then the vitals are written across the same span. A failed delete throws, so the caller keeps + /// the owed flag and the rewrite runs again on the next write-back; "nothing matched" is not a failure. + /// Without sharing permission for resting HR there is nothing this app may delete, so only the write runs. + private func rewriteRestingHR(whoopStore: WhoopStore, minDays: Int, sessions: [CachedSleepSession], + holdingDays: Set) async throws { + let computedDays = (try? await whoopStore.dailyMetrics(deviceId: computedDeviceId, from: "0000-01-01", + to: "9999-12-31")) ?? [] + let firstComputed = computedDays.map { $0.day }.min().flatMap { HealthKitBridge.date(from: $0) } + let span = firstComputed + .map { (Calendar.current.dateComponents([.day], from: $0, to: Date()).day ?? 0) + 1 } ?? 0 + let days = max(minDays, span) + if let type = HKQuantityType.quantityType(forIdentifier: .restingHeartRate), + store.authorizationStatus(for: type) == .sharingAuthorized, + let from = Calendar.current.date(byAdding: .day, value: -days, to: Date()) { + let pred = NSCompoundPredicate(andPredicateWithSubpredicates: [ + HKQuery.predicateForObjects(from: HKSource.default()), + HKQuery.predicateForSamples(withStart: Calendar.current.startOfDay(for: from), end: Date(), options: []), + ]) + do { + _ = try await store.deleteObjects(of: type, predicate: pred) + } catch let error as HKError where error.code == .errorNoData { + // Nothing of ours in the span: the cleanup is already done. + } + } + try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions, holdingDays: holdingDays) + } + /// The nightly vitals write (the original write-back), now stamped at the day's wake time when /// that day has a sleep session — a real timestamp inside the night the value describes, instead /// of a fabricated noon. Keys are unchanged, so re-stamped samples replace their noon ancestors. diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 3dcf98fd10..8c8aaa21c1 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,22 @@ "identity_sha256": "88a1bdfb13c227664f15d962fb6d41a5733fb4c2956e445e42c5e4d495bde0cb", "platform": "swift", "rationale": "Used only by the iOS HealthKitBridge write-back to hold a still-open night out of Apple Health; the Android Health Connect exporter does not call it (this PR is Swift-only)." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::rhrFloorMeanLogLine/4#1", + "identity_sha256": "5d72d3b8bf405f100c45e0a3e5e2f9a3a68a557da6620a197e2c6c07d4cf2794", + "platform": "kotlin", + "rationale": "Its Swift twin, IntelligenceEngine.rhrFloorMeanLogLine (byte-identical line, pinned by IntelligenceRhrFloorMeanTests), lives in the app target under Strand/Data, outside the governed Swift roots, so the ledger cannot pair the two." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt::dailyCutoff/2#1", + "identity_sha256": "6cb9a3a0755edb5e1621625ad35ea8c20de9b9542c1c746743915b0b77f1bcb1", + "platform": "kotlin", + "rationale": "Health Connect export window only; the iOS side of the same one-time rewrite lives in StrandiOS/Health/HealthKitBridge.swift (rewriteRestingHR), outside the governed roots." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 53f3770c23..c9390c3bd0 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,15 +19,15 @@ }, "authority": { "files": {"count": 502, "sha256": "322bb433fab56fef5ec926d7847faef78e4c64ea409d161184baaf483c8eec95"}, - "functions": {"count": 4455, "sha256": "a07ca22b2e581e8b6feadae9bdb1e4d645d46196921796df3a2be6cdf8c2ef7b"}, + "functions": {"count": 4462, "sha256": "4be3f9f1c534d4d3dae58f04d4525b8cf3481959e04a2c99447cbfc39cb72c80"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, - "constants": {"count": 1951, "sha256": "aa7ce58efe6a8d3409abd3ccad24d22889514d4555faca323a0ee751ff7925b5"}, + "constants": {"count": 1953, "sha256": "b914af320a87a8a2bdc6bd7e1496e2b2f15506af03a3a3e5e0dadb18574b6201"}, "file_pairs": {"count": 68, "sha256": "414dbafb27e1e35cf65cff54f6ff780f102f009762c1dfb12d91b0980fe30854"}, - "function_pairs": {"count": 176, "sha256": "e3a74634d5a9381cf6e3491df839dad5ab33ec6cee8f29eb77c47ba7974b2e76"}, + "function_pairs": {"count": 179, "sha256": "43e5a0d6e2d6bca3acf07a4f9a017705ef0148e73f83e31a2f39836aa6637caa"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, - "constant_pairs": {"count": 678, "sha256": "350d339d5fca3416a600ca96939a8ae8d269201e83ab5b05a8accbc6d3f85aa2"}, + "constant_pairs": {"count": 679, "sha256": "bb20c6f43a905569ba715d72e7f48ae5b5e18b14a540b712a53aec9cb1a80ef6"}, "unpaired_files": {"count": 384, "sha256": "17285ce29f015a373969bbcb13042b100a15d580825785f824680e0880b5d777"}, - "unpaired_functions": {"count": 4109, "sha256": "a3ec845a9b802edc70c7f018b6ecd56889022b7d95e9738c29dbe0fde6d4af5e"}, + "unpaired_functions": {"count": 4110, "sha256": "276f34db345027b3876676b6ea735195dacc3331c1293957c3b9b82195a7f753"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, "unpaired_constants": {"count": 595, "sha256": "f065b8e34db78569a84449444539956cb48df105c9b66274398422cf8c520bc7"} } 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..7fa0829ff5 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -457,7 +457,7 @@ object AnalyticsEngine { // skip the fill every other session gets. The rule is uniform: fill what is missing. if (s.restingHR != null && s.avgHRV != null) s else s.copy( - restingHR = s.restingHR ?: SleepStager.sessionRestingHR(s.start, s.end, hr), + restingHR = s.restingHR ?: SleepStager.sessionDeepSleepRestingHR(s.start, s.end, hr, s.stages), avgHRV = s.avgHRV ?: SleepStager.sessionAvgHRV(s.start, s.end, rrSorted), ) } diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt index f3d7fa5927..f8b803f9e4 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -83,7 +83,7 @@ data class DetectedSleep( /** asleep / in-bed in [0, 1] (AASM TST/TIB; asleep = in-bed − wake). */ val efficiency: Double, val stages: List, - /** Lowest 5-min rolling-mean HR during the session (bpm), or null. */ + /** The session's resting HR (bpm): mean HR across its deep-sleep segments, or null. See [SleepStager.sessionDeepSleepRestingHR]. */ val restingHR: Int?, /** Mean RMSSD over 5-min windows across the session (ms), or null. */ val avgHRV: Double?, diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 1527212d37..753ab42d03 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -3207,35 +3207,40 @@ object IntelligenceEngine { */ private fun rhrDiagLines( day: String, - rhrFloor: Int?, + restingHr: Int?, hr: List, sessions: List, ): List { - if (rhrFloor == null) return emptyList() + if (restingHr == null) return emptyList() val inBedBpms = hr.filter { s -> sessions.any { s.ts >= it.start && s.ts < it.end } }.map { it.bpm } + // The lowest-bin floor, taken the way the day took its resting HR before it became the deep-sleep mean + // (min across the night's sessions), so the two sit side by side. + val floor = sessions.mapNotNull { SleepStager.sessionRestingHR(it.start, it.end, hr) }.minOrNull() val out = ArrayList(2) - out.add(rhrFloorMeanLogLine(day, rhrFloor, inBedBpms)) - SleepStager.rhrBinGateLogLine(day, sessions.map { it.start to it.end }, hr, rhrFloor) - ?.let { out.add(it) } + out.add(rhrFloorMeanLogLine(day, restingHr, floor, inBedBpms)) + if (floor != null) { + SleepStager.rhrBinGateLogLine(day, sessions.map { it.start to it.end }, hr, floor) + ?.let { out.add(it) } + } return out } /** - * The per-day RHR floor-vs-mean diagnostic line (#691). NOOP's [floor] is the WHOOP-style resting - * HR , the lowest SUSTAINED 5-min in-bed level (SleepStager picks the min 5-min rolling-mean HR per - * session, the day takes the min across them) , whereas a "sleeping HR" app reports the night MEAN - * over the whole asleep span. The mean always sits at-or-above the floor, so NOOP reading lower is - * BY DESIGN, not a bug; logging both makes a "NOOP RHR is lower than my other app" report explainable - * from the strap log. [inBedBpms] is the bpm of every HR sample inside a matched in-bed session (the - * SAME span the floor came from, so the two numbers are directly comparable). Empty in-bed → nightMean - * is "nil". Counts/bpm only , no timestamps or PII. Pure so it's unit-tested directly and is the SAME - * line analyzeRecent ships. Byte-identical to the Swift `rhrFloorMeanLogLine`. + * The per-day resting-HR diagnostic line (#691). NOOP's resting HR ([restingHr]) is the mean HR across the + * night's deep-sleep segments ([SleepStager.sessionDeepSleepRestingHR]), the window WHOOP measures in. + * Beside it: [floor], the lowest 5-min bin, which NOOP reported as resting HR until it read ~6 bpm under + * WHOOP's; and nightMean, the mean over the whole in-bed span, which a "sleeping HR" app reports. All three + * on one line make a "NOOP reads differently from my other app" report explainable from the strap log. + * [inBedBpms] is the bpm of every HR sample inside a matched in-bed session. Empty in-bed → nightMean is + * "nil"; no floor → "nil". Counts/bpm only, no timestamps or PII. Pure so it's unit-tested directly and is + * the SAME line analyzeRecent ships. Byte-identical to the Swift `rhrFloorMeanLogLine`. */ - internal fun rhrFloorMeanLogLine(day: String, floor: Int, inBedBpms: List): String { + internal fun rhrFloorMeanLogLine(day: String, restingHr: Int, floor: Int?, inBedBpms: List): String { val meanLog = if (inBedBpms.isEmpty()) "nil" else Math.round(inBedBpms.sum().toDouble() / inBedBpms.size).toString() - return "rhr day=$day floor=$floor nightMean=$meanLog inBedSamples=${inBedBpms.size} " + - "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)" + return "rhr day=$day rhr=$restingHr floor=${floor ?: "nil"} nightMean=$meanLog " + + "inBedSamples=${inBedBpms.size} " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)" } /** diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index 30202f6f57..344bbb78aa 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -59,6 +59,12 @@ object SleepStager { /** Minimum plausible mean HR (bpm) for a bin to qualify. A dropout-driven sub-physiological * dip cannot become the floor. */ const val rhrMinPlausibleBpm: Double = 25.0 + /** + * 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]. Twin of + * Swift `SleepStager.rhrMinDeepSleepSamples`. + */ + const val rhrMinDeepSleepSamples: Int = 300 // ── Stage 0 constants (sleep.py) ───────────────────────────────────────── @@ -781,7 +787,7 @@ object SleepStager { // (#1879), so it is a quality marker now rather than a delete. Its one reader today, // `TodayScreen.showsHrOnlyNote`, is gated on a vital ACTUALLY being blank, so the note // explaining the blanks retires itself on the nights this change fills in. - 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, ) @@ -1769,15 +1775,21 @@ object SleepStager { bandSleepState = bandSleepState) val eff = efficiency(start = p.start, end = p.end, stages = stages) val avgHrv = sessionAvgHRV(start = p.start, end = p.end, rr = rrS) + val reportedRestingHR = sessionDeepSleepRestingHR(start = p.start, end = p.end, hr = hrS, stages = stages) sessions.add( DetectedSleep( 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?.invoke(SleepStagerTrace.runLine(runIndex, p.start, p.end, SleepStagerTrace.Verdict.KEPT, "accepted", - "spanMin=$spanMin eff=${SleepStagerTrace.round2(eff)} restingHR=${resting ?: -1} daytime=$isDaytime")) + "spanMin=$spanMin eff=${SleepStagerTrace.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 @@ -3228,15 +3240,64 @@ object SleepStager { * silently ignored. A zero-length window (`start == end`) is that single closed bin. */ internal fun sessionRestingHR(start: Long, end: Long, hr: List): 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. Not the night's resting HR any more (see + // [sessionDeepSleepRestingHR]); it remains the daytime false-sleep guard's resting-HR dip test. + val bins = restingBinMeans(start, end, hr) ?: return null + bins.gated.minOrNull()?.let { return it.roundToInt() } + bins.all.minOrNull()?.let { return it.roundToInt() } + return bins.sampleMean.roundToInt() + } + + /** + * The night's resting HR: the mean of the plausible HR samples inside its deep-sleep segments, the window + * WHOOP measures resting HR in and NOOP pools its WHOOP-style HRV over (#141). The lowest 5-min bin it + * replaces is the night's single calmest stretch, not a resting level, and read ~6 bpm under WHOOP's. + * Fewer than [rhrMinDeepSleepSamples] deep-sleep samples falls back to [sessionLowQuartileRestingHR]. + * Twin of Swift `SleepStager.sessionDeepSleepRestingHR`. + */ + internal fun sessionDeepSleepRestingHR(start: Long, end: Long, hr: List, stages: List): Int? { + val deep = stages.filter { it.stage == "deep" && it.end > it.start } + var sum = 0L + var n = 0 + if (deep.isNotEmpty()) { + for (s in hr) { + if (s.ts in start..end && s.bpm.toDouble() >= rhrMinPlausibleBpm && + deep.any { s.ts >= it.start && s.ts < it.end }) { + sum += s.bpm; n += 1 + } + } + } + if (n >= rhrMinDeepSleepSamples) return (sum.toDouble() / n.toDouble()).roundToInt() + return sessionLowQuartileRestingHR(start, end, 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. No qualifying bin falls back to [sessionRestingHR]. Twin of Swift + * `SleepStager.sessionLowQuartileRestingHR`. + */ + internal fun sessionLowQuartileRestingHR(start: Long, end: Long, hr: List): Int? { + val bins = restingBinMeans(start, end, hr) ?: return null + val gated = bins.gated.sorted() + if (gated.isEmpty()) return sessionRestingHR(start, end, hr) + return gated[gated.size / 4].roundToInt() + } + + private class RestingBins(val gated: List, val all: List, val sampleMean: Double) + + /** + * 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. null when the session holds no + * samples. Twin of Swift `SleepStager.restingBinMeans`. + */ + private fun restingBinMeans(start: Long, end: Long, hr: List): RestingBins? { val seg = hr.filter { it.ts in start..end } if (seg.isEmpty()) return null val windowS = 5 * 60L - // #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. val gatedMeans = ArrayList() val allMeans = ArrayList() var t = start @@ -3254,12 +3315,7 @@ object SleepStager { } t += windowS } while (t < end) - val m = gatedMeans.minOrNull() - if (m != null) return m.roundToInt() - val am = allMeans.minOrNull() - if (am != null) return am.roundToInt() - val all = seg.sumOf { it.bpm }.toDouble() / seg.size.toDouble() - return all.roundToInt() + return RestingBins(gatedMeans, allMeans, seg.sumOf { it.bpm }.toDouble() / seg.size.toDouble()) } /** One 5-min HRV window: its start ts, the sleep stage at its center, the clean-beat count, and the diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt index 9cf0cd7c72..61810d4507 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt @@ -87,6 +87,13 @@ object HealthConnectWriter { SleepSessionRecord::class, ) + /** + * The first day the daily vitals export covers: [WINDOW_DAYS] back from [today], or null (every computed + * day) when a history rewrite is owed after the resting-HR rescore. + */ + internal fun dailyCutoff(historyRewrite: Boolean, today: LocalDate): String? = + if (historyRewrite) null else today.minusDays(WINDOW_DAYS).toString() + /** The write-permission strings the UI must request before calling [write]. */ val PERMISSIONS: Set = WRITE_RECORDS.map { HealthPermission.getWritePermission(it) }.toSet() @@ -120,10 +127,13 @@ object HealthConnectWriter { // Guard the pre-insert work (client acquisition + the day read) the same way the concern inserts // below are guarded, so a provider race or DB error can't throw PAST recordStatus and leave the // UI showing a stale "OK" while sharing is actually broken (#660). Cancellation still propagates. + // After the resting-HR rescore, reach past the rolling window once so every computed day is upserted + // with the recomputed value (same clientRecordId, higher version, so no delete is needed). + val historyRewrite = NoopPrefs.hcRestingHrRewriteOwed(context) val (client, days) = runCatching { val c = HealthConnectClient.getOrCreate(context) - val cutoff = LocalDate.now().minusDays(WINDOW_DAYS).toString() - c to repo.days(repo.computedDeviceId(deviceId)).filter { it.day >= cutoff } + val cutoff = dailyCutoff(historyRewrite, LocalDate.now()) + c to repo.days(repo.computedDeviceId(deviceId)).filter { cutoff == null || it.day >= cutoff } }.getOrElse { t -> val result = WritebackResult(0, listOf(t.writebackCategory())) recordStatus(context, result) @@ -185,6 +195,8 @@ object HealthConnectWriter { runCatching { client.insertRecords(records); records.size } .fold({ total += it }, { failures += it.writebackCategory() }) } + // Cleared only once the daily records went in: a failed insert keeps the rewrite owed for the next export. + if (historyRewrite && failures.isEmpty()) NoopPrefs.setHcRestingHrRewriteOwed(context, false) runCatching { writeHeartRate(client, context, repo, deviceId, version) } .fold({ total += it }, { failures += it.writebackCategory() }) runCatching { writeSleep(client, context, repo, deviceId) } diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index d66d6d0f4e..780a50b158 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1160,6 +1160,25 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { ownerSource = RegistryDayOwnerSource(noopApp.deviceRegistry), ) }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } + // 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. Same pass as the Effort rescore above. + runCatching { + IntelligenceEngine.runEffortRescoreIfNeeded( + repo = repository, + profile = currentProfile(), + importedDeviceId = deviceId, + maxHROverride = profileStore.hrMaxOverride.takeIf { it > 0 }?.toDouble(), + flagGet = { NoopPrefs.restingHrRescoreDone(appContext) }, + flagSet = { + NoopPrefs.setRestingHrRescoreDone(appContext) + // Health Connect then replaces the resting HR it was given beyond its rolling window. + NoopPrefs.setHcRestingHrRewriteOwed(appContext, true) + }, + // #1567: this rewrites the FULL history once, so a missing owner source would bake the + // WHOOP5 skin-temp scale into every day of it. + ownerSource = RegistryDayOwnerSource(noopApp.deviceRegistry), + ) + }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } while (isActive) { // #547 RE-POLLUTION: a sync since the last tick may have flagged a re-heal (its ingest gate // dropped bad-clock records). Re-run the purge BEFORE this tick's rescore so the affected days diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index ba4d978eff..744518ad3b 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1412,6 +1412,29 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_EFFORT_RESCORE_DONE, true).apply() } + /** Whether the one-shot full-history rescore that moved every computed night's resting HR from the lowest + * 5-min bin to the deep-sleep mean has run. Twin of Swift `IntelligenceEngine.restingHRRescoreFlagKey`. */ + const val KEY_RESTING_HR_RESCORE_DONE = "noop.restingHRDeepSleepRescore.v1.done" + + fun restingHrRescoreDone(context: Context): Boolean = + of(context).getBoolean(KEY_RESTING_HR_RESCORE_DONE, false) + + fun setRestingHrRescoreDone(context: Context) { + of(context).edit().putBoolean(KEY_RESTING_HR_RESCORE_DONE, true).apply() + } + + /** Set when the resting-HR rescore completes; the Health Connect export then reaches past its rolling + * window once, so days older than it are upserted with the recomputed resting HR, and clears it. Twin of + * Swift `IntelligenceEngine.restingHRHealthRewriteOwedKey`. */ + const val KEY_HC_RESTING_HR_REWRITE_OWED = "noop.hc.restingHRRewriteOwed.v1" + + fun hcRestingHrRewriteOwed(context: Context): Boolean = + of(context).getBoolean(KEY_HC_RESTING_HR_REWRITE_OWED, false) + + fun setHcRestingHrRewriteOwed(context: Context, owed: Boolean) { + of(context).edit().putBoolean(KEY_HC_RESTING_HR_REWRITE_OWED, owed).apply() + } + /** Whether the one-shot #547 implausible-timestamp heal has run. Set true once it completes so the * on-upgrade purge of bad-strap-clock rows (far-past / future-dated) never re-runs. Re-running is * harmless (the deletes are idempotent), but the flag avoids the work on every launch. */ diff --git a/android/app/src/test/java/com/noop/analytics/IntelligenceRhrFloorMeanTest.kt b/android/app/src/test/java/com/noop/analytics/IntelligenceRhrFloorMeanTest.kt index 0e42998a73..7c58e168ee 100644 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceRhrFloorMeanTest.kt +++ b/android/app/src/test/java/com/noop/analytics/IntelligenceRhrFloorMeanTest.kt @@ -6,60 +6,43 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Pins the RHR floor-vs-mean strap-log line (#691). The recurring "NOOP's resting HR reads LOWER than - * my sleeping-HR app" reports are NOT a bug: NOOP's restingHr is the WHOOP-style FLOOR (the lowest - * sustained 5-min in-bed level), whereas a "sleeping HR" app reports the night MEAN over the whole - * asleep span. The mean always sits at-or-above the floor, so NOOP looking lower is by design. The - * engine now logs BOTH per scored night so a report carries the proof. `rhrFloorMeanLogLine` is the - * pure formatter the loop calls; it's tested directly. Mirrors the Swift `IntelligenceRhrFloorMeanTests` - * so the two platforms log byte-identical lines. + * Pins the resting-HR strap-log line (#691). NOOP's restingHr is the deep-sleep mean HR; the line carries it + * beside the lowest 5-min bin (floor, what NOOP reported until it read ~6 bpm under WHOOP's) and the whole + * in-bed mean a "sleeping HR" app reports. Mirrors the Swift `IntelligenceRhrFloorMeanTests` so the two + * platforms log byte-identical lines. */ class IntelligenceRhrFloorMeanTest { @Test - fun floorBelowMean_theReportedDiscrepancy() { - // The exact shape of the reports: an in-bed stretch that dips to a 48 bpm floor but averages 55. - val bpms = listOf(48, 50, 52, 55, 58, 60, 62) // mean = 55.0 → "55" - val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 48, bpms) + fun allThreeStatisticsShipOnOneLine() { + val bpms = listOf(48, 50, 52, 55, 58, 60, 62) // mean = 55.0 → "55" + val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 53, 48, bpms) assertEquals( - "rhr day=2026-06-12 floor=48 nightMean=55 inBedSamples=7 " + - "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)", + "rhr day=2026-06-12 rhr=53 floor=48 nightMean=55 inBedSamples=7 " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)", line, ) } @Test fun meanRoundsToNearest() { - // 50,51,52,54 → 207/4 = 51.75 → rounds to 52, matching Swift .rounded(). - val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-13", 50, listOf(50, 51, 52, 54)) - assertTrue(line, line.contains("floor=50 nightMean=52 inBedSamples=4")) + val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-13", 51, 50, listOf(50, 51, 52, 54)) + assertTrue(line, line.contains("rhr=51 floor=50 nightMean=52 inBedSamples=4")) } @Test - fun emptyInBed_meanIsNil() { - // A banked floor but no HR sample fell inside a matched session: mean reads "nil", not 0, and - // the line is still emitted so the night stays visible in the log. - val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 47, emptyList()) + fun emptyInBedAndMissingFloorReadNil() { + val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 47, null, emptyList()) assertEquals( - "rhr day=2026-06-12 floor=47 nightMean=nil inBedSamples=0 " + - "(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number)", + "rhr day=2026-06-12 rhr=47 floor=nil nightMean=nil inBedSamples=0 " + + "(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span)", line, ) } - @Test - fun floorNeverExceedsMean_byConstruction() { - // Sanity on the framing: across any in-bed set the floor (a min over the same span) is <= the - // mean, so NOOP's RHR can only read at-or-below a sleeping-HR-app's night mean. - val bpms = listOf(44, 46, 49, 53, 57, 61) - val mean = bpms.sum().toDouble() / bpms.size - assertTrue(bpms.min().toDouble() <= mean) - } - @Test fun lineCarriesNoEmDash() { - // House style: never an em-dash in shared text. - val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 48, listOf(48, 60)) + val line = IntelligenceEngine.rhrFloorMeanLogLine("2026-06-12", 50, 48, listOf(48, 60)) assertFalse(line.contains("—")) } } diff --git a/android/app/src/test/java/com/noop/analytics/SleepStagerDeepSleepRestingHRTest.kt b/android/app/src/test/java/com/noop/analytics/SleepStagerDeepSleepRestingHRTest.kt new file mode 100644 index 0000000000..41353c2f69 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepStagerDeepSleepRestingHRTest.kt @@ -0,0 +1,51 @@ +package com.noop.analytics + +import com.noop.data.HrSample +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The night's resting HR is the mean HR across its deep-sleep segments, not its single calmest 5-min bin. + * Twin of Swift `SleepStagerDeepSleepRestingHRTests`. + */ +class SleepStagerDeepSleepRestingHRTest { + + private val start = 1_000_000L + private val dev = "test" + + private fun samples(from: Long, to: Long, bpm: Int) = (from until to).map { HrSample(deviceId = dev, ts = it, 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 val hr = samples(start, start + 600, 60) + samples(start + 600, start + 1_800, 55) + + samples(start + 1_800, start + 2_100, 48) + samples(start + 2_100, start + 2_400, 60) + private val stages = listOf( + StageSegment(start, start + 600, "light"), + StageSegment(start + 600, start + 1_800, "deep"), + StageSegment(start + 1_800, start + 2_400, "light"), + ) + private val end = start + 2_400 + + @Test + fun theRestingHrIsTheDeepSleepMeanNotTheLowestBin() { + assertEquals(55, SleepStager.sessionDeepSleepRestingHR(start, end, hr, stages)) + assertEquals(48, SleepStager.sessionRestingHR(start, end, hr)) + } + + @Test + fun implausibleSamplesDoNotPullTheDeepSleepMeanDown() { + assertEquals(55, SleepStager.sessionDeepSleepRestingHR(start, end, samples(start + 600, start + 660, 0) + hr, stages)) + } + + @Test + fun tooLittleDeepSleepFallsBackToTheLowerQuartileBin() { + val briefDeep = listOf(StageSegment(start + 600, start + 840, "deep")) + assertEquals(55, SleepStager.sessionDeepSleepRestingHR(start, end, hr, briefDeep)) + assertEquals(55, SleepStager.sessionDeepSleepRestingHR(start, end, hr, emptyList())) + } + + @Test + fun aSessionWithNoSamplesHasNoRestingHr() { + assertNull(SleepStager.sessionDeepSleepRestingHR(start, start + 600, emptyList(), stages)) + } +} diff --git a/android/app/src/test/java/com/noop/ingest/HealthConnectRestingHrRewriteTest.kt b/android/app/src/test/java/com/noop/ingest/HealthConnectRestingHrRewriteTest.kt new file mode 100644 index 0000000000..e07c79a200 --- /dev/null +++ b/android/app/src/test/java/com/noop/ingest/HealthConnectRestingHrRewriteTest.kt @@ -0,0 +1,24 @@ +package com.noop.ingest + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.LocalDate + +/** + * After the resting-HR rescore, the Health Connect export reaches past its rolling window once, so days older + * than it are upserted with the recomputed value instead of keeping the lowest-bin figure they were given. + */ +class HealthConnectRestingHrRewriteTest { + private val today = LocalDate.parse("2026-09-19") + + @Test + fun theRollingExportCoversSixtyDays() { + assertEquals("2026-07-21", HealthConnectWriter.dailyCutoff(historyRewrite = false, today = today)) + } + + @Test + fun anOwedRewriteCoversEveryComputedDay() { + assertNull(HealthConnectWriter.dailyCutoff(historyRewrite = true, today = today)) + } +} diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index 50e2b6031c..e05850e079 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -331,7 +331,9 @@ Consecutive same-stage epochs are merged into `StageSegment`s tiling `[start, en ### Per-session resting HR and HRV -`sessionRestingHR` is the **minimum of 5-minute non-overlapping bin means** of the HR samples in `[start, end]` — "lowest sustained HR", which rejects single-beat dips while capturing the night's true floor. A bin qualifies to win only when it holds at least 5 samples and its mean is at least 25 bpm (#1943), so 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. When no bin qualifies, the floor falls back to the lowest of all bin means (ungated), then the all-sample mean, so a session with data never scores nil. `sessionHrvWindows` tumbles the same 5-minute grid over the RR series, cleans each bucket (range filter + Malik ectopic rejection, gap-aware) and emits one window per bin tagged with the stage at its center; `sessionAvgHRV` is the mean of those window RMSSDs. These two are the shipped source of the per-session `restingHR` / `avgHRV`, of the HRV nightly trace and of the last-deep-run comparator. +The session's resting HR is `sessionDeepSleepRestingHR`: the **mean of the HR samples inside its deep-sleep segments** (samples under 25 bpm excluded), the slow-wave window WHOOP measures resting HR in and the same window NOOP's WHOOP-style HRV pools over. It needs at least 300 deep-sleep samples (~5 min at 1 Hz); below that it falls back to `sessionLowQuartileRestingHR`, the lower quartile of the qualifying 5-minute bin means. + +It replaced `sessionRestingHR`, the **minimum of 5-minute non-overlapping bin means** ("lowest sustained HR"), which is the night's single calmest stretch rather than a resting level. On one WHOOP 5.0 wearer's 23 nights the minimum averaged 48.9 bpm against the 55.5 bpm WHOOP reported the month before, while the deep-sleep mean averaged 54.9 with a night-to-night spread (SD 3.1) close to WHOOP's (3.6). The minimum still drives the daytime false-sleep guard's resting-HR dip test and the strap log's `floor` field. A bin qualifies there only when it holds at least 5 samples and its mean is at least 25 bpm (#1943); with no qualifying bin it falls back to the lowest of all bin means (ungated), then the all-sample mean, so a session with data never scores nil. A one-shot full-history rescore (`restingHRRescoreFlagKey`) moves stored nights to the new statistic, and on iOS the Apple Health write-back then replaces the resting HR it had written. `sessionHrvWindows` tumbles the same 5-minute grid over the RR series, cleans each bucket (range filter + Malik ectopic rejection, gap-aware) and emits one window per bin tagged with the stage at its center; `sessionAvgHRV` is the mean of those window RMSSDs. These two are the shipped source of the per-session `restingHR` / `avgHRV`, of the HRV nightly trace and of the last-deep-run comparator. **Window endpoint rule.** The session window is closed at both ends, so the binning is too: bins are `[t, t + 300)` except the last one, which is `[t, end]`. A sample or beat sitting exactly on an aligned `end` passes the `[start, end]` prefilter, so it must land in a bin rather than be admitted and then dropped. A zero-length window (`start == end`) is that single closed bin. Bin start times, the stage-tagging center and the RMSSD math are unaffected. diff --git a/docs/FAQ.md b/docs/FAQ.md index 397e48f4c0..ed2132a0e0 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -6,28 +6,25 @@ Answers to the questions that come up most often in issues. If your question isn --- -## Why is NOOP's resting heart rate lower than the WHOOP app's? +## Why does NOOP's resting heart rate differ from the WHOOP app's? -**Because they are different statistics over the same night, and the gap is expected.** +**It should now sit within a couple of bpm.** NOOP's resting HR is your **mean heart rate across the night's +deep (slow-wave) sleep**, the window WHOOP measures in. NOOP's sleep staging is its own approximation, so +the deep-sleep windows it picks are not WHOOP's exactly, and a small gap either way is expected. -NOOP's resting HR is the **lowest sustained level** during your in-bed window — the minimum of the -night's 5-minute non-overlapping bin means. That rejects single-beat dips while capturing the night's -true floor. +Earlier versions reported the **lowest 5-minute average** of the night instead. That is the single calmest +stretch, not a resting level, and read about 6–8 bpm under WHOOP's. Updating recomputes your stored nights +once, and on iPhone replaces the resting HR NOOP wrote to Apple Health. -The WHOOP app's figure sits closer to the **whole-night average**, which is naturally a few bpm -higher. Typical reported gaps are 5–8 bpm. - -Neither number is wrong. NOOP logs both side by side so you can check it yourself — look for this -line in your strap log: +Your strap log carries all three figures for each night: ``` -rhr day=2026-07-16 floor=44 nightMean=50 inBedSamples=30247 -(floor = WHOOP-style lowest-sustained = NOOP RHR; mean = sleeping-HR-app number) +rhr day=2026-09-15 rhr=55 floor=48 nightMean=57 inBedSamples=33536 +(rhr = deep-sleep mean = NOOP RHR; floor = lowest 5-min bin; mean = whole in-bed span) ``` -`floor` is what NOOP shows you; `nightMean` is the number closer to what the WHOOP app displays. If -the difference between those two is roughly the difference you're seeing between the apps, there is -nothing wrong with your data. +`rhr` is what NOOP shows you. `nightMean` is the whole-night average a sleeping-heart-rate app reports, and +`floor` is the old lowest-5-minute figure. ## Why is my HRV different from the WHOOP app's?