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
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
774a272
Resolve conflicts
novalegra Dec 22, 2019
6849b3c
Actually resolve them :-)
novalegra Dec 22, 2019
ab440e9
Add what I have
novalegra Dec 23, 2019
c1512d4
Add sleep permission
novalegra Dec 25, 2019
ee2227a
Refine complication math
novalegra Dec 25, 2019
0658c47
Improvements to complication-refresh code
novalegra Dec 27, 2019
f68e6ef
Merge remote-tracking branch 'upstream/dev' into update-complications
novalegra Dec 27, 2019
e503af4
Update to match dev
novalegra Dec 27, 2019
2e742d7
Make cartfile accurate
novalegra Dec 27, 2019
53c98a8
Add newline
novalegra Dec 27, 2019
a65f586
TimeInterval -> Date
novalegra Dec 28, 2019
cc17049
Ensure last update time is updated in case of failure
novalegra Dec 28, 2019
28795e5
Remove print statement
novalegra Dec 29, 2019
ec14d2e
Changes based on review
novalegra Dec 30, 2019
e70a89e
More changes in response to review
novalegra Dec 31, 2019
fbaa204
Merge branch 'aq/update-complications' of https://github.com/novalegr…
ps2 Jan 26, 2020
f83217a
Avoid crash on HKSampleQuery error
ps2 Jan 26, 2020
20bc003
Fix crash due to incorrect error type
novalegra Jan 26, 2020
f71c485
Merge remote-tracking branch 'origin/aq/update-complications' into up…
novalegra Jan 26, 2020
aeb14be
Fix for authorization error
novalegra Jan 27, 2020
ffbaea9
Remove delay to mirror LoopKit
novalegra Jan 28, 2020
975c296
Merge remote-tracking branch 'upstream/dev' into update-complications
novalegra Jan 28, 2020
cc0d636
Update ExponentialInsulinModelPreset.swift
novalegra Jan 28, 2020
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
Prev Previous commit
Next Next commit
Changes based on review
  • Loading branch information
novalegra committed Dec 30, 2019
commit ec14d2ed120903a6ddfc1d10cd2f33a082d9107a
4 changes: 4 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@
C1FB428D21791D2500FAB378 /* PumpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C3B6F620BBCAA30026CAFA /* PumpManager.swift */; };
C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; };
C1FB4290217922A100FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; };
E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9BB27AA23B85C3500FB4987 /* SleepStore.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -1048,6 +1049,7 @@
C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = "<group>"; };
C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = "<group>"; };
C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = "<group>"; };
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepStore.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -1602,6 +1604,7 @@
4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */,
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
4328E0341CFC0AE100E199AA /* WatchDataManager.swift */,
E9BB27AA23B85C3500FB4987 /* SleepStore.swift */,
);
path = Managers;
sourceTree = "<group>";
Expand Down Expand Up @@ -2577,6 +2580,7 @@
430B29932041F5B300BA9F93 /* UserDefaults+Loop.swift in Sources */,
4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */,
43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */,
E9BB27AB23B85C3500FB4987 /* SleepStore.swift in Sources */,
C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */,
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */,
439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */,
Expand Down
121 changes: 121 additions & 0 deletions Loop/Managers/SleepStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// SleepStore.swift
// Loop
//
// Created by Anna Quinlan on 12/28/19.
// Copyright © 2019 LoopKit Authors. All rights reserved.
//

import Foundation
import HealthKit

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.

}

enum SleepStoreError: Error {
case noMatchingBedtime
case unknownReturnConfiguration
case noSleepDataAvailable
}

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.

switch self {
case .noMatchingBedtime:
return NSLocalizedString("Could not find a matching bedtime", comment: "")
case .unknownReturnConfiguration:
return NSLocalizedString("Unknown return configuration from query", comment: "")
case .noSleepDataAvailable:
return NSLocalizedString("No sleep data available", comment: "")
}
}
}

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.


public init(
healthStore: HKHealthStore,
sampleLimit: Int = 30
) {
self.healthStore = healthStore
self.sampleLimit = sampleLimit
}

func getAverageSleepStartTime(_ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
let inBedPredicate = HKQuery.predicateForCategorySamples(
with: .equalTo,
value: HKCategoryValueSleepAnalysis.inBed.rawValue
)

let asleepPredicate = HKQuery.predicateForCategorySamples(
with: .equalTo,
value: HKCategoryValueSleepAnalysis.asleep.rawValue
)

getAverageSleepStartTime(matching: inBedPredicate, sampleLimit: sampleLimit) {
(result) in
switch result {
case .success(_):
completion(result)
case .failure(let error):
switch error {
case SleepStoreError.noSleepDataAvailable:
// if there were no .inBed samples, check if there are any .asleep samples that could be used to estimate bedtime
self.getAverageSleepStartTime(matching: asleepPredicate, sampleLimit: self.sampleLimit, completion)
default:
// otherwise, call completion
completion(result)
}
}

}
}

fileprivate func getAverageSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!

// get more-recent values first
let sortByDate = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)

let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortByDate]) { (query, samples, error) in

if let error = error {
completion(.failure(error))
} else if let samples = samples as? [HKCategorySample] {
guard !samples.isEmpty else {
completion(.failure(SleepStoreError.noSleepDataAvailable))
return
}

// find the average hour and minute components from the sleep start times
let average = samples.reduce(0, {$0 + $1.startDate.timeOfDayInSeconds()}) / samples.count
let averageHour = average / 3600
let averageMinute = average % 3600 / 60

// find the next time that the user will go to bed, based on the averages we've computed
if let bedtime = Calendar.current.nextDate(after: Date(), matching: DateComponents(hour: averageHour, minute: averageMinute), matchingPolicy: .nextTime), bedtime.timeIntervalSinceNow <= .hours(24) {
completion(.success(bedtime))
} else {
completion(.failure(SleepStoreError.noMatchingBedtime))
}
} else {
completion(.failure(SleepStoreError.unknownReturnConfiguration))
}
}
healthStore.execute(query)
}
}

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

let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self)
let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second!

return dateSeconds
}
}
119 changes: 44 additions & 75 deletions Loop/Managers/WatchDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ import WatchConnectivity
import LoopKit
import LoopCore

enum SleepStoreResult<T> {
case success(T)
case failure(Error)
}

final class WatchDataManager: NSObject {

unowned let deviceManager: DeviceDataManager

init(deviceManager: DeviceDataManager) {
self.deviceManager = deviceManager
self.healthStore = deviceManager.loopManager.glucoseStore.healthStore
self.sleepStore = SleepStore (healthStore: healthStore)
self.lastBedtimeQuery = UserDefaults.appGroup?.lastBedtimeQuery ?? .distantPast
self.bedtime = UserDefaults.appGroup?.bedtime
self.log = DiagnosticLogger.shared.forCategory("WatchDataManager")

super.init()
Expand All @@ -45,35 +44,50 @@ final class WatchDataManager: NSObject {

private var lastSentSettings: LoopSettings?

let healthStore = HKHealthStore()
private var lastBedtimeUpdate: Date = Calendar.current.date(byAdding: .hour, value: -25, to: Date())!
private var bedtime: Date?
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 sleepStore: SleepStore

private func updateBedtime() {
let lastUpdateInterval = Date().timeIntervalSince(lastBedtimeUpdate)
guard
lastUpdateInterval < TimeInterval(hours: 24)
else {
// only look at samples within the past 6 months
let monthsToGoBack = -6
let start = Calendar.current.date(byAdding: .month, value: monthsToGoBack, to: Date())!

getSleepStartTime(start: start) {
(result) in

// update when we last checked the bedtime
self.lastBedtimeUpdate = Date()
var lastBedtimeQuery: Date {
didSet {
UserDefaults.appGroup?.lastBedtimeQuery = lastBedtimeQuery
}
}

var bedtime: Date? {
didSet {
UserDefaults.appGroup?.bedtime = bedtime
}
}

private func updateBedtimeIfNeeded() {
let now = Date()
let lastUpdateInterval = now.timeIntervalSince(lastBedtimeQuery)
let calendar = Calendar.current

guard lastUpdateInterval >= TimeInterval(hours: 24) else {
// increment the bedtime by 1 day if it's before the current time, but we don't need to make another HealthKit query yet
if let bedtime = bedtime, bedtime < now {
let hourComponent = calendar.component(.hour, from: bedtime)
let minuteComponent = calendar.component(.minute, from: bedtime)

switch result {
case .success(let bedtime):
self.bedtime = bedtime
case .failure:
return
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.

self.bedtime = newBedtime
}
}

return
}

sleepStore.getAverageSleepStartTime() {
(result) in
self.lastBedtimeQuery = now
switch result {
case .success(let bedtime):
self.bedtime = bedtime
case .failure:
self.bedtime = nil
}
}
}

@objc private func updateWatch(_ notification: Notification) {
Expand Down Expand Up @@ -148,7 +162,7 @@ final class WatchDataManager: NSObject {
}

let complicationShouldUpdate: Bool
updateBedtime()
updateBedtimeIfNeeded()

if let lastContext = lastComplicationContext,
let lastGlucose = lastContext.glucose, let lastGlucoseDate = lastContext.glucoseDate,
Expand Down Expand Up @@ -358,6 +372,7 @@ extension WatchDataManager {
"## WatchDataManager",
"lastSentSettings: \(String(describing: lastSentSettings))",
"lastComplicationContext: \(String(describing: lastComplicationContext))",
"lastBedtimeQuery: \(String(describing: lastBedtimeQuery))",
"bedtime: \(String(describing: bedtime))",
"complicationUserInfoTransferInterval: \(round(watchSession?.complicationUserInfoTransferInterval(bedtime: bedtime).minutes ?? 0)) min"
]
Expand All @@ -372,55 +387,9 @@ extension WatchDataManager {

return items.joined(separator: "\n")
}

private func getSleepStartTime(start: Date, end: Date? = nil, sampleLimit: Int = 30, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])

getSleepStartTime(matching: predicate, sampleLimit: sampleLimit, completion)
}

private func getSleepStartTime(matching predicate: NSPredicate, sampleLimit: Int, _ completion: @escaping (_ result: SleepStoreResult<Date>) -> Void) {
let sleepType = HKObjectType.categoryType(forIdentifier: HKCategoryTypeIdentifier.sleepAnalysis)!
// get more-recent values first
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)

let query = HKSampleQuery(sampleType: sleepType, predicate: predicate, limit: sampleLimit, sortDescriptors: [sortDescriptor]) { (query, samples, error) in

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
let average = samples.reduce(0, {$0 + $1.startDate.secondsFromMidnight()}) / samples.count
let averageHour = average / 3600
let averageMinute = average % 3600 / 60

// 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))
} else {
completion(.failure(NSError()))
}
} else {
assertionFailure("Unknown return configuration from query \(query)")
}
}

healthStore.execute(query)
}

}

extension Date {
fileprivate func secondsFromMidnight() -> Int {
let calendar = Calendar.current
let dateComponents = calendar.dateComponents([.hour, .minute, .second], from: self)
let dateSeconds = dateComponents.hour! * 3600 + dateComponents.minute! * 60 + dateComponents.second!

return dateSeconds
}
}


extension WCSession {
open override var debugDescription: String {
return [
Expand Down
28 changes: 28 additions & 0 deletions LoopCore/NSUserDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ extension UserDefaults {
case loopSettings = "com.loopkit.Loop.loopSettings"
case insulinSensitivitySchedule = "com.loudnate.Naterade.InsulinSensitivitySchedule"
case overrideHistory = "com.tidepool.loopkit.overrideHistory"
case lastBedtimeQuery = "com.loopkit.Loop.lastBedtimeQuery"
case bedtime = "com.loopkit.Loop.bedtime"
}

public static let appGroup = UserDefaults(suiteName: Bundle.main.appGroupSuiteName)
Expand Down Expand Up @@ -151,4 +153,30 @@ extension UserDefaults {
set(newValue?.rawValue, forKey: Key.overrideHistory.rawValue)
}
}

public var lastBedtimeQuery: Date? {
get {
if let rawValue = object(forKey: Key.lastBedtimeQuery.rawValue) as? Date {
return rawValue
} else {
return nil
}
}
set {
set(newValue, forKey: Key.lastBedtimeQuery.rawValue)
}
}

public var bedtime: Date? {
get {
if let rawValue = object(forKey: Key.bedtime.rawValue) as? Date {
return rawValue
} else {
return nil
}
}
set {
set(newValue, forKey: Key.bedtime.rawValue)
}
}
}