From 1aa20138e88e894094670dbb9bf55ae4012ce751 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Thu, 12 Dec 2024 08:55:28 +0000 Subject: [PATCH] VIT-7905: Implement electrocardiogram sync (#257) --- .../Base Models/ResourceData.swift | 7 ++ .../Patches/LocalElectrocardiogram.swift | 104 ++++++++++++++++++ .../HealthKitReads+Electrocardiogram.swift | 88 +++++++++++++++ .../HealthKit/HealthKitReads.swift | 5 +- 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 Sources/VitalCore/Core/Client/Data Models/Patches/LocalElectrocardiogram.swift create mode 100644 Sources/VitalHealthKit/HealthKit/HealthKitReads+Electrocardiogram.swift diff --git a/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift b/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift index 3a50966..c0dd27d 100644 --- a/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift +++ b/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift @@ -169,6 +169,7 @@ public enum SummaryData: Equatable, Encodable { case workout(WorkoutPatch) case menstrualCycle(MenstrualCyclePatch) case meal(MealPatch) + case electrocardiogram([ManualElectrocardiogram]) public var payload: Encodable { switch self { @@ -186,6 +187,8 @@ public enum SummaryData: Equatable, Encodable { return patch.cycles case let .meal(patch): return patch.meals + case let .electrocardiogram(ecgs): + return ecgs } } @@ -205,6 +208,8 @@ public enum SummaryData: Equatable, Encodable { return patch.cycles.count case let .meal(patch): return patch.dataCount() + case let .electrocardiogram(ecgs): + return ecgs.count } } @@ -224,6 +229,8 @@ public enum SummaryData: Equatable, Encodable { return "menstrual_cycle" case .meal: return "meal" + case .electrocardiogram: + return "electrocardiogram" } } } diff --git a/Sources/VitalCore/Core/Client/Data Models/Patches/LocalElectrocardiogram.swift b/Sources/VitalCore/Core/Client/Data Models/Patches/LocalElectrocardiogram.swift new file mode 100644 index 0000000..e2628ca --- /dev/null +++ b/Sources/VitalCore/Core/Client/Data Models/Patches/LocalElectrocardiogram.swift @@ -0,0 +1,104 @@ +import HealthKit + +public struct ManualElectrocardiogram: Equatable, Encodable { + public let electrocardiogram: ManualElectrocardiogram.Summary + public let voltageData: ManualElectrocardiogram.VoltageData + + public init(electrocardiogram: ManualElectrocardiogram.Summary, voltageData: ManualElectrocardiogram.VoltageData) { + self.electrocardiogram = electrocardiogram + self.voltageData = voltageData + } +} + +extension ManualElectrocardiogram { + public enum Classification: String, Codable { + case sinusRhythm = "sinus_rhythm" + case atrialFibrillation = "atrial_fibrillation" + case inconclusive = "inconclusive" + } + + public enum InconclusiveCause: String, Codable { + case highHeartRate = "high_heart_rate" + case lowHeartRate = "low_heart_rate" + case poorReading = "poor_reading" + } + + public struct Summary: Equatable, Encodable { + public let id: String + + public let sessionStart: Date + public let sessionEnd: Date + + public let voltageSampleCount: Int + public let heartRateMean: Int? + public let samplingFrequencyHz: Double? + + public let classification: Classification? + public let inconclusiveCause: InconclusiveCause? + + public let algorithmVersion: String? + + public let sourceBundle: String + public let productType: String? + public let deviceModel: String? + + public init( + id: String, + sessionStart: Date, + sessionEnd: Date, + voltageSampleCount: Int, + heartRateMean: Int?, + samplingFrequencyHz: Double?, + classification: Classification?, + inconclusiveCause: InconclusiveCause?, + algorithmVersion: String?, + sourceBundle: String, + productType: String?, + deviceModel: String? + ) { + self.id = id + self.sessionStart = sessionStart + self.sessionEnd = sessionEnd + self.voltageSampleCount = voltageSampleCount + self.heartRateMean = heartRateMean + self.samplingFrequencyHz = samplingFrequencyHz + self.classification = classification + self.inconclusiveCause = inconclusiveCause + self.algorithmVersion = algorithmVersion + self.sourceBundle = sourceBundle + self.productType = productType + self.deviceModel = deviceModel + } + } + + public struct VoltageData: Equatable, Encodable { + public let sessionStartOffsetMillisecond: [Int] + public let lead1: [Double?] + + public init(sessionStartOffsetMillisecond: [Int], lead1: [Double?]) { + self.sessionStartOffsetMillisecond = sessionStartOffsetMillisecond + self.lead1 = lead1 + } + } + + public static func mapClassification(_ ecgClassification: HKElectrocardiogram.Classification) -> (Classification?, InconclusiveCause?) { + switch ecgClassification { + case .atrialFibrillation: + return (.atrialFibrillation, nil) + case .sinusRhythm: + return (.sinusRhythm, nil) + case .inconclusiveHighHeartRate: + return (.inconclusive, .highHeartRate) + case .inconclusiveLowHeartRate: + return (.inconclusive, .lowHeartRate) + case .inconclusivePoorReading: + return (.inconclusive, .poorReading) + case .inconclusiveOther: + return (.inconclusive, nil) + case .notSet, .unrecognized: + return (nil, nil) + @unknown default: + return (nil, nil) + } + } +} diff --git a/Sources/VitalHealthKit/HealthKit/HealthKitReads+Electrocardiogram.swift b/Sources/VitalHealthKit/HealthKit/HealthKitReads+Electrocardiogram.swift new file mode 100644 index 0000000..b959b65 --- /dev/null +++ b/Sources/VitalHealthKit/HealthKit/HealthKitReads+Electrocardiogram.swift @@ -0,0 +1,88 @@ +import VitalCore +import HealthKit + +func handleElectrocardiogram( + healthKitStore: HKHealthStore, + vitalStorage: AnchorStorage, + instruction: SyncInstruction +) async throws -> (electrocardiograms: [ManualElectrocardiogram], anchors: [StoredAnchor]) { + + let (ecg, anchor) = try await anchoredQuery( + healthKitStore: healthKitStore, + vitalStorage: vitalStorage, + type: HKElectrocardiogramType.electrocardiogramType(), + sampleClass: HKElectrocardiogram.self, + unit: (), + limit: AnchoredQueryChunkSize.electrocardiogram, + startDate: instruction.query.lowerBound, + endDate: instruction.query.upperBound, + transform: { sample, _ in sample } + ) + + var electrocardiogram = [ManualElectrocardiogram]() + + for ecg in ecg { + let handle = CancellableQueryHandle { continuation in + var offsets = [Int]() + var lead1 = [Double?]() + + offsets.reserveCapacity(ecg.numberOfVoltageMeasurements) + lead1.reserveCapacity(ecg.numberOfVoltageMeasurements) + + let query = HKElectrocardiogramQuery(ecg) { query, result in + switch result { + case .done: + continuation.resume(returning: (offsets, lead1)) + + case let .error(error): + continuation.resume(throwing: error) + + case let .measurement(measurement): + offsets.append(Int(measurement.timeSinceSampleStart * 1000)) + lead1.append( + measurement.quantity(for: .appleWatchSimilarToLeadI)? + .doubleValue(for: .voltUnit(with: .milli)) + ) + @unknown default: + break + } + } + + return query + } + + let (offsets, lead1) = try await handle.execute(in: healthKitStore) + + let (classification, inconclusiveCause) = ManualElectrocardiogram.mapClassification( + ecg.classification + ) + + let summary = ManualElectrocardiogram.Summary( + id: ecg.uuid.uuidString, + sessionStart: ecg.startDate, + sessionEnd: ecg.endDate, + voltageSampleCount: ecg.numberOfVoltageMeasurements, + heartRateMean: (ecg.averageHeartRate?.doubleValue(for: .count().unitDivided(by: .minute()))).map(Int.init), + samplingFrequencyHz: ecg.samplingFrequency?.doubleValue(for: .hertz()), + classification: classification, + inconclusiveCause: inconclusiveCause, + algorithmVersion: ecg.metadata?[HKMetadataKeyAppleECGAlgorithmVersion] as? String, + sourceBundle: ecg.sourceRevision.source.bundleIdentifier, + productType: ecg.sourceRevision.productType, + deviceModel: ecg.device?.model + ) + + let voltageData = ManualElectrocardiogram.VoltageData( + sessionStartOffsetMillisecond: offsets, lead1: lead1 + ) + + electrocardiogram.append( + ManualElectrocardiogram(electrocardiogram: summary, voltageData: voltageData) + ) + } + + var anchors = [StoredAnchor]() + anchors.appendOptional(anchor) + + return (electrocardiogram, anchors) +} diff --git a/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift b/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift index d8fb349..d25d7f3 100644 --- a/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift +++ b/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift @@ -23,6 +23,7 @@ enum VitalHealthKitClientError: Error { enum AnchoredQueryChunkSize { static let timeseries = 10000 + static let electrocardiogram = 4 static let workout = 5 // IMPORTANT: The current Sleep Session stitching algorithm is not chunkable. static let sleep = HKObjectQueryNoLimit @@ -289,8 +290,8 @@ func read( case .electrocardiogram: - // VIT-7905: To be implemented - return (nil, []) + let payload = try await handleElectrocardiogram(healthKitStore: healthKitStore, vitalStorage: vitalStorage, instruction: instruction) + return (.summary(.electrocardiogram(payload.electrocardiograms)), payload.anchors) case .heartRateAlert: let payload = try await handleHeartRateAlerts(healthKitStore: healthKitStore, vitalStorage: vitalStorage, instruction: instruction)