diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 5372f21c1b..ccd97af1fe 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -176,6 +176,49 @@ public enum HealthWriteback { "noop:\(kind):\(identity)" } + /// When each day's nightly vitals are stamped: the midpoint of the LONGEST night whose wake falls on + /// that day. `dayOf` maps a unix second to the day string the vitals rows are keyed by. + /// + /// Inside the night, not on its edge. The values are nightly aggregates, and a reader of Health picks + /// them by the sleep window they describe — Bevel shows no Recovery when HRV or resting HR is not + /// captured during the sleep window. A sample stamped exactly at wake sits on that boundary. The + /// longest night rather than the latest, so a nap after the main sleep does not carry the night's + /// values out of the night they came from. + /// + /// The midpoint of a bridged night can fall in the awake gap between its fragments, or in an awake + /// segment of one. It is still inside the `.inBed` sample, but a reader that keys off asleep samples + /// would not see it, so an instant that lands outside every asleep interval moves to the nearest + /// asleep second. A night whose stages carry no timing has only `.unspecified` asleep intervals and + /// keeps its midpoint. + public static func vitalsInstantByDay(_ entries: [MergedSleepEntry], + dayOf: (Int) -> String) -> [String: Int] { + var longest: [String: MergedSleepEntry] = [:] + for entry in entries where entry.spanEnd > entry.spanStart { + let day = dayOf(entry.spanEnd) + if let current = longest[day], + current.spanEnd - current.spanStart >= entry.spanEnd - entry.spanStart { continue } + longest[day] = entry + } + return longest.mapValues { entry in + let mid = entry.spanStart + (entry.spanEnd - entry.spanStart) / 2 + let asleep = entry.intervals.filter { $0.kind != .awake && $0.end > $0.start } + if asleep.isEmpty || asleep.contains(where: { $0.start <= mid && mid < $0.end }) { return mid } + // Closest second inside each asleep interval; the nearest wins, the earlier on a tie. A plain loop: + // the equivalent `map { }.min { }` chain exceeded the type-checker's budget on CI. + var best = mid + var bestDistance = Int.max + for interval in asleep { + let candidate: Int = min(max(mid, interval.start), interval.end - 1) + let distance: Int = abs(candidate - mid) + if distance < bestDistance || (distance == bestDistance && candidate < best) { + best = candidate + bestDistance = distance + } + } + return best + } + } + /// The vitals key: `noop::`. public static func appleHealthVitalKey(metricId: String, day: String) -> String { appleHealthExternalUUID(kind: metricId, identity: day) diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index db0be640c3..e91b9cdabe 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -339,4 +339,56 @@ final class HealthWritebackTests: XCTestCase { swept = HealthWriteback.strandedSweepResult(swept: swept, succeededThisRun: ["sleepAnalysis"]) XCTAssertEqual(swept, ["restingHeartRate", "sleepAnalysis"]) } + + // MARK: - Vitals stamp inside the night + + private func entry(_ spanStart: Int, _ spanEnd: Int) -> HealthWriteback.MergedSleepEntry { + .init(keyStartTs: spanStart, spanStart: spanStart, spanEnd: spanEnd, intervals: [], allKeyStartTs: [spanStart]) + } + + /// Day = whole days since `start`, so attribution is by wake and independent of the test host's zone. + private func dayOf(_ ts: Int) -> String { "d\((ts - start) / 86_400)" } + + func testVitalsAreStampedAtTheNightsMidpointNotItsWake() { + let night = entry(start, start + 8 * 3600) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night], dayOf: dayOf), ["d0": start + 4 * 3600]) + } + + /// A nap that wakes later the same day must not pull the night's values out of the night. + func testTheLongestNightOwnsTheDayNotTheLatest() { + let night = entry(start, start + 8 * 3600) + let nap = entry(start + 14 * 3600, start + 15 * 3600) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night, nap], dayOf: dayOf), ["d0": start + 4 * 3600]) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([nap, night], dayOf: dayOf), ["d0": start + 4 * 3600]) + } + + func testEachDayIsStampedByTheNightThatWokeOnIt() { + let first = entry(start, start + 8 * 3600) + let second = entry(start + 86_400, start + 86_400 + 6 * 3600) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([first, second], dayOf: dayOf), + ["d0": start + 4 * 3600, "d1": start + 86_400 + 3 * 3600]) + } + + /// A bridged night whose midpoint falls in the awake gap between fragments is stamped at the nearest + /// asleep second instead. + func testAMidpointInABridgedWakeGapMovesToTheNearestAsleepSecond() { + let bridged = HealthWriteback.MergedSleepEntry( + keyStartTs: start, spanStart: start, spanEnd: start + 8 * 3600, + intervals: [.init(start: start, end: start + 3 * 3600, kind: .light), + .init(start: start + 3 * 3600, end: start + 4 * 3600 + 1800, kind: .awake), + .init(start: start + 4 * 3600 + 1800, end: start + 8 * 3600, kind: .deep)], + allKeyStartTs: [start, start + 4 * 3600 + 1800]) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([bridged], dayOf: dayOf), ["d0": start + 4 * 3600 + 1800]) + } + + func testAMidpointInsideAnAsleepIntervalStays() { + let night = HealthWriteback.MergedSleepEntry( + keyStartTs: start, spanStart: start, spanEnd: start + 8 * 3600, + intervals: [.init(start: start, end: start + 8 * 3600, kind: .unspecified)], allKeyStartTs: [start]) + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([night], dayOf: dayOf), ["d0": start + 4 * 3600]) + } + + func testANightWithNoSpanStampsNothing() { + XCTAssertEqual(HealthWriteback.vitalsInstantByDay([entry(start, start)], dayOf: dayOf), [:]) + } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index bcb3b8bfab..dde5daa757 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -760,7 +760,7 @@ final class HealthKitBridge: ObservableObject { /// Write NOOP's strap-derived data into Apple Health: sleep sessions with full stage segments, /// the continuous 1-minute heart-rate stream, strap/manual workouts, and the nightly vitals - /// (resting HR, HRV, SpO₂, respiratory rate) stamped at that day's wake time. + /// (resting HR, HRV, SpO₂, respiratory rate) stamped at the midpoint of that day's night. /// /// Each feature saves independently and guards on ITS OWN type's share status, so one declined /// Health checkbox (or a save error) skips that feature without sinking the rest; the first error @@ -780,7 +780,7 @@ final class HealthKitBridge: ObservableObject { let fromTs = Int(fromDate.timeIntervalSince1970) let nowTs = Int(now.timeIntervalSince1970) - // Sleep sessions drive both the sleep write and the vitals' wake-time stamps: computed + // Sleep sessions drive both the sleep write and the vitals' in-night stamps: computed // sessions (deviceId + "-noop") first, imported rows override on startTs collision — the // same source precedence as the dailies union below and IntelligenceEngine's sleep reads. let computedSleeps = (try? await whoopStore.sleepSessions(deviceId: computedDeviceId, from: fromTs, to: nowTs, limit: 200)) ?? [] @@ -878,21 +878,19 @@ final class HealthKitBridge: ObservableObject { } } - /// 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. + /// The nightly vitals write (the original write-back), stamped at the midpoint of the day's night + /// when it has one (`HealthWriteback.vitalsInstantByDay`) — a timestamp inside the night the value + /// describes, instead of a fabricated noon. Keys are unchanged, so re-stamped samples replace their + /// earlier wake- or noon-stamped ancestors. private func writeVitals(whoopStore: WhoopStore, days: Int, sessions: [CachedSleepSession]) async throws { let cal = Calendar.current let to = HealthKitBridge.dayString(Date()) guard let fromDate = cal.date(byAdding: .day, value: -days, to: Date()) else { return } let from = HealthKitBridge.dayString(fromDate) - // day (of wake) → wake instant. Ascending session order means the latest wake of a day wins, - // matching collectSleep's end-date day attribution. - var wakeByDay: [String: Date] = [:] - for s in sessions where s.endTs > s.effectiveStartTs { - let wake = Date(timeIntervalSince1970: TimeInterval(s.endTs)) - wakeByDay[HealthKitBridge.dayString(wake)] = wake + // day (of wake) → mid-night instant, attributed by wake like collectSleep's end-date attribution. + let instantByDay = HealthWriteback.vitalsInstantByDay(sleepPlan(sessions: sessions)) { + HealthKitBridge.dayString(Date(timeIntervalSince1970: TimeInterval($0))) } // Read NOOP's COMPUTED dailies (deviceId + "-noop"), which is the only place a strap-only // user's recovery/HRV/RHR/SpO₂/resp lives, then union with any imported `noopDeviceId` rows so @@ -932,7 +930,7 @@ final class HealthKitBridge: ObservableObject { for row in rows { guard let date = HealthKitBridge.date(from: row.day) else { continue } let noon = cal.date(bySettingHour: 12, minute: 0, second: 0, of: date) ?? date - let at = wakeByDay[row.day] ?? noon + let at = instantByDay[row.day].map { Date(timeIntervalSince1970: TimeInterval($0)) } ?? noon if let rhr = row.restingHr { add(.restingHeartRate, HKUnit.count().unitDivided(by: .minute()), Double(rhr), row.day, at) } @@ -972,6 +970,21 @@ final class HealthKitBridge: ObservableObject { try await self.store.save(candidates.map { $0.sample }) } + /// The bridged nights (#364) the write-back exports, shared by the sleep write and the vitals stamps so + /// both describe the same night. + private func sleepPlan(sessions: [CachedSleepSession]) -> [HealthWriteback.MergedSleepEntry] { + let blocks = sessions.map { SleepStageTotals.NightBlock(start: $0.effectiveStartTs, end: $0.endTs) } + let groups = SleepStageTotals.bridgedNightGroups(blocks, offsetSec: TimeZone.current.secondsFromGMT()) + .map { g in + g.indices.map { i -> HealthWriteback.SleepFragment in + let s = sessions[i] + return .init(startTs: s.startTs, effectiveStartTs: s.effectiveStartTs, + endTs: s.endTs, stagesJSON: s.stagesJSON) + } + } + return HealthWriteback.mergedSleepPlan(groups: groups) + } + /// Write each BRIDGED NIGHT (#364) as one `.inBed` sample plus one category sample per stage /// segment (`deep → .asleepDeep`, `rem → .asleepREM`, `light → .asleepCore`, `wake → .awake`) — /// the same shape Oura and Apple Watch write, so Health renders the full hypnogram. A night the @@ -991,18 +1004,9 @@ final class HealthKitBridge: ObservableObject { private func writeSleep(sessions: [CachedSleepSession]) async throws { guard let type = HKObjectType.categoryType(forIdentifier: .sleepAnalysis), store.authorizationStatus(for: type) == .sharingAuthorized else { return } - let blocks = sessions.map { SleepStageTotals.NightBlock(start: $0.effectiveStartTs, end: $0.endTs) } - let groups = SleepStageTotals.bridgedNightGroups(blocks, offsetSec: TimeZone.current.secondsFromGMT()) - .map { g in - g.indices.map { i -> HealthWriteback.SleepFragment in - let s = sessions[i] - return .init(startTs: s.startTs, effectiveStartTs: s.effectiveStartTs, - endTs: s.endTs, stagesJSON: s.stagesJSON) - } - } var samples: [HKCategorySample] = [] var keys: [String] = [] - for entry in HealthWriteback.mergedSleepPlan(groups: groups) { + for entry in sleepPlan(sessions: sessions) { let key = HealthWriteback.appleHealthSleepKey(startTs: entry.keyStartTs) let meta = [HKMetadataKeyExternalUUID: key] keys.append(contentsOf: entry.allKeyStartTs.map { HealthWriteback.appleHealthSleepKey(startTs: $0) }) diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 036071b6f1..da80dec35a 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -1,4 +1,13 @@ { "schema_version": 1, - "dispositions": [] + "dispositions": [ + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::vitalsInstantByDay/2#1", + "identity_sha256": "cc1a46149abee1ead9df3a0da97441674f2ac5e5e966f178abd12b558bf24294", + "platform": "swift", + "rationale": "Used only by the iOS HealthKitBridge write-back to stamp nightly HKQuantitySamples inside the sleep window they describe; the Android Health Connect exporter does not call it." + } + ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 04df8ad57d..072a72268c 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,7 +19,7 @@ }, "authority": { "files": {"count": 500, "sha256": "515000556406ef31edcca8c0c5a5aae1c519e69b998e1920a30921b1d87cea57"}, - "functions": {"count": 4449, "sha256": "1adfe3587fad29217a74fcfdd41629780744e18c8334684a7382411c3a55c6c6"}, + "functions": {"count": 4450, "sha256": "7dbc2b2e19442915eb8b32d8bf7a4098946636f244c21d8420bc7eb460e146b3"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, "constants": {"count": 1943, "sha256": "28aef07e38b51398fa1f0b85c1d90415a12cf5d9923a582a38c0753cd2d451fd"}, "file_pairs": {"count": 66, "sha256": "54ce5acd351bb1d2ce6bea06495cbfb7181975294ef7a94811e649be938f7f2a"}, @@ -27,7 +27,7 @@ "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, "constant_pairs": {"count": 676, "sha256": "e1e9dc35e152e5a439f1033e04a41ee28f5b6ea30cd8163746b67362ba0cdbed"}, "unpaired_files": {"count": 386, "sha256": "19bf9fb79a6000964912b38eeeb61515ec0dfe7b8632e1d539dc29e537d61cc6"}, - "unpaired_functions": {"count": 4109, "sha256": "a1c5691444fb06aa29ff47405d6fc673187efcc66acf6cbe31fbf318727b382f"}, + "unpaired_functions": {"count": 4110, "sha256": "cfe8984f5fc1d61d65140f6e705682b70ce2784566864325e800fd66baa7b6ce"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, "unpaired_constants": {"count": 591, "sha256": "c20942c8756be7c7c0bd38862eec53b1f0eb4ab25fbb68535d76d88d7b639514"} }