In our last tutorial we looked at the HKStatisticsQuery which is used to perform statistical calculations on sets of matching samples. In todays tutorial we will investigate the HKStatisticsCollectionQuery.
This particular query is useful for creating graphs. Unlike the HKStatisticsQuery which could only run one statistical calculation on one set of matching samples, the HKStatisticsCollectionQuery can perform these calculations over a series of fixed-length time intervals. This allows us to query samples such as step counts and fetch the cumulative sum in 1 hour intervals over a period of time set by the predicate.
This particular query can also be long-lived if needed, meaning that any changes made in Apple Health will be recognised, and you will be notified so that you can act.
Lets first begin by downloading the starter project which has the initial setup of HealthKit already sorted. If you want to learn how to setup HealthKit in your app then click that link.
The HKStatisticsCollectionQuery Anchor and Interval
The anchor is an important part of the HKStatisticsCollectionQuery. When we initialise the query we need to specify an anchor. The anchor defines a moment in time where we are to calculate our samples from. Likewise, the interval is also important. We need to setup the query to work on specific time intervals in order for it to calculate based on that interval.
The anchor date is important if you want weekly step counts from Monday to Sunday. If that’s the case, you would set it for a Monday, and then set the interval at 7 days. However, it doesn’t matter what Monday you set. You could set it for a Monday in 1970 as much as you could set it for a Monday in 2050. The query runs on the specific interval set extending away both in the past and in the future from when the anchor was set.
But likewise, you might be interested in hourly intervals on the hour. In that case, any date would be OK, and your focus would just be on the time of the interval such as 3pm as the interval would then calculate in hourly intervals from that moment in time.
HKStatisticsCollectionQuery Example
Lets work with step count in this tutorial so that we can test different intervals and do a cumulative sum on hourly, daily, weekly, monthly, and more step counts. We will also do similar for body mass further on in the tutorial so that you can see both cumulative and discrete statistics calculations being performed.
func testStatisticsCollectionQueryCumulitive() { guard let stepCountType = HKObjectType.quantityType(forIdentifier: .stepCount) else { fatalError("*** Unable to get the step count type ***") } var interval = DateComponents() interval.hour = 1 let calendar = Calendar.current let anchorDate = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: Date()) let query = HKStatisticsCollectionQuery.init(quantityType: stepCountType, quantitySamplePredicate: nil, options: .cumulativeSum, anchorDate: anchorDate!, intervalComponents: interval) query.initialResultsHandler = { query, results, error in let startDate = calendar.startOfDay(for: Date()) results?.enumerateStatistics(from: startDate, to: Date(), with: { (result, stop) in print("Time: \(result.startDate), \(result.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0)") }) } healthStore.execute(query) }
Lines 2-4 is the usual drill of getting the stepCount quantity type.
On lines 6 and 7 we create a variable we call interval of type DateComponents and then set the .hour property to 1 hour. If you want to use 24 hours, a month, or any other range, you can interchange the .hour to .minute, .day, .month, etc… The code we will use later would be best suited to intervals less than a few hours.
On line 9 we get the current calendar, and then on line 10 we create an anchor date which is based on today with the hour set at 12, minutes at 0 and seconds also at 0. For this particular query of hourly step data the main focus is on hours, minutes, and seconds though as we are not working based on a particular day.
On line 12 we begin creating the query. We pass in our quantity type followed by the predicate which in these examples we will use nil. We set our option to a cumulative sum which is the correct type of option for a step count (see the discussion here). If we used body Mass, then the description for that indicates that we use discrete values and thus, our available options are a little different.
The previous HealthKit queries we have created, HKSampleQuery, HKAnchoredObjectQuery, and HKStatisticsQuery, each declare the result handler block when the init method. The HKStatisticsCollectionQuery offers two results handlers. We have the option of using one or both of them; but we set the property separately as seen on line 18 onwards.
The example above uses the initialResultsHandler which has the query, results (which are of type HKStatisticsCollection, marked as optional), and an error.
On line 21 I though it would be good to fetch just todays hourly step count, so for this reason I set a startDate at the beginning of today (ie, calendar.startOfDay). I simply passed in the current time/date as Date() for it to provide the beginning of today.
We then have the option of enumerating through the statistics. We do that on line 23 where we get the optional results and use the instance method called enumerateStatistics(from:to:with:).
We provide the start date. I then set the end date to now.
This particular method declares a block which passes back the result and stop. We won’t use stop in this tutorial, but we do use the result which contains all the data we need.
On line 25 we print the start date of result, along with the double value of the sumQuantity.
Because we set the options on line 14 as .cumulativeSum, we see an hourly sum of all step counts printed to the log. They are offset according to your timezone of which you will need to adapt to when using the results in a live app.
Other HKStatisticsCollectionQuery Options
I mentioned above that we set the options to .cumulativeSum for the query. This adds all step counts stored for each interval and provides us with the sum. The other available option we can use is .seperateBySource which can be used with the cumulativeSum. If we opt to use that, switch out line 14 to contain the following: options: [.cumulativeSum, .separateBySource],.
This calculates the sum based on each source. You still have access to the total sum that we used, but that result also contains an array of sources. Each source is a different source of data. If you have an Apple Watch then you will have your iPhone as a source, and then your Apple Watch as another source. You might also have other sources of step count samples such as from FitBit, or perhaps a step counting app. Each of these can provide separate results. An example of this is below:
query.initialResultsHandler = { query, results, error in let startDate = calendar.startOfDay(for: Date()) print("Cumulative Sum") results?.enumerateStatistics(from: startDate, to: Date(), with: { (result, stop) in print("Time: \(result.startDate), \(result.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0)") }) print("By Source") for source in (results?.sources())! { print("Next Device: \(source.name)") results?.enumerateStatistics(from: startDate, to: Date(), with: { (result, stop) in print("\(result.startDate): \(result.sumQuantity(for: source)?.doubleValue(for: HKUnit.count()) ?? 0)") }) } } healthStore.execute(query)
Some changes have been made. The first was mentioned above where you changed line 14 (the options) to use options: [.cumulativeSum, .separateBySource],.
The next changes begin on line 12 (although I did add in line 6 above to provide an indicator of what is being logged to the console). Line 12 we print another helper.
Line 13: We learn from the documentation that results contains sources which are a Set of HKSource. These are present regardless of .separateBySource being set as an option or not. If you don’t set that option, the quantitySum of each source will be nil. What we do here is a for-in around the sources and store each in source.
Line 14 we print “Next Device: ” followed by the source.name which will be something like “Matthews Apple Watch”.
On line 15 we enumerate through the results. We do this because we are looking for each result, and then that result will contain a source which will contain the interval samples. We access these on line 17 with the following:
result.sumQuantity(for: source)?.doubleValue(for: HKUnit.count()) ?? 0
So we first get the result. We then use the maximumQuantity(for:) instance method which requires a HKSource. The HKSource is available from line 13 (called source). We then fetch the doubleValue and provide the unit as a HKUnit.count().
What this does is enumerate through each source for each of the available hours. You might notice that you have 2 iPhones and a few Apple Watches. This, in my case, was because I have reset my watch twice. It is viewed as a new source by Apple. I left it logging to the screen, even if there was no value. This simply means that the previous Watch hasn’t logged steps today.
Go ahead and run the app now and you should see your cumulative sum of all step counts combined, followed by a breakdown/hour of each source.
A Look at Discrete Values with Body Mass
I wanted to next show you how to use bodyMass to calculate discrete values such as discreteAverage showing the average for all of your data based on a month to month interval. To do this we use the following code:
func testStatisticsCollectionQueryDiscrete() { guard let bodyMassType = HKObjectType.quantityType(forIdentifier: .bodyMass) else { fatalError("*** Unable to get the body mass type ***") } var interval = DateComponents() interval.month = 1 let calendar = Calendar.current let components = calendar.dateComponents([.year, .month], from: Date()) let startOfMonth = calendar.date(from: components) let anchorDate = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: startOfMonth!) let query = HKStatisticsCollectionQuery.init(quantityType: bodyMassType, quantitySamplePredicate: nil, options: [.discreteAverage, .separateBySource], anchorDate: anchorDate!, intervalComponents: interval) query.initialResultsHandler = { query, results, error in let date = calendar.startOfDay(for: Date()) let startDate = Calendar.current.date(byAdding: .year, value: -5, to: date) print("Cumulative Sum") results?.enumerateStatistics(from: startDate!, to: Date(), with: { (result, stop) in print("Month: \(result.startDate), \(result.averageQuantity()?.doubleValue(for: HKUnit.pound()) ?? 0)lbs") }) print("By Source") for source in (results?.sources())! { print("Next Device: \(source.name)") results?.enumerateStatistics(from: startDate!, to: Date(), with: { (result, stop) in print("\(result.startDate): \(result.averageQuantity(for: source)?.doubleValue(for: HKUnit.pound()) ?? 0)lbs") }) } } healthStore.execute(query) }
Lines 2-4 we create our quantity type, which in this case is bodyMass.
Line 6 we create our interval, and then on line 7 we specify that we want the interval to be 1 month.
On lines 9-13 we get the current calendar and then create a date at the start of the month. We use a method of Calendar to specify an hour, minute, and second, and then provide it with the start of the month date to get the final date which is some time in the first day of the month.
On line 15 we begin the HKStatisticsCollectionQuery by providing the bodyMassType we created as the quantity type. We do not use a predicate here. The options I selected are discreteAverage, and separatedBySource. We then pass in our anchor, and pass in the interval we created.
Just like the results handler from the stepCount example, we also create the same here. On line 21 I create a date, and then line 24 I create the startDate which is 5 years before the current date. You may adjust this if Apple Health contains data older than 5 years.
On line 31 the main change is switching the result to averageQuantity and then specifying HKUnit.pound() for the unit we want. We also repeat the same change on line 39.
When we run the app now, you will get the average weight you recorded in Apple Health split in to months. The average weight calculates from start date to end date of each month.
The Long-Running statisticsUpdateHandler
The implementation of the long-running statisticsUpdateHandler is almost identical to the single run version that was demonstrated just above. It hands you a HKStatistics object which is an extra compared to the single run version, but overall, it works in the same way with the difference being that once you set the property it will run automatically while your app is in the foreground. If your weight data or step count data changes then you will be notified of the change. You can then configure your app to handle that change whether it be a deletion of a sample or an addition of a sample. Some sample code to get you started is below:
query.statisticsUpdateHandler = { query, statistics, statisticsCollection, error in print(query) print(statistics) print(statisticsCollection) print(error) }
If you paste in the above code in the stepCount section just above line 43, then each time step counts are recorded, you will see a log in the console letting you know that a change has been made.
Closing
The HKStatisticsCollectionQuery is quite powerful and lets you quite quickly query lots of data and run some statistical analysis over those samples of data. Just like the HKStatisticsQuery from a previous tutorial, you can get averages, max, min, as well as cumulative sum of quantity samples. The benefit with the Collection version of this query is that you can set up an interval with the anchor and then retrieve information based on the specified interval. That might be hourly, daily, weekly, monthly, or some other period of time. You then receive those statistics through the update handler at which point you can update your user interface.
The full code is below, and the project can be downloaded from here.
import UIKit import HealthKit class ViewController: UIViewController { let healthStore = HKHealthStore() override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. if HKHealthStore.isHealthDataAvailable() { let readDataTypes : Set = [HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)!, HKObjectType.characteristicType(forIdentifier: HKCharacteristicTypeIdentifier.biologicalSex)!, HKObjectType.characteristicType(forIdentifier: HKCharacteristicTypeIdentifier.dateOfBirth)!, HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bodyMass)!] let writeDataTypes : Set = [HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount)!] healthStore.requestAuthorization(toShare: writeDataTypes, read: readDataTypes) { (success, error) in if !success { // Handle the error here. } else { //self.testCharachteristic() //self.testSampleQuery() //self.testSampleQueryWithPredicate() //self.testSampleQueryWithSortDescriptor() //self.testAnchoredQuery() //self.testStatisticsQueryCumulitive() //self.testStatisticsQueryDiscrete() self.testStatisticsCollectionQueryCumulitive() self.testStatisticsCollectionQueryDiscrete() } } } } // Fetches biologicalSex of the user, and date of birth. func testCharachteristic() { // Biological Sex if try! healthStore.biologicalSex().biologicalSex == HKBiologicalSex.female { print("You are female") } else if try! healthStore.biologicalSex().biologicalSex == HKBiologicalSex.male { print("You are male") } else if try! healthStore.biologicalSex().biologicalSex == HKBiologicalSex.other { print("You are not categorised as male or female") } // Date of Birth if #available(iOS 10.0, *) { try! print(healthStore.dateOfBirthComponents()) } else { // Fallback on earlier versions do { let dateOfBirth = try healthStore.dateOfBirth() print(dateOfBirth) } catch let error { print("There was a problem fetching your data: \(error)") } } } // HKSampleQuery with a nil predicate func testSampleQuery() { // Simple Step count query with no predicate and no sort descriptors let sampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.stepCount) let query = HKSampleQuery.init(sampleType: sampleType!, predicate: nil, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { (query, results, error) in print(results) } healthStore.execute(query) } // HKSampleQuery with a predicate func testSampleQueryWithPredicate() { let sampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bodyMass) let today = Date() let startDate = Calendar.current.date(byAdding: .month, value: -3, to: today) let predicate = HKQuery.predicateForSamples(withStart: startDate, end: today, options: HKQueryOptions.strictEndDate) let query = HKSampleQuery.init(sampleType: sampleType!, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: nil) { (query, results, error) in print(results) } healthStore.execute(query) } // Sample query with a sort descriptor func testSampleQueryWithSortDescriptor() { let sampleType = HKSampleType.quantityType(forIdentifier: HKQuantityTypeIdentifier.bodyMass) let today = Date() let startDate = Calendar.current.date(byAdding: .month, value: -3, to: today) let predicate = HKQuery.predicateForSamples(withStart: startDate, end: today, options: HKQueryOptions.strictEndDate) let query = HKSampleQuery.init(sampleType: sampleType!, predicate: predicate, limit: HKObjectQueryNoLimit, sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)]) { (query, results, error) in print(results) } healthStore.execute(query) } func testAnchoredQuery() { guard let bodyMassType = HKObjectType.quantityType(forIdentifier: .bodyMass) else { fatalError("*** Unable to get the body mass type ***") } var anchor = HKQueryAnchor.init(fromValue: 0) if UserDefaults.standard.object(forKey: "Anchor") != nil { let data = UserDefaults.standard.object(forKey: "Anchor") as! Data anchor = NSKeyedUnarchiver.unarchiveObject(with: data) as! HKQueryAnchor } let query = HKAnchoredObjectQuery(type: bodyMassType, predicate: nil, anchor: anchor, limit: HKObjectQueryNoLimit) { (query, samplesOrNil, deletedObjectsOrNil, newAnchor, errorOrNil) in guard let samples = samplesOrNil, let deletedObjects = deletedObjectsOrNil else { fatalError("*** An error occurred during the initial query: \(errorOrNil!.localizedDescription) ***") } anchor = newAnchor! let data : Data = NSKeyedArchiver.archivedData(withRootObject: newAnchor as Any) UserDefaults.standard.set(data, forKey: "Anchor") for bodyMassSample in samples { print("Samples: \(bodyMassSample)") } for deletedBodyMassSample in deletedObjects { print("deleted: \(deletedBodyMassSample)") } print("Anchor: \(anchor)") } query.updateHandler = { (query, samplesOrNil, deletedObjectsOrNil, newAnchor, errorOrNil) in guard let samples = samplesOrNil, let deletedObjects = deletedObjectsOrNil else { // Handle the error here. fatalError("*** An error occurred during an update: \(errorOrNil!.localizedDescription) ***") } anchor = newAnchor! let data : Data = NSKeyedArchiver.archivedData(withRootObject: newAnchor as Any) UserDefaults.standard.set(data, forKey: "Anchor") for bodyMassSample in samples { print("samples: \(bodyMassSample)") } for deletedBodyMassSample in deletedObjects { print("deleted: \(deletedBodyMassSample)") } } healthStore.execute(query) } func testStatisticsQueryCumulitive() { guard let stepCountType = HKObjectType.quantityType(forIdentifier: .stepCount) else { fatalError("*** Unable to get the step count type ***") } let query = HKStatisticsQuery.init(quantityType: stepCountType, quantitySamplePredicate: nil, options: [HKStatisticsOptions.cumulativeSum, HKStatisticsOptions.separateBySource]) { (query, results, error) in print("Total: \(results?.sumQuantity()?.doubleValue(for: HKUnit.count()))") for source in (results?.sources)! { print("Seperate Source: \(results?.sumQuantity(for: source)?.doubleValue(for: HKUnit.count()))") } } healthStore.execute(query) } func testStatisticsQueryDiscrete() { guard let bodyMassType = HKObjectType.quantityType(forIdentifier: .bodyMass) else { fatalError("*** Unable to get the body mass type ***") } let query = HKStatisticsQuery.init(quantityType: bodyMassType, quantitySamplePredicate: nil, options: [HKStatisticsOptions.discreteMax, HKStatisticsOptions.separateBySource]) { (query, results, error) in print("Total: \(results?.maximumQuantity()?.doubleValue(for: HKUnit.pound()))") for source in (results?.sources)! { print("Seperate Source: \(results?.maximumQuantity(for: source)?.doubleValue(for: HKUnit.pound()))") } } healthStore.execute(query) } func testStatisticsCollectionQueryCumulitive() { guard let stepCountType = HKObjectType.quantityType(forIdentifier: .stepCount) else { fatalError("*** Unable to get the step count type ***") } var interval = DateComponents() interval.hour = 1 let calendar = Calendar.current let anchorDate = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: Date()) let query = HKStatisticsCollectionQuery.init(quantityType: stepCountType, quantitySamplePredicate: nil, options: [.cumulativeSum, .separateBySource], anchorDate: anchorDate!, intervalComponents: interval) query.initialResultsHandler = { query, results, error in let startDate = calendar.startOfDay(for: Date()) print("Cumulative Sum") results?.enumerateStatistics(from: startDate, to: Date(), with: { (result, stop) in print("Time: \(result.startDate), \(result.sumQuantity()?.doubleValue(for: HKUnit.count()) ?? 0)") }) print("By Source") for source in (results?.sources())! { print("Next Device: \(source.name)") results?.enumerateStatistics(from: startDate, to: Date(), with: { (result, stop) in print("\(result.startDate): \(result.sumQuantity(for: source)?.doubleValue(for: HKUnit.count()) ?? 0)") }) } } query.statisticsUpdateHandler = { query, statistics, statisticsCollection, error in print(query) print(statistics) print(statisticsCollection) print(error) } healthStore.execute(query) } func testStatisticsCollectionQueryDiscrete() { guard let bodyMassType = HKObjectType.quantityType(forIdentifier: .bodyMass) else { fatalError("*** Unable to get the body mass type ***") } var interval = DateComponents() interval.month = 1 let calendar = Calendar.current let components = calendar.dateComponents([.year, .month], from: Date()) let startOfMonth = calendar.date(from: components) let anchorDate = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: startOfMonth!) let query = HKStatisticsCollectionQuery.init(quantityType: bodyMassType, quantitySamplePredicate: nil, options: [.discreteAverage, .separateBySource], anchorDate: anchorDate!, intervalComponents: interval) query.initialResultsHandler = { query, results, error in let date = calendar.startOfDay(for: Date()) let startDate = Calendar.current.date(byAdding: .year, value: -5, to: date) print("Cumulative Sum") results?.enumerateStatistics(from: startDate!, to: Date(), with: { (result, stop) in print("Month: \(result.startDate), \(result.averageQuantity()?.doubleValue(for: HKUnit.pound()) ?? 0)lbs") }) print("By Source") for source in (results?.sources())! { print("Next Device: \(source.name)") results?.enumerateStatistics(from: startDate!, to: Date(), with: { (result, stop) in print("\(result.startDate): \(result.averageQuantity(for: source)?.doubleValue(for: HKUnit.pound()) ?? 0)lbs") }) } } healthStore.execute(query) } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } }
Leave a Reply
You must be logged in to post a comment.