Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

VIT-7905: Implement electrocardiogram sync #257

Merged
merged 2 commits into from
Dec 12, 2024
Merged
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
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