From 84ede494e6b5f3e2a9e46ea5dc9bbce9acc06031 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Wed, 16 Sep 2026 19:38:55 +0300 Subject: [PATCH 1/5] 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/5] 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/5] 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 } } From 94dfc98a789a8c83431d68d9f4803ab135b5acc2 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Wed, 16 Sep 2026 19:45:22 +0300 Subject: [PATCH 4/5] feat(health): write each night's beat-to-beat intervals to Apple Health as heartbeat series MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NOOP exported HRV to Apple Health only as one nightly SDNN value. A reader that computes its own HRV has nothing to compute from: Bevel's Recovery, for one, reads HRV from beat-to-beat measurements inside the sleep window, and shows no Recovery without it. Each finished night's R-R intervals are now written as `HKHeartbeatSeriesSample`s in 5-minute chunks. Beats are laid out one interval after the previous rather than on their whole-second row stamps, and a row that disagrees with that by more than 2 s starts a gap (`HealthWriteback.heartbeatSeriesPlan`). Only nights whose beats clear both gates NOOP applies before trusting the same statistic itself are written — no over-counted beats, and individually accurate values rather than a record period decomposed across one timestamp (`HRVAnalyzer.beatSeriesIsExportable`) — so no reader is handed intervals NOOP would refuse. A night is rewritten only when its fingerprint moves, and cleared if it stops being exportable; series are immutable, so a rewrite deletes the night's series by key under our own `HKSource` first. The fingerprint is recorded only once the whole night is written, so a failure mid-night retries. Heartbeat series joins the share types, so an existing install is asked once, in the foreground, through the existing re-ask for newly added write types. Health Connect has no beat-to-beat record type, so the new declarations carry `platform_specific` parity dispositions. --- .../Sources/StrandAnalytics/HRVAnalyzer.swift | 12 +++ .../HRVBeatSeriesExportTests.swift | 35 +++++++ .../StrandImport/HealthWriteback.swift | 92 +++++++++++++++++++ .../HealthWritebackTests.swift | 55 +++++++++++ StrandiOS/Health/HealthKitBridge.swift | 74 +++++++++++++++ Tools/parity_dispositions.json | 56 +++++++++++ Tools/parity_twin_map.json | 8 +- 7 files changed, 328 insertions(+), 4 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVBeatSeriesExportTests.swift diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift index be09ab27d0..663ff0aa89 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift @@ -557,6 +557,18 @@ public enum HRVAnalyzer { !(beatAccurateFraction < beatAccuracyMinFraction) } + /// Whether a night's intervals may be handed to another app as beat-to-beat data (Apple Health's + /// heartbeat series). A reader computes its own successive-difference HRV from them, so they must clear + /// both gates NOOP applies before trusting the same statistic itself: no over-counted beats + /// (`successiveDiffIsTrustworthy`) and individually accurate values rather than a record period + /// decomposed across one timestamp (`beatValuesAreTrustworthy`). Pure; `tsSec` and `rrMs` are parallel. + public static func beatSeriesIsExportable(tsSec: [Int], rrMs: [Double]) -> Bool { + let verdict = classifyCoverage(coverage: rrCoverage(tsSec: tsSec, rrMs: rrMs), + collapsed: collapsedCoverage(tsSec: tsSec, rrMs: rrMs)) + return successiveDiffIsTrustworthy(verdict) + && beatValuesAreTrustworthy(beatAccurateFraction: beatAccurateFraction(tsSec: tsSec, rrMs: rrMs)) + } + /// Tolerance BELOW 1.0 treated as "fits", the mirror of `coveragePlausibleCeiling`. Same allowance, /// same caveat: a ROUNDING allowance rather than a tuned threshold, because whole-second timestamps /// under-report as easily as they over-report. Where the real boundary sits still needs coverage diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVBeatSeriesExportTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVBeatSeriesExportTests.swift new file mode 100644 index 0000000000..da32e37d17 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HRVBeatSeriesExportTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import StrandAnalytics + +/// Beat-to-beat export: another app computes its own successive-difference HRV from these intervals, so a +/// night is exported only when NOOP would trust the same statistic from the same beats. +final class HRVBeatSeriesExportTests: XCTestCase { + + /// One beat per second, each stamped where the previous interval says it lands. + func testACleanBeatTrainIsExportable() { + let ts = Array(1_000..<1_600) + let rr = ts.map { _ in 1_000.0 } + XCTAssertTrue(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr)) + } + + /// Two beats a second for a heart beating once a second: the stream banks more beat-time than the + /// clock spans, and its successive differences are not the heart's. + func testAnOverCountedNightIsNotExportable() { + var ts: [Int] = [], rr: [Double] = [] + for i in 0..<600 { + ts.append(1_000 + i); rr.append(900) + ts.append(1_000 + i); rr.append(905) + } + XCTAssertFalse(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr)) + } + + /// A banked record: five intervals decomposed across one timestamp. The sum is right, the individual + /// values are not beat-to-beat measurements. + func testADecomposedRecordIsNotExportable() { + var ts: [Int] = [], rr: [Double] = [] + for record in 0..<120 { + for _ in 0..<5 { ts.append(1_000 + record * 5); rr.append(1_000) } + } + XCTAssertFalse(HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr)) + } +} diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index ccd97af1fe..73d1c230a2 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -242,6 +242,98 @@ public enum HealthWriteback { appleHealthExternalUUID(kind: "workout", identity: "\(startTs)") } + /// The beat-to-beat key: `noop:heartbeat:`, the night's sleep key identity. Carried by every + /// heartbeat-series sample written for that night, so the night can be cleared as a unit. + public static func appleHealthHeartbeatKey(startTs: Int) -> String { + appleHealthExternalUUID(kind: "heartbeat", identity: "\(startTs)") + } + + // MARK: - Beat-to-beat (heartbeat series) + + /// One heartbeat-series sample: its start (unix seconds) and each beat's offset from it. + public struct HeartbeatSeriesChunk: Equatable { + public struct Beat: Equatable { + public let offset: Double + /// The beat does not follow the previous one directly: beats between them were not recorded. + public let precededByGap: Bool + public init(offset: Double, precededByGap: Bool) { + self.offset = offset; self.precededByGap = precededByGap + } + } + public let start: Double + public let beats: [Beat] + public init(start: Double, beats: [Beat]) { self.start = start; self.beats = beats } + } + + /// The longest heartbeat-series sample, in seconds — the 5-minute window NOOP's own SDNN index uses, + /// short enough for a reader to window its own statistics over. + public static let heartbeatChunkSeconds: Double = 300 + + /// How far a beat may sit from the time its interval predicts before it is read as a gap. The row + /// stamps are whole seconds, so a continuous train drifts by up to one second against them. + public static let heartbeatGapToleranceSeconds: Double = 2 + + /// Beat times from stored R-R rows (`tsSec` parallel to `rrMs`, ascending). Each beat lands one interval + /// after the previous; a row whose stamp disagrees with that by more than the tolerance starts again from + /// its own stamp as a gap. Chunks split at `heartbeatChunkSeconds`. Non-positive intervals are skipped. + public static func heartbeatSeriesPlan(tsSec: [Int], rrMs: [Int]) -> [HeartbeatSeriesChunk] { + guard tsSec.count == rrMs.count else { return [] } + var chunks: [HeartbeatSeriesChunk] = [] + var chunkStart = 0.0 + var beats: [HeartbeatSeriesChunk.Beat] = [] + var previous: Double? + for (ts, rr) in zip(tsSec, rrMs) where rr > 0 { + let stamp = Double(ts) + var time = stamp + var gap = true + if let previous { + let predicted = previous + Double(rr) / 1_000 + if abs(predicted - stamp) <= heartbeatGapToleranceSeconds { time = predicted; gap = false } + } + if beats.isEmpty || time - chunkStart >= heartbeatChunkSeconds { + if !beats.isEmpty { chunks.append(.init(start: chunkStart, beats: beats)) } + chunkStart = time + beats = [] + gap = false + } + beats.append(.init(offset: time - chunkStart, precededByGap: gap)) + previous = time + } + if !beats.isEmpty { chunks.append(.init(start: chunkStart, beats: beats)) } + return chunks + } + + /// What identifies a night's exported beats: a change in any of these rewrites the night. + public static func heartbeatFingerprint(tsSec: [Int], rrMs: [Int]) -> String { + "\(tsSec.count):\(tsSec.first ?? 0):\(tsSec.last ?? 0):\(rrMs.reduce(0, +))" + } + + /// Which nights' heartbeat series to rewrite or clear. `nights` pairs each night's key with its current + /// fingerprint, nil when it has no exportable beats; `written` is what was last written per key. + /// A night is rewritten when its fingerprint moved, cleared when it was written and is no longer + /// exportable, and left alone otherwise. `kept` is the written record for the nights left alone; a key + /// outside `nights` has aged out of the window and drops out of the record without touching Health. + public static func heartbeatSyncPlan(nights: [(key: String, fingerprint: String?)], + written: [String: String]) + -> (rewrite: [(key: String, fingerprint: String)], clear: [String], kept: [String: String]) { + var rewrite: [(key: String, fingerprint: String)] = [] + var clear: [String] = [] + var kept: [String: String] = [:] + for night in nights { + switch (night.fingerprint, written[night.key]) { + case let (current?, previous) where current != previous: + rewrite.append((night.key, current)) + case let (current?, _): + kept[night.key] = current + case (nil, _?): + clear.append(night.key) + case (nil, nil): + break + } + } + return (rewrite, clear, kept) + } + // MARK: - #1503 stranded-records sweep completion tracking // // The one-off sweep that clears Apple Health records written under the OLD device-id-keyed diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index e91b9cdabe..0dcb3c50be 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -391,4 +391,59 @@ final class HealthWritebackTests: XCTestCase { func testANightWithNoSpanStampsNothing() { XCTAssertEqual(HealthWriteback.vitalsInstantByDay([entry(start, start)], dayOf: dayOf), [:]) } + + // MARK: - Beat-to-beat (heartbeat series) + + private func assertOffsets(_ chunk: HealthWriteback.HeartbeatSeriesChunk, _ expected: [Double], + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(chunk.beats.count, expected.count, file: file, line: line) + for (beat, offset) in zip(chunk.beats, expected) { + XCTAssertEqual(beat.offset, offset, accuracy: 1e-9, file: file, line: line) + } + } + + func testBeatsLandOneIntervalAfterThePreviousNotOnTheirWholeSecondStamp() { + // 800 ms beats stamped to whole seconds: 1000, 1000, 1001, 1002, 1003 + let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_000, 1_001, 1_002, 1_003], + rrMs: [800, 800, 800, 800, 800]) + XCTAssertEqual(plan.count, 1) + XCTAssertEqual(plan[0].start, 1_000) + assertOffsets(plan[0], [0, 0.8, 1.6, 2.4, 3.2]) + XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, false, false, false]) + } + + func testAStampFarFromItsPredictedTimeIsAGap() { + let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001, 1_060], rrMs: [1_000, 1_000, 1_000]) + assertOffsets(plan[0], [0, 1, 60]) + XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, true]) + } + + func testSeriesSplitAtFiveMinutes() { + let ts = Array(0..<601).map { 1_000 + $0 } + let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: ts, rrMs: ts.map { _ in 1_000 }) + XCTAssertEqual(plan.map(\.start), [1_000, 1_300, 1_600]) + XCTAssertEqual(plan.map { $0.beats.count }, [300, 300, 1]) + XCTAssertFalse(plan[1].beats[0].precededByGap) + } + + func testNonPositiveIntervalsAreSkippedAndMismatchedInputPlansNothing() { + XCTAssertEqual(HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001], rrMs: [0, 1_000])[0].beats.count, 1) + XCTAssertEqual(HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000], rrMs: []), []) + } + + func testTheHeartbeatKeySharesTheSleepIdentity() { + XCTAssertEqual(HealthWriteback.appleHealthHeartbeatKey(startTs: 42), "noop:heartbeat:42") + } + + func testHeartbeatSyncRewritesMovedNightsClearsUntrustedOnesAndForgetsAgedOut() { + let plan = HealthWriteback.heartbeatSyncPlan( + nights: [(key: "new", fingerprint: "a"), (key: "same", fingerprint: "b"), + (key: "moved", fingerprint: "c2"), (key: "untrusted", fingerprint: nil), + (key: "never", fingerprint: nil)], + written: ["same": "b", "moved": "c1", "untrusted": "d", "agedOut": "e"]) + XCTAssertEqual(plan.rewrite.map(\.key), ["new", "moved"]) + XCTAssertEqual(plan.rewrite.map(\.fingerprint), ["a", "c2"]) + XCTAssertEqual(plan.clear, ["untrusted"]) + XCTAssertEqual(plan.kept, ["same": "b"]) + } } diff --git a/StrandiOS/Health/HealthKitBridge.swift b/StrandiOS/Health/HealthKitBridge.swift index dde5daa757..1b8a8eed0d 100644 --- a/StrandiOS/Health/HealthKitBridge.swift +++ b/StrandiOS/Health/HealthKitBridge.swift @@ -111,6 +111,7 @@ final class HealthKitBridge: ObservableObject { } if let sleep = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) { s.insert(sleep) } s.insert(HKObjectType.workoutType()) + s.insert(HKSeriesType.heartbeat()) return s } @@ -804,6 +805,7 @@ final class HealthKitBridge: ObservableObject { await attempt { try await migrateStrandedHealthRecords(fromTs: fromTs, nowTs: nowTs) } await attempt { try await writeVitals(whoopStore: whoopStore, days: days, sessions: sessions) } await attempt { try await writeSleep(sessions: sessions) } + await attempt { try await writeHeartbeats(whoopStore: whoopStore, sessions: sessions) } await attempt { try await writeHeartRate(whoopStore: whoopStore, fromTs: fromTs, nowTs: nowTs) } await attempt { try await writeWorkouts(whoopStore: whoopStore, fromTs: fromTs, toTs: nowTs) } if let firstError { throw firstError } @@ -1039,6 +1041,78 @@ final class HealthKitBridge: ObservableObject { try await store.save(samples) } + /// UserDefaults key for the fingerprint of each night's heartbeat series as last written, keyed by + /// `HealthWriteback.appleHealthHeartbeatKey`. + private static let heartbeatWrittenKey = "hkHeartbeatWritten.v1" + + /// Write each finished night's R-R intervals as heartbeat series (`HKHeartbeatSeriesSample`), in the + /// 5-minute chunks `HealthWriteback.heartbeatSeriesPlan` lays out. This is the beat-to-beat data a + /// reader computes its own HRV from: Bevel's Recovery reads HRV from beat-to-beat measurements inside + /// the sleep window, and a single nightly SDNN sample gives it nothing to compute from. + /// + /// Only nights whose beats clear `HRVAnalyzer.beatSeriesIsExportable` are written, so no reader is + /// handed intervals NOOP would refuse to compute the same statistic from. A night is rewritten only + /// when its fingerprint moves, and cleared if it stops being exportable. Series are immutable, so a + /// rewrite deletes the night's series by key (scoped to our own `HKSource`) before writing; the + /// fingerprint is recorded only after the whole night is written, so a failure mid-night retries. + private func writeHeartbeats(whoopStore: WhoopStore, sessions: [CachedSleepSession]) async throws { + let type = HKSeriesType.heartbeat() + guard store.authorizationStatus(for: type) == .sharingAuthorized else { return } + let nowTs = Int(Date().timeIntervalSince1970) + let strictWhoop5 = (try? await whoopStore.isWhoop5RRSource(deviceId: noopDeviceId)) ?? true + var nights: [(key: String, fingerprint: String?)] = [] + var beatsByKey: [String: (ts: [Int], rr: [Int])] = [:] + for entry in sleepPlan(sessions: sessions) where entry.spanEnd <= nowTs { + let key = HealthWriteback.appleHealthHeartbeatKey(startTs: entry.keyStartTs) + let rows = (try? await whoopStore.rrIntervals(deviceId: noopDeviceId, from: entry.spanStart, + to: entry.spanEnd, limit: StreamReadCap.rr, + unlabelledAliasOfWhoop5: strictWhoop5)) ?? [] + let ts = rows.map { $0.ts } + let rr = rows.map { $0.rrMs } + guard !rows.isEmpty, + HRVAnalyzer.beatSeriesIsExportable(tsSec: ts, rrMs: rr.map { Double($0) }) else { + nights.append((key, nil)) + continue + } + nights.append((key, HealthWriteback.heartbeatFingerprint(tsSec: ts, rrMs: rr))) + beatsByKey[key] = (ts, rr) + } + let written = UserDefaults.standard.dictionary(forKey: Self.heartbeatWrittenKey) as? [String: String] ?? [:] + let plan = HealthWriteback.heartbeatSyncPlan(nights: nights, written: written) + var record = plan.kept + UserDefaults.standard.set(record, forKey: Self.heartbeatWrittenKey) + + let bySource = HKQuery.predicateForObjects(from: HKSource.default()) + func deleteSeries(_ key: String) async { + let byKey = HKQuery.predicateForObjects(withMetadataKey: HKMetadataKeyExternalUUID, allowedValues: [key]) + _ = try? await store.deleteObjects( + of: type, predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [bySource, byKey])) + } + for key in plan.clear { await deleteSeries(key) } + for night in plan.rewrite { + guard let beats = beatsByKey[night.key] else { continue } + await deleteSeries(night.key) + for chunk in HealthWriteback.heartbeatSeriesPlan(tsSec: beats.ts, rrMs: beats.rr) { + let builder = HKHeartbeatSeriesBuilder(healthStore: store, device: nil, + start: Date(timeIntervalSince1970: chunk.start)) + for beat in chunk.beats { + try await withCheckedThrowingContinuation { (done: CheckedContinuation) in + builder.addHeartbeatWithTimeInterval(sinceSeriesStartDate: beat.offset, + precededByGap: beat.precededByGap) { added, error in + if let error { done.resume(throwing: error) } + else if !added { done.resume(throwing: HKError(.errorInvalidArgument)) } + else { done.resume() } + } + } + } + try await builder.addMetadata([HKMetadataKeyExternalUUID: night.key]) + _ = try await builder.finishSeries() + } + record[night.key] = night.fingerprint + UserDefaults.standard.set(record, forKey: Self.heartbeatWrittenKey) + } + } + /// UserDefaults key for the HR write cursor (the newest bucket ts we've written). Per-strap so a /// device switch restarts the backfill for the new strap instead of resuming mid-stream. private var hrWriteCursorKey: String { "hkHRWriteCursor.v1.\(noopDeviceId)" } diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index da80dec35a..7b0caaf854 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -8,6 +8,62 @@ "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." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift::beatSeriesIsExportable/2#1", + "identity_sha256": "d138f08905829685f03b6e13ff999fe008c28eeaf40b96e0a0056aeabe0a14b6", + "platform": "swift", + "rationale": "Gates the iOS-only Apple Health heartbeat-series export (HKHeartbeatSeriesSample). Health Connect has no beat-to-beat record type, so Android has nothing to gate; it composes the already-twinned successiveDiffIsTrustworthy and beatValuesAreTrustworthy." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::appleHealthHeartbeatKey/1#1", + "identity_sha256": "ae75baa928dad6d98d6be8de61a253598e19acea30ce186f94232934ed9b428b", + "platform": "swift", + "rationale": "External-UUID key for Apple Health heartbeat-series samples written by the iOS HealthKitBridge; Health Connect has no beat-to-beat record type to key." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::heartbeatSeriesPlan/2#1", + "identity_sha256": "5039e153e955c609dd29d656fddfaa30a319bf13687f1484e308cded8e7d266d", + "platform": "swift", + "rationale": "Lays out beat offsets for HKHeartbeatSeriesBuilder, an Apple HealthKit API; Health Connect has no beat-to-beat record type, so there is no Kotlin writer to share it." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::heartbeatFingerprint/2#1", + "identity_sha256": "e0477925cbd1b61105072624d205f022b916d9d25a732008ea971bdba43ee99f", + "platform": "swift", + "rationale": "Change detection for the iOS-only Apple Health heartbeat-series export; Health Connect has no beat-to-beat record type." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-function", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::heartbeatSyncPlan/2#1", + "identity_sha256": "9a74354c4e9f458d23838e667bd6ba543ff4981273056807824046f84ce4334b", + "platform": "swift", + "rationale": "Decides which nights the iOS-only Apple Health heartbeat-series export rewrites or clears; Health Connect has no beat-to-beat record type." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::heartbeatChunkSeconds", + "identity_sha256": "1e7c6d906fdc01347930aca59d33dc644f520db944e7b6893525a93e5706a473", + "platform": "swift", + "rationale": "Chunk length for the iOS-only Apple Health heartbeat-series export (HKHeartbeatSeriesBuilder); Health Connect has no beat-to-beat record type." + }, + { + "type": "platform_specific", + "kind": "add-unpaired-constant", + "identity": "swift\u0000Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift::heartbeatGapToleranceSeconds", + "identity_sha256": "9ecb620cdc093557e5f70df1bb80d549e71b074e6f575515b66988d590a00374", + "platform": "swift", + "rationale": "Gap tolerance for laying out beats in the iOS-only Apple Health heartbeat-series export; Health Connect has no beat-to-beat record type." } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 072a72268c..19da1a6b5f 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -19,16 +19,16 @@ }, "authority": { "files": {"count": 500, "sha256": "515000556406ef31edcca8c0c5a5aae1c519e69b998e1920a30921b1d87cea57"}, - "functions": {"count": 4450, "sha256": "7dbc2b2e19442915eb8b32d8bf7a4098946636f244c21d8420bc7eb460e146b3"}, + "functions": {"count": 4455, "sha256": "5e59a5e0f77a686d5c5f91e6f404c3dc96fd1eb4860dac2b8e1efb6bc6b6f4c4"}, "properties": {"count": 458, "sha256": "d654302949fe0cb34f6e43a757e3a47fe485758e8a902c247285c0e288964d45"}, - "constants": {"count": 1943, "sha256": "28aef07e38b51398fa1f0b85c1d90415a12cf5d9923a582a38c0753cd2d451fd"}, + "constants": {"count": 1945, "sha256": "925871ba2e13f1f35af25e39895dbb47114828720fa3201c186c00aab1c04ebc"}, "file_pairs": {"count": 66, "sha256": "54ce5acd351bb1d2ce6bea06495cbfb7181975294ef7a94811e649be938f7f2a"}, "function_pairs": {"count": 173, "sha256": "3bf3599416d9adc1e339cf20fdb915b9d9f8ce955e0fb8329834af88105959ca"}, "property_pairs": {"count": 148, "sha256": "6fa59982fca9e8e306562b9d027676457c9fa4cc6766c1d6171e86084f3b2c54"}, "constant_pairs": {"count": 676, "sha256": "e1e9dc35e152e5a439f1033e04a41ee28f5b6ea30cd8163746b67362ba0cdbed"}, "unpaired_files": {"count": 386, "sha256": "19bf9fb79a6000964912b38eeeb61515ec0dfe7b8632e1d539dc29e537d61cc6"}, - "unpaired_functions": {"count": 4110, "sha256": "cfe8984f5fc1d61d65140f6e705682b70ce2784566864325e800fd66baa7b6ce"}, + "unpaired_functions": {"count": 4115, "sha256": "8da5d825084f9e01742d32446d3239951cc90784b4f0839b8a07eef851a50680"}, "unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"}, - "unpaired_constants": {"count": 591, "sha256": "c20942c8756be7c7c0bd38862eec53b1f0eb4ab25fbb68535d76d88d7b639514"} + "unpaired_constants": {"count": 593, "sha256": "dff0ef6eee8c4841a673b9bef12626e393aa900386ee77336172420ef48040ae"} } } From f616b27d5b85910d3994976197b6070de3eacfb5 Mon Sep 17 00:00:00 2001 From: Iskren Alexandrov Date: Thu, 17 Sep 2026 10:22:30 +0300 Subject: [PATCH 5/5] fix(health): drop an R-R row stamped behind the beats already placed so the heartbeat series stays in order --- .../Sources/StrandImport/HealthWriteback.swift | 7 +++++++ .../StrandImportTests/HealthWritebackTests.swift | 15 +++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift index 73d1c230a2..847f3c2e3b 100644 --- a/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift +++ b/Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift @@ -276,6 +276,12 @@ public enum HealthWriteback { /// Beat times from stored R-R rows (`tsSec` parallel to `rrMs`, ascending). Each beat lands one interval /// after the previous; a row whose stamp disagrees with that by more than the tolerance starts again from /// its own stamp as a gap. Chunks split at `heartbeatChunkSeconds`. Non-positive intervals are skipped. + /// + /// A row stamped at or before the beat already placed is dropped. HealthKit refuses a series whose beats + /// do not strictly advance ("Heartbeats must be added in order"), and one such row failed the whole + /// night. It happens when the placed beats run ahead of their whole-second stamps: a phone copy of 15 + /// real nights had 0 to 29 per night, the previous beat sitting 0 to 1.9 s past the late row's stamp. + /// The beat after a dropped one is still predicted from the last beat placed, so the train carries on. public static func heartbeatSeriesPlan(tsSec: [Int], rrMs: [Int]) -> [HeartbeatSeriesChunk] { guard tsSec.count == rrMs.count else { return [] } var chunks: [HeartbeatSeriesChunk] = [] @@ -289,6 +295,7 @@ public enum HealthWriteback { if let previous { let predicted = previous + Double(rr) / 1_000 if abs(predicted - stamp) <= heartbeatGapToleranceSeconds { time = predicted; gap = false } + else if stamp <= previous { continue } } if beats.isEmpty || time - chunkStart >= heartbeatChunkSeconds { if !beats.isEmpty { chunks.append(.init(start: chunkStart, beats: beats)) } diff --git a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift index 0dcb3c50be..5049e707c0 100644 --- a/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift +++ b/Packages/StrandImport/Tests/StrandImportTests/HealthWritebackTests.swift @@ -418,6 +418,21 @@ final class HealthWritebackTests: XCTestCase { XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, true]) } + func testARowStampedBehindThePlacedBeatsIsDroppedSoTheSeriesStaysInOrder() { + // Shape from a real night: the placed beats run 1.8 s ahead of the stamps, then a 676 ms row also + // stamped 1 002 predicts 1 004.476, outside the tolerance, and its own stamp is behind the beat + // already placed at 1 003.8. Placing it there is the out-of-order add HealthKit rejects. + let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_001, 1_002, 1_002, 1_004], + rrMs: [1_000, 1_900, 1_900, 676, 900]) + assertOffsets(plan[0], [0, 1.9, 3.8, 4.7]) + XCTAssertEqual(plan[0].beats.map(\.precededByGap), [false, false, false, false]) + } + + func testARowStampedExactlyOnThePlacedBeatIsDropped() { + let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: [1_000, 1_003, 1_003], rrMs: [1_000, 3_000, 2_033]) + assertOffsets(plan[0], [0, 3]) + } + func testSeriesSplitAtFiveMinutes() { let ts = Array(0..<601).map { 1_000 + $0 } let plan = HealthWriteback.heartbeatSeriesPlan(tsSec: ts, rrMs: ts.map { _ in 1_000 })