From 84ede494e6b5f3e2a9e46ea5dc9bbce9acc06031 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Wed, 16 Sep 2026 19:38:55 +0300 Subject: [PATCH 1/3] feat(health): stamp nightly vitals inside the night they describe, not at its wake Resting HR, HRV, SpO2 and respiratory rate were each written as one instant at the day's latest wake, which sits exactly on the sleep window's boundary. A reader that selects them by that window cannot count on a boundary sample: Bevel, for one, shows no Recovery when HRV or resting HR is not captured during the sleep window. The latest wake could also belong to a nap after the main sleep. They are now stamped at the midpoint of the longest bridged night that woke on that day, from the same night plan the sleep write exports (factored out of `writeSleep` so the two cannot disagree). Keys are unchanged, so the next write-back replaces the wake-stamped samples. `vitalsInstantByDay` is pure and covered in `HealthWritebackTests`. --- .../StrandImport/HealthWriteback.swift | 20 ++++++++ .../HealthWritebackTests.swift | 33 +++++++++++++ StrandiOS/Health/HealthKitBridge.swift | 48 ++++++++++--------- Tools/parity_dispositions.json | 11 ++++- Tools/parity_twin_map.json | 4 +- 5 files changed, 91 insertions(+), 25 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 5372f21c1b..8ef0bd8124 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -176,6 +176,26 @@ 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. + 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 { $0.spanStart + ($0.spanEnd - $0.spanStart) / 2 } + } + /// 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..da3fbe53c5 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -339,4 +339,37 @@ 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]) + } + + 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"} } From e3435387252030a290b0e1d9b8642dc54e5a719f Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 10:51:15 +0300 Subject: [PATCH 2/3] fix(health): stamp a bridged night's vitals at an asleep second when its midpoint falls in a wake gap --- .../StrandImport/HealthWriteback.swift | 15 ++++++++++++++- .../HealthWritebackTests.swift | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 8ef0bd8124..28837c9a84 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -184,6 +184,12 @@ public enum HealthWriteback { /// 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] = [:] @@ -193,7 +199,14 @@ public enum HealthWriteback { current.spanEnd - current.spanStart >= entry.spanEnd - entry.spanStart { continue } longest[day] = entry } - return longest.mapValues { $0.spanStart + ($0.spanEnd - $0.spanStart) / 2 } + 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. + return asleep.map { min(max(mid, $0.start), $0.end - 1) } + .min { abs($0 - mid) == abs($1 - mid) ? $0 < $1 : abs($0 - mid) < abs($1 - mid) } ?? mid + } } /// The vitals key: `noop::`. diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index da3fbe53c5..e91b9cdabe 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -369,6 +369,25 @@ final class HealthWritebackTests: XCTestCase { ["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), [:]) } From 32ba5eecc65847fd379f3f9e1fe2d56fe1493206 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 15:13:04 +0300 Subject: [PATCH 3/3] fix(health): split the nearest-asleep-second search into a loop the CI type-checker accepts --- .../Sources/StrandImport/HealthWriteback.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 28837c9a84..ccd97af1fe 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -203,9 +203,19 @@ public enum HealthWriteback { 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. - return asleep.map { min(max(mid, $0.start), $0.end - 1) } - .min { abs($0 - mid) == abs($1 - mid) ? $0 < $1 : abs($0 - mid) < abs($1 - mid) } ?? 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 } }