From a0e48e2cc58663a8d41d3bfa5c9109db18b900f5 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Wed, 16 Sep 2026 21:03:02 +0300 Subject: [PATCH 1/5] fix(rhr): report resting HR as the deep-sleep mean, not the night's lowest 5-min bin The night's resting HR was the minimum of its 5-min bin means: the single calmest stretch of the night, not 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 (56.9 over its last 60 days). The value is displayed, stored on the daily row, exported to Apple Health, used for Effort's heart-rate reserve, and fed to the recovery baseline, whose imported WHOOP history sat ~8 bpm above every computed night and read each one as an unusually low resting HR. It is now the mean of the plausible HR samples inside the night's deep-sleep segments (`SleepStager.sessionDeepSleepRestingHR`): the slow-wave window WHOOP measures in, and the one NOOP's WHOOP-style HRV already pools over (#141). On the same nights it averaged 54.9 bpm with a night-to-night spread (SD 3.1) close to WHOOP's (3.6); the whole-night mean read 58.1 (SD 2.2) and the last deep run alone 54.2 (SD 4.4). Under 300 deep-sleep samples it falls back to the lower quartile of the qualifying 5-min bins. The lowest bin is kept for the daytime false-sleep guard, whose resting-HR dip thresholds were tuned against it, and the binning is shared rather than copied. Stored nights move with it: a one-shot full-history rescore reuses the Effort rescore pass under its own flag, and on iOS the Apple Health write-back then deletes the resting HR it wrote since the first computed night (our own source only) and writes the recomputed values over that span. The strap-log line now carries `rhr=` (what NOOP reports) beside `floor=` (the lowest bin) and `nightMean=`, and the #1943 bin-gate check compares against the floor it actually tests. Kotlin twin included; FAQ and ANALYTICS.md updated. --- .../StrandAnalytics/AnalyticsEngine.swift | 3 +- .../Sources/StrandAnalytics/SleepStager.swift | 81 +++++++++++++++---- .../SleepStagerDeepSleepRestingHRTests.swift | 54 +++++++++++++ Strand/App/AppModel.swift | 6 ++ Strand/Data/IntelligenceEngine.swift | 68 +++++++++++----- .../IntelligenceRhrFloorMeanTests.swift | 47 +++++------ StrandiOS/Health/HealthKitBridge.swift | 36 ++++++++- Tools/parity_dispositions.json | 8 ++ .../com/noop/analytics/AnalyticsEngine.kt | 2 +- .../com/noop/analytics/AnalyticsModels.kt | 2 +- .../com/noop/analytics/IntelligenceEngine.kt | 39 +++++---- .../java/com/noop/analytics/SleepStager.kt | 80 ++++++++++++++---- .../src/main/java/com/noop/ui/AppViewModel.kt | 15 ++++ .../src/main/java/com/noop/ui/MainActivity.kt | 11 +++ .../analytics/IntelligenceRhrFloorMeanTest.kt | 49 ++++------- .../SleepStagerDeepSleepRestingHRTest.kt | 51 ++++++++++++ docs/ANALYTICS.md | 4 +- docs/FAQ.md | 27 +++---- 18 files changed, 434 insertions(+), 149 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/SleepStagerDeepSleepRestingHRTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/SleepStagerDeepSleepRestingHRTest.kt 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..17f4d6a1a0 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)) } @@ -1589,7 +1594,10 @@ public enum SleepStager { let eff = efficiency(start: p.start, end: p.end, stages: stages) let avgHrv = sessionAvgHRV(start: p.start, end: p.end, rr: rrS) sessions.append(SleepSession(start: p.start, end: p.end, efficiency: eff, - stages: stages, restingHR: resting, avgHRV: avgHrv)) + stages: stages, + restingHR: sessionDeepSleepRestingHR(start: p.start, end: p.end, + hr: hrS, stages: stages), + avgHRV: avgHrv)) 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)")) @@ -2901,16 +2909,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 +2984,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 +597,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 +619,20 @@ 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 } + /// + /// `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 } 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) } + guard !computing else { return false } + UserDefaults.standard.set(true, forKey: flagKey) + return true } /// UserDefaults flag guarding the one-shot #547 implausible-timestamp DB heal (below). Set once the @@ -1659,17 +1675,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. 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..3e2a8cafdb 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,32 @@ 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. + 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: []), + ]) + _ = try? await store.deleteObjects(of: type, predicate: pred) + } + 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..76d51efd84 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -24,6 +24,14 @@ "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." } ] } 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..72adc611ef 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, ) @@ -1772,7 +1778,9 @@ object SleepStager { sessions.add( DetectedSleep( start = p.start, end = p.end, efficiency = eff, - stages = stages, restingHR = resting, avgHRV = avgHrv, + stages = stages, + restingHR = sessionDeepSleepRestingHR(start = p.start, end = p.end, hr = hrS, stages = stages), + avgHRV = avgHrv, ) ) traceSink?.invoke(SleepStagerTrace.runLine(runIndex, p.start, p.end, @@ -3228,15 +3236,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 +3311,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/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index d66d6d0f4e..a5d5bb94b3 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,21 @@ 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) }, + // #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..0364df242b 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,17 @@ 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() + } + /** 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/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? From f08b41b621d0cba9e1fc8b88eb589d00706ebc1a Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 10:45:44 +0300 Subject: [PATCH 2/5] fix(rhr): wait for a running pass before the one-shot resting-HR rescore instead of retrying next launch --- Strand/Data/IntelligenceEngine.swift | 34 ++++++++++++++----- .../IntelligenceOneShotRescoreTests.swift | 28 +++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 StrandTests/IntelligenceOneShotRescoreTests.swift diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index a8b5fc7310..660bfdab00 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -49,6 +49,11 @@ final class IntelligenceEngine: ObservableObject { /// Uptime the pass holding `computing` started at, and how many days it covers; nil when none is running. private var runningPassStart: UInt64? private var runningPassDays = 0 + /// How many `analyzeRecent` passes have run to completion in this process, and how many days the latest + /// one covered, so a caller that needs a particular pass can tell whether its call did the work or + /// found the lock taken (`runEffortRescoreIfNeeded`). + private var completedPasses = 0 + private var lastCompletedPassDays = 0 /// #899 heal bound: true while the last heal already re-armed a rescore, so a heal firing again on /// the very next pass cannot re-arm a second time (the Android twin is hard-bounded to exactly one /// re-pass; this mirrors it). Reset by any pass whose heal finds nothing, restoring the budget. @@ -625,14 +630,25 @@ final class IntelligenceEngine: ObservableObject { @discardableResult func runEffortRescoreIfNeeded(historyDays: Int = 4000, flagKey: String = effortRescoreFlagKey) async -> Bool { guard !UserDefaults.standard.bool(forKey: flagKey) else { return false } - 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. - guard !computing else { return false } - UserDefaults.standard.set(true, forKey: flagKey) - return true + // 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 @@ -2923,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") + } +} From f3d571a8bed6dff514eeb849ca46f1054dde11e8 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Sat, 19 Sep 2026 10:37:43 +0300 Subject: [PATCH 3/5] fix(rhr): Android Health Connect replaces resting HR past its 60-day window once the rescore has run --- Tools/parity_dispositions.json | 8 +++++++ .../com/noop/ingest/HealthConnectWriter.kt | 16 +++++++++++-- .../src/main/java/com/noop/ui/AppViewModel.kt | 6 ++++- .../src/main/java/com/noop/ui/MainActivity.kt | 12 ++++++++++ .../HealthConnectRestingHrRewriteTest.kt | 24 +++++++++++++++++++ 5 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ingest/HealthConnectRestingHrRewriteTest.kt diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 76d51efd84..8c8aaa21c1 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -32,6 +32,14 @@ "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/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 a5d5bb94b3..780a50b158 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1169,7 +1169,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { importedDeviceId = deviceId, maxHROverride = profileStore.hrMaxOverride.takeIf { it > 0 }?.toDouble(), flagGet = { NoopPrefs.restingHrRescoreDone(appContext) }, - flagSet = { NoopPrefs.setRestingHrRescoreDone(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), 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 0364df242b..744518ad3b 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1423,6 +1423,18 @@ object NoopPrefs { 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/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)) + } +} From e27dbc348b6abf16113c516194a9d1eca8130552 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Mon, 21 Sep 2026 12:59:48 +0300 Subject: [PATCH 4/5] rhr: gate trace prints the reported resting HR beside the floor; a failed one-time Health delete keeps the owed flag The accepted-run trace printed the lowest bin as restingHR while the session reports the deep-sleep mean, so a night NOOP reports as 55 traced as 48. It now prints both, named like the rhr day= line. The one-time resting-HR delete in Apple Health was try?, so a failure still cleared the flag and the cleanup never ran again; it now throws (a no-match is not a failure). --- .../Sources/StrandAnalytics/SleepStager.swift | 9 ++++++--- StrandiOS/Health/HealthKitBridge.swift | 10 ++++++++-- .../src/main/java/com/noop/analytics/SleepStager.kt | 8 ++++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 17f4d6a1a0..0eed082a2d 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -1593,14 +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: sessionDeepSleepRestingHR(start: p.start, end: p.end, - hr: hrS, 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 diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index 3e2a8cafdb..9432b5dfad 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -902,7 +902,9 @@ final class HealthKitBridge: ObservableObject { /// 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. + /// 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", @@ -918,7 +920,11 @@ final class HealthKitBridge: ObservableObject { HKQuery.predicateForObjects(from: HKSource.default()), HKQuery.predicateForSamples(withStart: Calendar.current.startOfDay(for: from), end: Date(), options: []), ]) - _ = try? await store.deleteObjects(of: type, predicate: pred) + 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) } 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 72adc611ef..344bbb78aa 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -1775,17 +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 = sessionDeepSleepRestingHR(start = p.start, end = p.end, hr = hrS, 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 From ec94cb9a2dd458163e2bc6c32769e8e4cb50fd64 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Mon, 21 Sep 2026 13:04:19 +0300 Subject: [PATCH 5/5] chore(parity): refresh derived snapshots --- Tools/parity_twin_map.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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"} }