Skip to content

RFC: Apple Watch complication update algorithm #816

Closed

Description

@mpangburn

Background

One of the primary technical challenges in the development of an Apple Watch complication for a diabetes app is the timely transmission of data from the phone to the watch: an app has only 50 guaranteed immediate complication updates per day. With a new sensor glucose data point incoming every five minutes but only enough guaranteed updates for one every ~30 minutes, keeping the complication up-to-date presents a meaningful challenge.

Present State

Currently, after a loop is completed, the decision to use one of the budgeted complication updates boils down to the fulfillment of one of the following requirements:

  • Sufficient time (30 minutes) has elapsed since the last update.
  • The sensor glucose value has changed significantly (20 mg/dL) since the last update.

If neither of these conditions are met, Loop requests an update to the watch via application context. The underlying framework determines an appropriate time to transfer the information based on factors such as communication strength and battery life; this transfer of data is opportunistic rather than deterministic.

Loop accommodates the restrictions on complication updates by including the time elapsed since the recorded glucose in complications providing enough space for the additional text. This information dwindles in utility over time nonetheless, often leading users to necessarily tap the complication and open the main app in order to view the most recent glucose value.

Proposed Changes

While Loop remains restricted by the confines of the existing complication update system provided by Apple, we can improve the algorithm used to update the complication to enhance the timeliness of the information presented.

I propose the following:

1. The time threshold for using an immediate complication update, rather than being fixed at 30 minutes, should be determined dynamically based on the number of remaining complication updates for that day:

(affordable update rate) = (remaining immediate updates) / (time until budget resets)

This simple adjustment has the following effects:

  • If the watch is disconnected from the phone for any period of time throughout the day, the update rate increases appropriately.
  • If the second immediate update condition (sufficient change in glucose value) holds when sufficient time has not yet passed, the update rate will decrease to accommodate the remainder of the day accordingly.

2. The complication update algorithm should favor the user's waking hours.

People sleep. Even if a user elects to wear their watch while sleeping, expending immediate updates during sleeping hours is an inefficient use; the amount of time spent glancing at the watch face overnight is substantially lower than during the day. To make better use of the budget, we can bias the distribution of immediate updates in favor of the user's waking hours.

With an unbiased update model and a stable connection with the watch all day, a user who sleeps for 8 hours per day utilizes one third of their budgeted updates during their sleep. If we halve the update rate overnight, we can increase the percentage of complication updates during waking hours from 66.6% to 83.3%—other factors held constant, this bias would decrease the guaranteed complication update time from ~29 minutes to ~23.5 minutes.

Taking this factor into consideration slightly complicates the simple formula listed in the first proposed change above, but the algorithm remains straightforward. Obtaining the user's waking hours also introduces some complexity to Loop's UI, but with sensible default values the effect on the user is little to none.

  • An additional cell in the settings table of the iOS app is created for Apple Watch configuration (hidden if no watch is paired).
  • Tapping the watch configuration cell brings the user to an additional table containing the configuration details.
  • The configuration details table contains a cell for configuring the waking hours, which default to 6am...10pm.

Discussion: Workouts

Workout state could reasonably be incorporated into the complication update algorithm. Glucose values are more volatile mid-exercise, and changes can affect exercise performance. It's reasonable that a user would desire more frequent complication updates during their run, even if it meant a decreased update rate for the remainder of the day.

However, Loop's existing Workout Mode feature is insufficient to make adjustments to the algorithm. Workout Mode is simply a temporary target adjustment, so its use cases generalize beyond just exercise; it's fathomable that a user would enable Workout Mode when experiencing increased insulin sensitivity for any reason (post-exercise recovery, illness, etc.).
Pending some design decisions (#593), the enabling of a mode more thoroughly dedicated to workouts could be sufficient information to increase the update rate. The direction of such a feature is still undecided, however; it may become an improved temporary setting for insulin sensitivity nonspecific to workouts. We may consider changes to the update algorithm if this area develops in the future.

Thoughts and concerns appreciated!

Activity

  1. elnjensen commented on Sep 22, 2018

    @elnjensen
    Contributor

    @mpangburn Thanks for this very thorough and thoughtful writeup. I hadn't known how Loop decides to update the complication, so this was very informative for me. I think that what you propose makes a lot of sense.

    A couple of questions:

    1. Does the 50-per-day limit reset at midnight? Or is a rolling 24-hour window? I couldn't find any documentation on this.

    2. According to this documentation with some related discussion here it's still possible to transfer info after the complication limit is exceeded, but it is processed in a different way. What are the pros and cons of doing that, i.e. more aggressively sending data anyway?

    I'm very much in favor of your idea of a separate workout app (and have even played around a little with it, though not very productively). I'd love to just have something with a stopwatch and BG value or graph.

    Thanks for opening this discussion!

  2. mpangburn commented on Sep 24, 2018

    @mpangburn
    ContributorAuthor

    @elnjensen Thanks for raising these great questions.

    Does the 50-per-day limit reset at midnight? Or is a rolling 24-hour window?

    I'm glad you brought attention to this assumption, because it appears I was misguided:
    screen shot 2018-09-24 at 8 39 54 am

    The budget resets immediately, so we can have confidence it isn't a rolling window. However, it also doesn't reset right at midnight. I'll continue to log this information over the next few days, though I'm doubtful that 11:05pm would remain consistent between users. It wouldn't surprise me if the reset date were relative to an event like the user first putting the app's complication on their watch face.

    It might be helpful to have a couple more people log the update resets to gain some more data points here, but it's likely that we'll have to dynamically determine the reset time on a per-user basis in code.

    [...] it's still possible to transfer info after the complication limit is exceeded, but it is processed in a different way. What are the pros and cons of doing that, i.e. more aggressively sending data anyway?

    There are four paths provided by WatchConnectivity for communication between the phone and the watch. I'll describe each of them below for future readers of the thread, since I don't think the documentation lays them out very clearly. Your question pertains specifically to the second and third methods of communication I'll describe.

    • Message sending: When the watch app is open and in use, the apps can communicate freely with effectively instantaneous response times (used when interacting with the main buttons in the Loop watch app).
    • User info transfer, complication priority: The budgeted immediate updates discussed in this thread, not applicable if the complication isn't on the user's watch face.
    • User info transfer, standard priority: The standard way to queue up information to transfer from the phone to the watch. Attempting to transfer with complication priority when the budget is exhausted defaults to this method, as noted by the documentation you've linked. Information sent by user info transfer will make its way to the watch, but we don't get guarantees on when that will happen. The framework makes those decisions based on factors like communication strength, battery life, and queued user info transfers from other apps, but the process is opaque.
    • Application context: Transfer happen by the same manner as user info transfers with one key difference: each transfer via application context replaces the previous, rather than queueing up to transfer serially. This method is currently used as the alternative when Loop decides not to use one of the budgeted complication updates.

    Let me know if I can clarify these any further.

  3. elnjensen commented on Sep 24, 2018

    @elnjensen
    Contributor

    @mpangburn Thanks for the clarifications. I'd be happy to keep an eye on when the complication update budget is resetting, but not sure how to do it - can you share how you produced the screenshot above?

  4. mpangburn commented on Sep 24, 2018

    @mpangburn
    ContributorAuthor

    @elnjensen That'd be great. Here's how you can track the update budget:

    1. Configure Loggly. If you've already done this for debugging, you can skip this step.
      The signup process isn't too bad—create a free account at loggly.com and enter your customer token from Source Setup > Customer Tokens (along the top navigation bar) in Loop through the Settings page in-app.
    2. Add the following lines to the main body of WatchDataManager:
        private var budgetUpdateObservationToken: NSKeyValueObservation?
    
        private var lastLoggedRemainingUpdates: Int?
    
        deinit {
            budgetUpdateObservationToken?.invalidate()
        }
    
        private func configureBudgetUpdateObservation() {
            budgetUpdateObservationToken = WCSession.default.observe(\.remainingComplicationUserInfoTransfers, options: [.initial, .new]) { [weak self] session, change in
                guard let self = self else { return }
                if let newValue = change.newValue, newValue != self.lastLoggedRemainingUpdates {
                    self.log.debug(["remainingUpdates": newValue])
                    self.lastLoggedRemainingUpdates = newValue
                }
            }
        }
    1. Add the following line to end of the init(deviceManager:) initializer near the top of WatchDataManager.swift to begin the observation:
    configureBudgetUpdateObservation()

    After you launch the app, it'll log the remaining updates to Loggly each time one is used. When that number jumps back up to 50, the budget has reset.
    Loop logs a number of events via Loggly, so if you just want to see the changes in updates remaining, you can go the main Search tab at the top of Loggly and start typing "remainingUpdates". It should auto-complete and filter appropriately based on the selected date range.

    If you encounter any snags along the way, please let me know.

  5. mpangburn commented on Sep 25, 2018

    @mpangburn
    ContributorAuthor

    Huh, well would you look at that:
    screen shot 2018-09-25 at 7 48 30 am
    Night 2: a reset right at midnight.

  6. mpangburn commented on Sep 26, 2018

    @mpangburn
    ContributorAuthor

    Day 3 findings:
    Yesterday I went for a hike in the morning. When I came back, I stuck my watch on the charger while I jumped in the shower—and my update budget reset back to 50 at 10:05am. I have a hunch that one of the triggers for resetting the budget is the watch recharging to a certain battery level (my watch was still pretty high in battery, so when I put it on the charger, I suspect it was already passed that level, thus resetting the budget immediately).

    To try to push the battery life theory a little more, I chose to wear my watch while sleeping last night rather than place it on the charger. The update budget did not reset right at midnight, but instead at about 5:03am. The pattern here remains ambiguous, unfortunately.

  7. elnjensen commented on Sep 27, 2018

    @elnjensen
    Contributor

    @mpangburn When I try to add your code to WatchDataManager.swift, I get an error on this line:

    self.log.debug(["remainingUpdates": newValue])

    that says "Value of type 'WatchDataManager' has no member 'log'. " I'm trying to insert the code you gave above into the class definition in that file - I assume that's what you meant by "main body." Thanks for any thoughts - I'm using 1.9.2.

  8. mpangburn commented on Sep 27, 2018

    @mpangburn
    ContributorAuthor

    @elnjensen My mistake—it looks like the log property was added in a more recent commit. If you use the current dev branch, that line should work fine.

  9. elnjensen commented on Sep 27, 2018

    @elnjensen
    Contributor

    OK - I looked at merging dev with my current apple watch code changes, and it's not simple. There is a lot going on there. So for now I'll have to pass on this, unless there's an easy replacement for that code. Sorry!

  10. mpangburn commented on Sep 27, 2018

    @mpangburn
    ContributorAuthor

    @elnjensen No worries if you can't get it up and running. I'll try one more time at a code addition to integrate with your branch, since nothing in dev is needed other than the couple lines to add the logger.

    In the class definition for WatchDataManager, add

        private let log: CategoryLogger
    
        private var budgetUpdateObservationToken: NSKeyValueObservation?
    
        private var lastLoggedRemainingUpdates: Int?
    
        deinit {
            budgetUpdateObservationToken?.invalidate()
        }
    
        private func configureBudgetUpdateObservation() {
            budgetUpdateObservationToken = WCSession.default.observe(\.remainingComplicationUserInfoTransfers, options: [.initial, .new]) { [weak self] session, change in
                guard let self = self else { return }
                if let newValue = change.newValue, newValue != self.lastLoggedRemainingUpdates {
                    self.log.debug(["remainingUpdates": newValue])
                    self.lastLoggedRemainingUpdates = newValue
                }
            }
        }

    and at the top of the init(deviceManager:) initializer, initialize the log:

    self.log = deviceManager.logger.forCategory("WatchDataManager")

    and within that same initializer, add the following as the very last line before the closing brace:

    configureBudgetUpdateObservation()

    If that works for you—great! If not, it's no problem. I appreciate you giving it a shot.

  11. elnjensen commented on Sep 28, 2018

    @elnjensen
    Contributor

    Perfect! I'm up and running now with that code, and I can see the results in Loggly - I'll let you know what happens.

  12. elnjensen commented on Sep 30, 2018

    @elnjensen
    Contributor

    OK, I have a couple of days of data on this now; here are some preliminary results. First night, I ran out of updates at 2:42 AM, and it reset 2:20 later, at 5:02 AM:
    screen shot 2018-09-29 at 10 32 56 pm

    But then that morning I charged my watch while I was having breakfast, and it reset the update count:
    screen shot 2018-09-29 at 10 34 54 pm

    Overnight last night I was in a remote area with poor cell service so it didn't log everything, but it looks consistent with resetting around 5 AM again:
    screen shot 2018-09-29 at 10 36 19 pm

    I did recharge this morning as well, though I think not all the way back to 100%, and didn't see any change during that time.

    That's what I know so far - I'll keep watching it.

  13. mpangburn commented on Sep 30, 2018

    @mpangburn
    ContributorAuthor

    @elnjensen Thanks for collecting this—it's validating to have more data aligning with my observations.
    The budget appears to reset when one of the following conditions is met:

    • The Watch has charged to ~85-90%.
    • 24 hours have elapsed since the last budget reset.

    On the algorithm side, the action here is to monitor changes in the number of remaining updates and record whenever the budget resets, using that moment to estimate the next reset date.
    I've been running some code which does exactly this in addition to biasing the updates toward waking hours. The results so far are promising. I have one more idea of an improvement to make, which I'll take a stab at after a couple more days of collecting data. Hope to have some code to share in the near future.

  14. elnjensen commented on Oct 3, 2018

    @elnjensen
    Contributor

    Just a quick update: continuing to see resets at about 5 AM, plus when I charge. The main thing I'd add to your bullet points above is that it appears to me that the "bonus" resets from charging don't actually change the time of the 24-hour resets. What I see is reset at 5 AM, additional reset during the day if I charge, and then reset at 5 AM again. Is that consistent with what you're seeing? So I don't know what sets that reset time initially, and what might change it.

    It's interesting to note that we've both seen resets at 5:02 - 5:03 AM. Could be a coincidence, but it's something to keep an eye on.

  15. mpangburn commented on Oct 12, 2018

    @mpangburn
    ContributorAuthor

    After a couple weeks of testing, I'm happy to open the PR for this issue: #832

  16. beached commented on Nov 22, 2018

    @beached
    Contributor

    Is the 50 updates per day a per watch or per phone counter? I still have my old watch series 0 and use that at night for dexcom alarm vibrations.

  17. beached commented on Nov 27, 2018

    @beached
    Contributor

    One thing too, one way around this in certain situations is to start issuing optional alerts when events like low glucose, fast fall rate on the phone. The watch mirrors these and I don't know that the limit applies there.

  18. sraka1 commented on Oct 16, 2019

    @sraka1

    @mpangburn it's very likely that remainingComplicationUserInfoTransfers can be reset with a jailbroken phone (probably by hooking into the Watch.app and reloading the watch face). There are already tweaks to ping or reboot the watch (which presumably resets the counter) and this can be automated - https://repo.ayden.dev/depiction/applebetas.ios.tweak.watchreboot/. However, there is probably a way to just reset the counter without anything visibly happening on the watch itself. I'll be looking into this soon...

  19. github-actions commented on Nov 17, 2023

    @github-actions

    This issue is stale because it has been open for 30 days with no activity.

  20. github-actions commented on Dec 1, 2023

    @github-actions

    This issue was closed because it has been inactive for 14 days since being marked as stale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions