diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/VitalCore.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/VitalCore.xcscheme index 1b0b1dd..a78e682 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/VitalCore.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/VitalCore.xcscheme @@ -30,6 +30,7 @@ Bool { let longerThanThirtyMinutes = diff > 60 * 30 return longerThanThirtyMinutes } + +private func orderByDate(_ values: [QuantitySample]) -> [QuantitySample] { + return values.sorted { $0.startDate < $1.startDate } +} + +func splitPerBundle(_ values: [QuantitySample]) -> [[QuantitySample]] { + var temp: [String: [QuantitySample]] = ["na": []] + + for value in values { + if let bundle = value.sourceBundle { + if temp[bundle] != nil { + var copy = temp[bundle]! + copy.append(value) + temp[bundle] = copy + } else { + temp[bundle] = [value] + } + } else { + var copy = temp["na"]! + copy.append(value) + temp["na"] = copy + } + } + + var outcome: [[QuantitySample]] = [[]] + + for key in temp.keys { + let samples = temp[key]! + outcome.append(samples) + } + + return outcome +} + +private func _accumulate(_ values: [QuantitySample], interval: Int, calendar: Calendar) -> [QuantitySample] { + let ordered = orderByDate(values) + + return ordered.reduce(into: []) { newValues, newValue in + if let lastValue = newValues.last { + + let lastValueHour = calendar.component(.hour, from: lastValue.startDate) + let newValueHour = calendar.component(.hour, from: newValue.startDate) + + guard lastValueHour == newValueHour else { + newValues.append(newValue) + return + } + + let lastValueBucket = Int(calendar.component(.minute, from: lastValue.startDate) / interval) + let newValueBucket = Int(calendar.component(.minute, from: newValue.startDate) / interval) + + guard lastValueBucket == newValueBucket else { + newValues.append(newValue) + return + } + + var lastValue = newValues.removeLast() + lastValue.value = lastValue.value + newValue.value + lastValue.endDate = newValue.endDate + + newValues.append(lastValue) + + } else { + newValues.append(newValue) + } + } +} + +func accumulate(_ values: [QuantitySample], interval: Int = 15, calendar: Calendar) -> [QuantitySample] { + let split = splitPerBundle(values) + let outcome = split.flatMap { + _accumulate($0, interval: interval, calendar: calendar) + } + + return orderByDate(outcome) +} + + +private func _average(_ values: [QuantitySample], calendar: Calendar) -> [QuantitySample] { + let ordered = orderByDate(values) + + guard ordered.count > 2 else { + return values + } + + /// We want to preserve these values, instead of averaging them. + var min: QuantitySample? = nil + var max: QuantitySample? = nil + // var copy = ordered + + for value in values { + if let minValue = min { + if value.value <= minValue.value { + min = value + } + } else { + min = value + } + + if let maxValue = max { + if value.value >= maxValue.value { + max = value + } + } else { + max = value + } + } + + class Payload { + var samples: [QuantitySample] = [] + var total: Double = 0 + var totalGrouped: Double = 0 + var count = 0 + } + + var outcome: [QuantitySample] = ordered.reduce(into: Payload()) { payload, newValue in + payload.count = payload.count + 1 + + if let lastValue = payload.samples.last { + + let time = calendar.date(byAdding: .second, value: 5, to: lastValue.startDate)! + if time >= newValue.startDate { + var lastValue = payload.samples.removeLast() + + payload.total = payload.total + newValue.value + payload.totalGrouped = payload.totalGrouped + 1 + lastValue.endDate = newValue.endDate + + if payload.count == ordered.count { + lastValue.value = payload.total / payload.totalGrouped + } + + payload.samples.append(lastValue) + } else { + var lastValue = payload.samples.removeLast() + lastValue.value = payload.total / payload.totalGrouped + payload.samples.append(lastValue) + + payload.total = newValue.value + payload.totalGrouped = 1 + + payload.samples.append(newValue) + } + + } else { + payload.total = newValue.value + payload.totalGrouped = payload.totalGrouped + 1 + payload.samples.append(newValue) + } + }.samples + + if let max = max { + outcome.append(max) + } + + if let min = min { + outcome.append(min) + } + + return outcome +} + +func average(_ values: [QuantitySample], calendar: Calendar) -> [QuantitySample] { + let split = splitPerBundle(values) + let outcome = split.flatMap { + _average($0, calendar: calendar) + } + + return orderByDate(outcome) +} diff --git a/Sources/VitalHealthKit/HealthKit/VitalHealthKitClient.swift b/Sources/VitalHealthKit/HealthKit/VitalHealthKitClient.swift index e3588cf..4b0c370 100644 --- a/Sources/VitalHealthKit/HealthKit/VitalHealthKitClient.swift +++ b/Sources/VitalHealthKit/HealthKit/VitalHealthKitClient.swift @@ -39,7 +39,7 @@ public class VitalHealthKitClient { private let backgroundDeliveryEnabled: ProtectedBox = .init(value: false) let configuration: ProtectedBox - + public var status: AnyPublisher { return _status.eraseToAnyPublisher() } @@ -67,7 +67,7 @@ public class VitalHealthKitClient { public static func configure( _ configuration: Configuration = .init() ) async { - await self.shared.setConfiguration(configuration: configuration) + await self.shared.setConfiguration(configuration: configuration) } public static func automaticConfiguration() async { @@ -198,7 +198,7 @@ extension VitalHealthKitClient { for sampleType in sampleTypes { let query = HKObserverQuery(sampleType: sampleType, predicate: nil) {[weak self] query, handler, error in - + guard error == nil else { self?.logger?.error("Failed to background deliver for \(String(describing: sampleType)).") @@ -251,7 +251,7 @@ extension VitalHealthKitClient { _status.send(.syncingCompleted) } } - + public enum SyncPayload { case type(HKSampleType) case resource(VitalResource) @@ -346,9 +346,11 @@ extension VitalHealthKitClient { /// Make sure the user has a connected source set up try await vitalClient.checkConnectedSource(.appleHealthKit) + let transformedData = transform(data: data, calendar: Calendar.autoupdatingCurrent) + // Post data try await vitalClient.post( - data, + transformedData, stage, .appleHealthKit, TimeZone.autoupdatingCurrent @@ -410,3 +412,62 @@ extension VitalHealthKitClient { store.hasAskedForPermission(resource) } } + + +func transform(data: PostResourceData, calendar: Calendar) -> PostResourceData { + switch data { + case let .summary(.activity(patch)): + let activities = patch.activities.map { activity in + ActivityPatch.Activity( + activeEnergyBurned: accumulate(activity.activeEnergyBurned, calendar: calendar), + basalEnergyBurned: accumulate(activity.basalEnergyBurned, calendar: calendar), + steps: accumulate(activity.steps, calendar: calendar), + floorsClimbed: accumulate(activity.floorsClimbed, calendar: calendar), + distanceWalkingRunning: accumulate(activity.distanceWalkingRunning, calendar: calendar), + vo2Max: accumulate(activity.vo2Max, calendar: calendar) + ) + } + + return .summary(.activity(ActivityPatch(activities: activities))) + + case let .summary(.workout(patch)): + let workouts = patch.workouts.map { workout in + WorkoutPatch.Workout( + id: workout.id, + startDate: workout.startDate, + endDate: workout.endDate, + sourceBundle: workout.sourceBundle, + productType: workout.productType, + sport: workout.sport, + calories: workout.calories, + distance: workout.distance, + heartRate: average(workout.heartRate, calendar: calendar), + respiratoryRate: average(workout.respiratoryRate, calendar: calendar) + ) + } + + return .summary(.workout(WorkoutPatch(workouts: workouts))) + + case let.summary(.sleep(patch)): + let sleep = patch.sleep.map { sleep in + SleepPatch.Sleep.init( + id: sleep.id, + startDate: sleep.startDate, + endDate: sleep.endDate, + sourceBundle: sleep.sourceBundle, + productType: sleep.productType, + heartRate: average(sleep.heartRate, calendar: calendar), + restingHeartRate: average(sleep.restingHeartRate, calendar: calendar), + heartRateVariability: average(sleep.heartRateVariability, calendar: calendar), + oxygenSaturation: average(sleep.oxygenSaturation, calendar: calendar), + respiratoryRate: average(sleep.respiratoryRate, calendar: calendar) + ) + } + + return .summary(.sleep(SleepPatch(sleep: sleep))) + case .summary(.body), .summary(.profile): + return data + case .timeSeries: + return data + } +} diff --git a/Tests/VitalHealthKitTests/VitalHealthKitFreeFunctions.swift b/Tests/VitalHealthKitTests/VitalHealthKitFreeFunctions.swift new file mode 100644 index 0000000..7280b4a --- /dev/null +++ b/Tests/VitalHealthKitTests/VitalHealthKitFreeFunctions.swift @@ -0,0 +1,161 @@ +import XCTest +import HealthKit + +@testable import VitalHealthKit +@testable import VitalCore + +class VitalHealthKitFreeFunctionsTests: XCTestCase { + + func testAccumulate() { + let calendar = Calendar.current + let date = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date())! + + let s1: QuantitySample = .init(value: 10, date: date, unit: "") + let s2: QuantitySample = .init(value: 10, date: date.adding(minutes: 14), unit: "") + + let s3: QuantitySample = .init(value: 5, date: date.adding(minutes: 15), unit: "") + let s4: QuantitySample = .init(value: 5, date: date.adding(minutes: 29), unit: "") + + let s5: QuantitySample = .init(value: 1, date: date.adding(minutes: 30), unit: "") + let s6: QuantitySample = .init(value: 1, date: date.adding(minutes: 44), unit: "") + + let s7: QuantitySample = .init(value: 2, date: date.adding(minutes: 45), unit: "") + let s8: QuantitySample = .init(value: 2, date: date.adding(minutes: 59), unit: "") + + let s9: QuantitySample = .init(value: 1, date: date.adding(minutes: 60), unit: "") + + func assert(array: [QuantitySample]) { + XCTAssertTrue(array.count == 5) + XCTAssertTrue(array[0].value == 20) + XCTAssertTrue(array[1].value == 10) + XCTAssertTrue(array[2].value == 2) + XCTAssertTrue(array[3].value == 4) + XCTAssertTrue(array[4].value == 1) + } + + let array1 = accumulate([s1, s2, s3, s4, s5, s6, s7, s8, s9], calendar: calendar) + assert(array: array1) + + let array2 = accumulate([s4, s8, s3, s6, s7, s9, s5, s2, s1], calendar: calendar) + assert(array: array2) + } + + func testAccumulatePerBundle() { + let calendar = Calendar.current + let date = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date())! + + let s1: QuantitySample = .init(value: 10, date: date, sourceBundle: "1", unit: "") + let s2: QuantitySample = .init(value: 10, date: date.adding(minutes: 14), sourceBundle: "1", unit: "") + + let s3: QuantitySample = .init(value: 5, date: date.adding(minutes: 15), sourceBundle: "1", unit: "") + let s4: QuantitySample = .init(value: 5, date: date.adding(minutes: 29), sourceBundle: "1", unit: "") + + let s5: QuantitySample = .init(value: 1, date: date.adding(minutes: 30), sourceBundle: "2", unit: "") + let s6: QuantitySample = .init(value: 1, date: date.adding(minutes: 44), sourceBundle: "2", unit: "") + + let s7: QuantitySample = .init(value: 1, date: date.adding(minutes: 45), sourceBundle: "3", unit: "") + let s8: QuantitySample = .init(value: 2, date: date.adding(minutes: 50), sourceBundle: "3", unit: "") + let s9: QuantitySample = .init(value: 3, date: date.adding(minutes: 59), sourceBundle: "3", unit: "") + + + func assert(array: [QuantitySample]) { + XCTAssertTrue(array.count == 4) + XCTAssertTrue(array[0].value == 20) + XCTAssertTrue(array[1].value == 10) + XCTAssertTrue(array[2].value == 2) + XCTAssertTrue(array[3].value == 6) + } + + let array1 = accumulate([s1, s2, s3, s4, s5, s6, s7, s8, s9], calendar: calendar) + assert(array: array1) + + let array2 = accumulate([s4, s8, s3, s6, s7, s9, s5, s2, s1], calendar: calendar) + assert(array: array2) + } + + func testAverageTwoElements() { + let calendar = Calendar.current + let date = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date())! + + let s1: QuantitySample = .init(value: 12, date: date, unit: "") + let s2: QuantitySample = .init(value: 10, date: date.adding(seconds: 1), unit: "") + + let array = average([s1, s2], calendar: calendar) + + XCTAssertTrue(array.count == 2) + XCTAssertTrue(array[0].value == 12) + XCTAssertTrue(array[1].value == 10) + } + + func testAverage() { + let calendar = Calendar.current + let date = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date())! + + let s1: QuantitySample = .init(value: 12, date: date, unit: "") + let s2: QuantitySample = .init(value: 1, date: date.adding(seconds: 1), unit: "") + let s3: QuantitySample = .init(value: 15, date: date.adding(seconds: 3), unit: "") + let s4: QuantitySample = .init(value: 11, date: date.adding(seconds: 4), unit: "") + + let s5: QuantitySample = .init(value: 10, date: date.adding(seconds: 6), unit: "") + let s6: QuantitySample = .init(value: 11, date: date.adding(seconds: 7), unit: "") + let s7: QuantitySample = .init(value: 12, date: date.adding(seconds: 8), unit: "") + let s8: QuantitySample = .init(value: 13, date: date.adding(seconds: 9), unit: "") + + func assert(array: [QuantitySample]) { + XCTAssertTrue(array.count == 4) + XCTAssertTrue(array[0].value == 9.75) + XCTAssertTrue(array[1].value == 1) + XCTAssertTrue(array[2].value == 15) + XCTAssertTrue(array[3].value == 11.5) + } + + let array1 = average([s1, s2, s3, s4, s5, s6, s7, s8], calendar: calendar) + assert(array: array1) + + let array2 = average([s3, s7, s1, s4, s8, s6, s2, s5], calendar: calendar) + assert(array: array2) + } + + func testAveragePerBundle() { + let calendar = Calendar.current + let date = calendar.date(bySettingHour: 9, minute: 0, second: 0, of: Date())! + + let s1: QuantitySample = .init(value: 12, date: date, sourceBundle: "1", unit: "") + let s2: QuantitySample = .init(value: 1, date: date.adding(seconds: 1), sourceBundle: "1", unit: "") + let s3: QuantitySample = .init(value: 15, date: date.adding(seconds: 3), sourceBundle: "1", unit: "") + let s4: QuantitySample = .init(value: 11, date: date.adding(seconds: 4), sourceBundle: "1", unit: "") + + let s5: QuantitySample = .init(value: 9, date: date.adding(seconds: 6), sourceBundle: "2", unit: "") + let s6: QuantitySample = .init(value: 11, date: date.adding(seconds: 7), sourceBundle: "2", unit: "") + let s7: QuantitySample = .init(value: 7, date: date.adding(seconds: 8), sourceBundle: "2", unit: "") + + let s8: QuantitySample = .init(value: 13, date: date.adding(seconds: 9), sourceBundle: "3", unit: "") + + func assert(array: [QuantitySample]) { + XCTAssertTrue(array.count == 7) + XCTAssertTrue(array[0].value == 9.75) + XCTAssertTrue(array[1].value == 1) + XCTAssertTrue(array[2].value == 15) + XCTAssertTrue(array[3].value == 9) + XCTAssertTrue(array[4].value == 11) + XCTAssertTrue(array[5].value == 7) + XCTAssertTrue(array[6].value == 13) + } + + let array1 = average([s1, s2, s3, s4, s5, s6, s7, s8], calendar: calendar) + assert(array: array1) + + let array2 = average([s3, s7, s1, s4, s8, s6, s2, s5], calendar: calendar) + assert(array: array2) + } +} + +extension Date { + func adding(minutes: Int) -> Date { + return Calendar.current.date(byAdding: .minute, value: minutes, to: self)! + } + + func adding(seconds: Int) -> Date { + return Calendar.current.date(byAdding: .second, value: seconds, to: self)! + } +}