Skip to content

Commit

Permalink
VIT-7905: Implement electrocardiogram sync (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
andersio authored Dec 12, 2024
1 parent 18a11d0 commit 1aa2013
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand All @@ -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
}
}

Expand All @@ -224,6 +229,8 @@ public enum SummaryData: Equatable, Encodable {
return "menstrual_cycle"
case .meal:
return "meal"
case .electrocardiogram:
return "electrocardiogram"
}
}
}
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)
}
}
}
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)
}
5 changes: 3 additions & 2 deletions Sources/VitalHealthKit/HealthKit/HealthKitReads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 1aa2013

Please sign in to comment.