-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
VIT-7905: Implement electrocardiogram sync
- Loading branch information
Showing
4 changed files
with
200 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
104 changes: 104 additions & 0 deletions
104
Sources/VitalCore/Core/Client/Data Models/Patches/LocalElectrocardiogram.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} | ||
} |
86 changes: 86 additions & 0 deletions
86
Sources/VitalHealthKit/HealthKit/HealthKitReads+Electrocardiogram.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
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 stream = AsyncThrowingStream<(TimeInterval, Double?), any Error> { continuation in | ||
let query = HKElectrocardiogramQuery(ecg) { query, result in | ||
switch result { | ||
case .done: | ||
continuation.finish() | ||
case let .error(error): | ||
continuation.finish(throwing: error) | ||
case let .measurement(measurement): | ||
continuation.yield(( | ||
measurement.timeSinceSampleStart, | ||
measurement.quantity(for: .appleWatchSimilarToLeadI)? | ||
.doubleValue(for: .voltUnit(with: .milli))) | ||
) | ||
@unknown default: | ||
break | ||
} | ||
} | ||
|
||
healthKitStore.execute(query) | ||
} | ||
|
||
let (offsets, lead1) = try await stream.reduce(into: ([Int](), [Double?]())) { accumulator, value in | ||
let (offset, value) = value | ||
// seconds -> milliseconds | ||
accumulator.0.append(Int(offset * 1000)) | ||
// millivolt | ||
accumulator.1.append(value) | ||
} | ||
|
||
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters