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

Incorporate sleep data into complication user info transfer calculations #1217

Merged
merged 23 commits into from
Feb 4, 2020

Conversation

novalegra
Copy link
Collaborator

As discussed in #816 and #832, complication transfer intervals could be optimized if bedtime was incorporated into the calculations, as this would make the complication updates more likely to occur during waking hours.

This implementation uses HealthKit sleep data in order to determine "bedtime" without adding additional settings to Loop. This adds an extra HealthKit permission, for read access to sleep data. A HealthKit query for this data is made every 24 hours and uses a maximum of the most recent 30 samples from the past 6 months. The start times of these sleep samples are then averaged to arrive at the final "bedtime". complicationUserInfoTransferInterval was changed from a variable to a function to allow for bedtime to be incorporated. The time until complication allowance refresh is calculated with bedtime - now if bedtime is in the future, or midnight - now if the bedtime is in the past. If there is no bedtime (bedtime is nil), the calculations are the same as in the current implementation.

@scottleibrand
Copy link

Does the Apple Watch or iOS passively track sleep times and record them to Health if you don’t use an active sleep tracker? A quick check of a non-health-tracking phone shows no sleep data, so I assume not. If not, does this PR default to the previous behavior, or to a default bedtime, or something else?

Copy link
Contributor

@mpangburn mpangburn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Anna, for taking this on! Using HealthKit data to improve the update interval without a user setting is the right path forward, and this is a great start. I've included a few suggestions.

@@ -40,6 +44,37 @@ final class WatchDataManager: NSObject {
}()

private var lastSentSettings: LoopSettings?

let healthStore = HKHealthStore()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than create a new store object, use the app's shared HKHealthStore, loopDataManager.glucoseStore.healthStore (going through carbStore or doseStore would work just as well).

@@ -40,6 +44,37 @@ final class WatchDataManager: NSObject {
}()

private var lastSentSettings: LoopSettings?

let healthStore = HKHealthStore()
private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use .distantPast as the initial value here.

let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate)
guard
lastUpdateInterval < TimeInterval(hours: 24)
else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A style nit: if you invert the logic here, you can drop a level of indentation by moving the logic beneath the guard; i.e.

guard lastUpdateInterval >= .hours(24) else {
   return
}
// remainder of the function body

private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())!
private var bedtime: Date?

private func updateBedtime() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this function only performs work conditionally, consider naming it updateBedtimeIfNeeded.

(result) in

// update when we last checked the bedtime
self.lastBedtimeUpdate = Date()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we only update the bedtime date in the successful case, should this variable only be updated on that path?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This variable should be renamed, as it's intended to be the last time that we've attempted to find the bedtime. I had it update even in a failure case to ensure that where there's no sleep data, the query doesn't get attempted every time the function is called. Would this be the desired behavior?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's reasonable—and it prompted me to look at the behavior of the bedtime computation function when no sleep data exists. Added an additional comment down there.

if let error = error {
completion(.failure(error))
} else if let samples = samples as? [HKCategorySample] {
// find the average hour and minute components from the sleep start times
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each sample's value property will correspond to a value of HKCategoryValueSleepAnalysis. If a user has a sleep tracking app, they might have primarily .asleep values; if the data comes from Bedtime in the Clock app, they'll have primarily .inBed values. It's possible that a user has a value of each type each night, in which case they'll both be averaged.

Consider performing an additional step here which filters samples down to those with .inBed values to use in the computation. If there aren't "enough", try .asleep instead. In any case, we'll want to make sure we don't include any .awake values.

Copy link
Collaborator Author

@novalegra novalegra Dec 30, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. For the purposes of "enough," what could be a decent threshold? The current code just needs one value for it to use the .inBed values instead of the .asleep values, but I'm not sure what the ideal amount would be


// find the next time that the user will go to bed, based on the averages we've computed
if let time = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime) {
completion(.success(time))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a tricky case here where the time might not exist within the next 24 hours, like in the hour jump that occurs for DST. It's worth doing a check that time.timeIntervalSinceNow <= .hours(24) before proceeding.

if let time = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime) {
completion(.success(time))
} else {
completion(.failure(NSError()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might consider defining a SleepStoreError enum to use in SleepStoreResult, with a noMatchingBedtime case.

completion(.failure(NSError()))
}
} else {
assertionFailure("Unknown return configuration from query \(query)")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since assertion failures trigger only in debug modes, it's best practice to still call the completion handler on this path—not that I'd expect it to happen!

}

extension Date {
fileprivate func secondsFromMidnight() -> Int {
Copy link
Contributor

@mpangburn mpangburn Dec 29, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function's body does what you want: produce a scalar value that encodes the time of day to be averaged. However, this scalar isn't always the number of seconds from midnight; consider DST. Something like timeOfDayInSeconds would communicate the intention more accurately.

@novalegra
Copy link
Collaborator Author

novalegra commented Dec 29, 2019

Does the Apple Watch or iOS passively track sleep times and record them to Health if you don’t use an active sleep tracker? A quick check of a non-health-tracking phone shows no sleep data, so I assume not. If not, does this PR default to the previous behavior, or to a default bedtime, or something else?

If Bedtime, an iOS feature, is set up, then it'll track the sleep times; there are also other iOS and/or Watch apps that can track sleep. If there is no data, this PR would default to the previous behavior, which uses midnight - now as the interval until the complication allowance will be refreshed (or a duration of 24 hours if midnight can't be found)

completion(.failure(error))
} else if let samples = samples as? [HKCategorySample] {
// find the average hour and minute components from the sleep start times
let average = samples.reduce(0, {$0 + $1.startDate.secondsFromMidnight()}) / samples.count
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll want a guard to check that !samples.isEmpty to avoid trapping here if no sleep data is available. SleepStoreError can include a noSleepDataAvailable case to represent the failure.

Copy link
Contributor

@mpangburn mpangburn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good! I have just a couple final requests (one or two of which I missed in the first review, that's on me!), then I think this'll be good to go from my perspective.


enum SleepStoreResult<T> {
case success(T)
case failure(Error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use SleepStoreError as the type contained by the failure case.

}

extension SleepStoreError: LocalizedError {
public var localizedDescription: String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like we'll be surfacing these errors to the user, so you can remove the localized descriptions.


class SleepStore {
var healthStore: HKHealthStore
var sampleLimit: Int
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sampleLimit strikes me more as a parameter to the query, rather than as a property of the SleepStore itself. Consider making this a parameter to getAverageSleepStartTime with a default value.


extension Date {
fileprivate func timeOfDayInSeconds() -> Int {
let calendar = Calendar.current
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One detail here I forgot to mention—the time of day for a particular sleep sample is dependent upon the time zone in which the sample was recorded. For example, if a user went on vacation recently, using their home time zone to determine the time of day in seconds could yield a result that doesn't accurately reflect when they went to sleep in their vacation location's time zone.

Add a parameter to this function of type TimeZone, and assign it to the timeZone property of calendar. You can retrieve the TimeZone for each sleep sample by retrieving the name string from the sample's metadata via HKMetadataKeyTimeZone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For unwrapping the timezone, what could an appropriate default be? We could use the current time zone, or exclude the sample from the analysis (though this would probably mean it wouldn't be possible to use a reduce function to average the samples)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just checked my sleep data, from SleepTracker, and it has timezone. Whether or not this field is present will depend on the app, and since it's metadata, the app isn't forced to store it. I think assuming local timezone in that case is probably the best option, as the user probably will have no samples with timezone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Just checked my sleep data (some from Bedtime, some from SleepCycle) and it all contains timezones

@@ -40,6 +43,52 @@ final class WatchDataManager: NSObject {
}()

private var lastSentSettings: LoopSettings?

let healthStore: HKHealthStore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we no longer need a HKHealthStore instance directly inside WatchDataManager, since the access is managed through SleepStore; is this property used anywhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, removed

let hourComponent = calendar.component(.hour, from: bedtime)
let minuteComponent = calendar.component(.minute, from: bedtime)

if let newBedtime = calendar.nextDate(after: now, matching: DateComponents(hour: hourComponent, minute: minuteComponent), matchingPolicy: .nextTime) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's another case where newBedtime could be more than a day in the future in case of DST. We could add a newBedtime.timeIntervalSince(bedtime) <= .hours(24) clause similarly to before; alternatively, see my comment in complicationUserInfoTransferInterval below.

// we can have a more frequent refresh rate if we only refresh when it's likely the user is awake (based on HealthKit sleep data)
if let nextBedtime = bedtime {
let timeUntilBedtime = nextBedtime.timeIntervalSince(now)
timeUntilRefresh = timeUntilBedtime > TimeInterval(0) ? timeUntilBedtime : midnight.timeIntervalSince(now)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might defensively make the condition in this ternary also check an upper bound on timeUntilBedtime, i.e. (0..<TimeInterval(hours: 24)).contains(timeUntilBedtime), to ensure that complication transfers aren't slowed down if bedtime somehow ended up being a Date further in the future.

Copy link
Collaborator

@ps2 ps2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great. Thanks Anna! And thanks Michael for reviewing.


extension Date {
fileprivate func timeOfDayInSeconds() -> Int {
let calendar = Calendar.current
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just checked my sleep data, from SleepTracker, and it has timezone. Whether or not this field is present will depend on the app, and since it's metadata, the app isn't forced to store it. I think assuming local timezone in that case is probably the best option, as the user probably will have no samples with timezone.

@ps2 ps2 merged commit 05af23d into LoopKit:dev Feb 4, 2020
@novalegra novalegra deleted the aq/update-complications branch April 4, 2020 16:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants