-
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 (#257)
- Loading branch information
Showing
4 changed files
with
202 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) | ||
} | ||
} | ||
} |
88 changes: 88 additions & 0 deletions
88
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,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) | ||
} |
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