Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions Packages/StrandImport/Sources/StrandImport/HealthWriteback.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:<metricId>:<day>`.
public static func appleHealthVitalKey(metricId: String, day: String) -> String {
appleHealthExternalUUID(kind: metricId, identity: day)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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), [:])
}
}
48 changes: 26 additions & 22 deletions StrandiOS/Health/HealthKitBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) ?? []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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) })
Expand Down
11 changes: 10 additions & 1 deletion Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
4 changes: 2 additions & 2 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
},
"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"},
"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": 4109, "sha256": "a1c5691444fb06aa29ff47405d6fc673187efcc66acf6cbe31fbf318727b382f"},
"unpaired_functions": {"count": 4110, "sha256": "cfe8984f5fc1d61d65140f6e705682b70ce2784566864325e800fd66baa7b6ce"},
"unpaired_properties": {"count": 162, "sha256": "eede893a804203b080f27bb05ca6a09ee9ac9d708ade984e2855813f35b6ae22"},
"unpaired_constants": {"count": 591, "sha256": "c20942c8756be7c7c0bd38862eec53b1f0eb4ab25fbb68535d76d88d7b639514"}
}
Expand Down
Loading