From 8db13214ad694556eb7c5f9dd4affb0ba2543c8b Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Thu, 5 Dec 2024 15:08:59 +0000 Subject: [PATCH] VIT-7905: Introduce VitalResource electrocardiogram, afib_burden and heart_rate_alert cases --- .../Base Models/ResourceData.swift | 7 ++- .../Base Models/VitalResource.swift | 44 +++++++++++++++---- .../HealthKit/Abstractions.swift | 15 +++++++ .../HealthKit/HealthKitReads.swift | 24 ++++++++++ .../Models/CoreModels+Extensions.swift | 2 + .../Models/VitalResource+HealthKit.swift | 42 ++++++++++++++++++ 6 files changed, 123 insertions(+), 11 deletions(-) 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 5d7a617..2870c3e 100644 --- a/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift +++ b/Sources/VitalCore/Core/Client/Data Models/Base Models/ResourceData.swift @@ -82,6 +82,7 @@ public enum TimeSeriesData: Equatable, Encodable { case steps([LocalQuantitySample]) case vo2Max([LocalQuantitySample]) case temperature([LocalQuantitySample]) + case afibBurden([LocalQuantitySample]) public var payload: Encodable { switch self { @@ -91,7 +92,7 @@ public enum TimeSeriesData: Equatable, Encodable { let .nutrition(.water(samples)), let .mindfulSession(samples), let .caloriesActive(samples), let .caloriesBasal(samples), let .distance(samples), let .floorsClimbed(samples), let .steps(samples), let .vo2Max(samples), - let .respiratoryRate(samples), let .temperature(samples): + let .respiratoryRate(samples), let .temperature(samples), let .afibBurden(samples): return samples case let .bloodPressure(samples): @@ -107,7 +108,7 @@ public enum TimeSeriesData: Equatable, Encodable { let .nutrition(.water(samples)), let .mindfulSession(samples), let .caloriesActive(samples), let .caloriesBasal(samples), let .distance(samples), let .floorsClimbed(samples), let .steps(samples), let .vo2Max(samples), - let .respiratoryRate(samples), let .temperature(samples): + let .respiratoryRate(samples), let .temperature(samples), let .afibBurden(samples): return samples.count case let .bloodPressure(samples): @@ -149,6 +150,8 @@ public enum TimeSeriesData: Equatable, Encodable { return "respiratory_rate" case .temperature: return "temperature" + case .afibBurden: + return "afib_burden" } } } diff --git a/Sources/VitalCore/Core/Client/Data Models/Base Models/VitalResource.swift b/Sources/VitalCore/Core/Client/Data Models/Base Models/VitalResource.swift index fa427e9..e2f6252 100644 --- a/Sources/VitalCore/Core/Client/Data Models/Base Models/VitalResource.swift +++ b/Sources/VitalCore/Core/Client/Data Models/Base Models/VitalResource.swift @@ -11,17 +11,22 @@ public enum VitalResource: Equatable, Hashable, Codable { @_spi(VitalSDKInternals) public var priority: Int { switch self { - case .activity, .body, .workout, .menstrualCycle, .profile: - return 0 - case .sleep, .individual(.vo2Max), .vitals(.bloodOxygen), .vitals(.bloodPressure), - .vitals(.glucose), .vitals(.heartRateVariability), - .nutrition(.water), .nutrition(.caffeine), - .vitals(.mindfulSession), .vitals(.temperature), .vitals(.respiratoryRate), .meal: + case .activity, .body, .profile: return 1 + case .sleep, .menstrualCycle, .meal: + return 4 + case .workout, .individual(.vo2Max), .nutrition(.water), .nutrition(.caffeine): + return 8 + case .electrocardiogram, .heartRateAlert, .afibBurden: + return 12 + case .vitals(.bloodOxygen), .vitals(.bloodPressure), + .vitals(.glucose), .vitals(.heartRateVariability), + .vitals(.mindfulSession), .vitals(.temperature), .vitals(.respiratoryRate): + return 16 case .individual(.distanceWalkingRunning), .individual(.steps), .individual(.floorsClimbed): - return 2 + return 32 case .vitals(.heartRate), .individual(.activeEnergyBurned), .individual(.basalEnergyBurned): - return 3 + return 64 case .individual(.exerciseTime), .individual(.weight), .individual(.bodyFat): return Int.max } @@ -76,6 +81,12 @@ public enum VitalResource: Equatable, Hashable, Codable { return .respiratoryRate case .meal: return BackfillType.meal + case .afibBurden: + return .afibBurden + case .electrocardiogram: + return .electrocardiogram + case .heartRateAlert: + return .heartRateAlert } } @@ -171,7 +182,10 @@ public enum VitalResource: Equatable, Hashable, Codable { case individual(Individual) case nutrition(Nutrition) case meal - + case electrocardiogram + case heartRateAlert + case afibBurden + public static var all: [VitalResource] = [ .profile, .body, @@ -201,6 +215,10 @@ public enum VitalResource: Equatable, Hashable, Codable { .nutrition(.water), .nutrition(.caffeine), + + .electrocardiogram, + .heartRateAlert, + .afibBurden, ] public var logDescription: String { @@ -225,6 +243,12 @@ public enum VitalResource: Equatable, Hashable, Codable { return individual.logDescription case let .nutrition(nutrition): return nutrition.logDescription + case .electrocardiogram: + return "electrocardiogram" + case .heartRateAlert: + return "heartRateAlert" + case .afibBurden: + return "afibBurden" } } } @@ -265,4 +289,6 @@ public struct BackfillType: RawRepresentable, Codable, Equatable, Hashable { public static let electrocardiogram = BackfillType(rawValue: "electrocardiogram") public static let temperature = BackfillType(rawValue: "temperature") public static let menstrualCycle = BackfillType(rawValue: "menstrual_cycle") + public static let heartRateAlert = BackfillType(rawValue: "heart_rate_alert") + public static let afibBurden = BackfillType(rawValue: "afib_burden") } diff --git a/Sources/VitalHealthKit/HealthKit/Abstractions.swift b/Sources/VitalHealthKit/HealthKit/Abstractions.swift index 12b41ed..8a39d91 100644 --- a/Sources/VitalHealthKit/HealthKit/Abstractions.swift +++ b/Sources/VitalHealthKit/HealthKit/Abstractions.swift @@ -186,6 +186,15 @@ extension VitalHealthKitStore { HKQuantityType.quantityType(forIdentifier: .dietarySelenium)!: return [.meal] + case + HKCategoryType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!, + HKCategoryType.categoryType(forIdentifier: .lowHeartRateEvent)!, + HKCategoryType.categoryType(forIdentifier: .highHeartRateEvent)!: + return [.heartRateAlert] + + case HKElectrocardiogramType.electrocardiogramType(): + return [.electrocardiogram] + default: if #available(iOS 15.0, *) { switch type { @@ -208,6 +217,12 @@ extension VitalHealthKitStore { HKCategoryType.categoryType(forIdentifier: .irregularMenstrualCycles)!, HKCategoryType.categoryType(forIdentifier: .infrequentMenstrualCycles)!: return [.menstrualCycle] + + + case + HKQuantityType.quantityType(forIdentifier: .atrialFibrillationBurden)!: + return [.afibBurden] + default: break } diff --git a/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift b/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift index fb1f3fe..8b2b0aa 100644 --- a/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift +++ b/Sources/VitalHealthKit/HealthKit/HealthKitReads.swift @@ -287,6 +287,30 @@ func read( return (.timeSeries(.respiratoryRate(payload.samples)), payload.anchors) + + case .electrocardiogram: + // VIT-7905: To be implemented + return (nil, []) + + case .heartRateAlert: + // VIT-7905: To be implemented + return (nil, []) + + case .afibBurden: + if #available(iOS 16.0, *) { + let payload = try await handleTimeSeries( + .atrialFibrillationBurden, + healthKitStore: healthKitStore, + vitalStorage: vitalStorage, + startDate: instruction.query.lowerBound, + endDate: instruction.query.upperBound + ) + + return (.timeSeries(.afibBurden(payload.samples)), payload.anchors) + } else { + return (nil, []) + } + case .individual(.exerciseTime), .individual(.bodyFat), .individual(.weight): throw VitalHealthKitClientError.invalidRemappedResource } diff --git a/Sources/VitalHealthKit/HealthKit/Models/CoreModels+Extensions.swift b/Sources/VitalHealthKit/HealthKit/Models/CoreModels+Extensions.swift index 15fd3c8..73704f6 100644 --- a/Sources/VitalHealthKit/HealthKit/Models/CoreModels+Extensions.swift +++ b/Sources/VitalHealthKit/HealthKit/Models/CoreModels+Extensions.swift @@ -217,6 +217,7 @@ struct QuantityUnit { if #available(iOS 16.0, *) { mapping[.appleSleepingWristTemperature] = .degreeCelsius + mapping[.atrialFibrillationBurden] = .percentage } mapping[.bodyMass] = .kg @@ -289,6 +290,7 @@ struct QuantityUnit { if #available(iOS 16.0, *) { mapping[.appleSleepingWristTemperature] = .degreeCelsius() + mapping[.atrialFibrillationBurden] = .percent() } mapping[.bodyMass] = .gramUnit(with: .kilo) diff --git a/Sources/VitalHealthKit/HealthKit/Models/VitalResource+HealthKit.swift b/Sources/VitalHealthKit/HealthKit/Models/VitalResource+HealthKit.swift index d134f2e..b5e7340 100644 --- a/Sources/VitalHealthKit/HealthKit/Models/VitalResource+HealthKit.swift +++ b/Sources/VitalHealthKit/HealthKit/Models/VitalResource+HealthKit.swift @@ -276,6 +276,25 @@ func toHealthKitTypes(resource: VitalResource) -> HealthKitObjectTypeRequirement ], supplementary: [] ) + + case .electrocardiogram: + return single(HKElectrocardiogramType.electrocardiogramType()) + case .afibBurden: + if #available(iOS 16, *) { + return single(HKQuantityType.quantityType(forIdentifier: .atrialFibrillationBurden)!) + } else { + return HealthKitObjectTypeRequirements(required: [], optional: [], supplementary: []) + } + case .heartRateAlert: + return HealthKitObjectTypeRequirements( + required: [], + optional: [ + HKCategoryType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!, + HKCategoryType.categoryType(forIdentifier: .highHeartRateEvent)!, + HKCategoryType.categoryType(forIdentifier: .lowHeartRateEvent)!, + ], + supplementary: [] + ) } } @@ -306,6 +325,14 @@ func observedSampleTypes() -> [[HKSampleType]] { ]) } + var afibBurdenTypes = [HKSampleType]() + + if #available(iOS 16.0, *) { + afibBurdenTypes = [ + HKQuantityType.quantityType(forIdentifier: .atrialFibrillationBurden)! + ] + } + return [ /// Profile [ @@ -441,6 +468,21 @@ func observedSampleTypes() -> [[HKSampleType]] { [ HKSampleType.quantityType(forIdentifier: .respiratoryRate)! ], + + /// AFib Burden + afibBurdenTypes, + + /// Electrocardiogram + [ + HKElectrocardiogramType.electrocardiogramType() + ], + + /// Heart rate alerts + [ + HKCategoryType.categoryType(forIdentifier: .irregularHeartRhythmEvent)!, + HKCategoryType.categoryType(forIdentifier: .highHeartRateEvent)!, + HKCategoryType.categoryType(forIdentifier: .lowHeartRateEvent)!, + ] ] }