Designing a Bangle.js commute timerWed 16 August 2023
I work in an office occasionally, and there is a particular point on my commute where I always check the clock so I can tell how I'm doing for time. If it's 8.30 I'm likely to be on time, before 8.30 I'm doing well, after that I might be late. But why stop at checking my delta time at a single waypoint? Why not store a GPS trace of a reference commute and continuously compute the delta time, like you get in time trial mode in racing games?
I want to make a Bangle.js commute timer, as kind of a complement to Waypointer Moto: instead of telling me where to go, it will tell me how long it's going to take. It will help me answer questions like "how many minutes did that horsebox really cost me?", and "is the traffic really worse today or am I just more grumpy?".
I don't just want a rough minute-precision ETA like you get on Google Maps. When I'm stuck at traffic lights I want to see my delta time ticking up by 1 second per second. I want to really feel that delay.
This will be very much a home-cooked app. It only needs to solve my problem, it doesn't need to be useful for anyone else. I might even hardcode the route endpoints.
It's easy to say "make it store a reference route and show your delta time and ETA, and update the reference route if you beat the time", but there are a few subtleties to the implementation that make it harder than it sounds. The purpose of this post is to come up with a design that addresses all of the subtleties, so that I can write it right on the first try.
Sometimes the Bangle.js won't have picked up a GPS fix until after I have set off.
Sometimes it will have a GPS fix before I set off.
Sometimes roads are closed, or I need fuel, or the GPS stops working temporarily, or I deviate from the route for some other reason.
Sometimes I will loop back on myself, or the GPS signal will be noisy.
Sometimes I'm going home instead of to work.
Sometimes I will be standing still or moving very slowly.
When the new route is faster than the reference route and we update the reference route, the start and end points should stay fixed even if the GPS trace has a small amount of drift.
Sometimes the time on the Bangle.js drifts, particularly when the battery is low.
The GPS route will be a series of waypoints annotated with timestamps. This could be stored in GPX format, but wouldn't have to be.
If the commute is about an hour long and we want under 1000 waypoints, then we want to take waypoints less frequently than once every 3.6 seconds. Maybe a 5 second interval would be fine. Why 1000? Just an arbitrary starting point. Note that the Bangle.js does not have an awful lot of RAM and ideally we'd keep the entire route in memory.
Whenever we get a fresh location, we'll look for a "target" waypoint, which we define to be the nearest waypoint which we are getting closer to. (Finding this could be O(1) in the common case, where you have moved a bit closer to the same waypoint you were closest to last time, but O(n) in the worst case, if you have to scan all waypoints).
If the target waypoint is the first waypoint of the route and we're more than, say, 20 metres away, then we'll say that we haven't started the route yet.
Otherwise, once we have the target waypoint, we can look at our current speed and our distance from the target to work out how long it will take us to get there. We then know a point on the reference route, and the time that we expect to get there, so we can work out how long it would take us to complete the route at the reference pace, and therefore what time we should arrive at the destination. (And if we have so far averaged 1% slower than reference pace then we can also assume that we'll continue to average 1% slower and adjust the ETA accordingly.)
I think the only time this target waypoint selection falls down is if we're moving very slowly, and the noise in the GPS data could cause it to think we've moved away from the target, which means it would select a new target in the opposite direction. Maybe we say that if we're moving less than, say, 5mph, then the target selection works differently (e.g. bias it towards retaining the existing target even if it looks like we moved away from it).
There's also the issue that if we're not moving, then the time to the target waypoint is infinite, so we expect never to arrive at the destination. This is obviously wrong, so maybe we cap the "time to next waypoint" at the total time it took to reach that waypoint in the reference route.
So this waypoint selection method allows us to "wake up" partway through the route and still select an appropriate target. It allows us to take a diversion for a road closure and rejoin the route with an appropriate target as we approach it. If we make sure that the "start" location for the route is 100 metres or so up the road from my house, then it also won't accidentally start the timer while I'm still faffing about on the drive.
But how do we calculate the starting time for a journey where we woke up a few miles into the route? I propose that if we "start" the route with some target other than the starting point, then we blindly import all of the preceding waypoints into the "current" route, but adjusted to match today's timestamps. That way we end up with a complete route that we can use as the new reference if required, and we have an estimate of the full journey time. Another option would be to sit on the drive until the watch has a GPS fix, but that is less appealing.
To prevent the start/end point from drifting (e.g. if we start the journey when we're within 10 metres of the start point, it would move back by up to 10 metres every time a new reference is taken), we can maintain the original coordinates for the first and last waypoint, and adjust the time to match our best guess at when we passed that exact point on the new journey.
When the GPS data is noisy we might find that the delta time and ETA become noisy. We could update them using an exponential moving average instead of replacing them each time there is new data. Will have to experiment with that.
And to solve the issue where we're going in the reverse direction, we can keep separate reference routes for each direction. It might be good enough to decide which one to use based on time of day (i.e. before noon we're going to work and after noon we're going home). Alternatively we could keep track of the delta time for each route and say that we're travelling the route whose delta time looks most stable. I think just looking at time of day is adequate and simpler.
To keep the Bangle.js time in sync I will make it update the time from the GPS fix whenever there is one.
The key pieces of information I want to display are:
- GPS status (high-quality fix, low-quality fix, or no fix at all)
- ETA as a time of day, maybe with sub-second precision (e.g."08:57:43.1")
- delta time to personal best, definitely with sub-second precision (e.g. "-1.5")
After that, there is lots of other stuff that we could easily calculate: elapsed time so far, estimated time remaining, estimated total time, average speed, average speed delta, start time, current time of day, progress percentage in terms of time, progress percentage in terms of distance, probably lots more.
I might experiment with showing some of that, but the ETA and the delta time are the most interesting to me.
Once we arrive at the destination, we can switch to a different mode that shows the overall delta time and the true time of arrival. If the overall delta time is negative (i.e. we beat the best time), then it should probably update the reference route to use the new journey.
But maybe we would want that to be optional, for example if the journey was fast for some non-repeatable reason that we don't want to confound the data on future journeys? And for that matter, maybe we'd want the option to replace the reference route even if the new journey was not an improvement? Not sure.
I'm not sure whether writing all this up has made things clearer in my mind or less clear. And I still don't know what to do about the case where the reference route has loops in it. Maybe it's fine to ignore as an unlikely edge case? Maybe before storing a reference route we need to post-process it to cut out loops? Maybe it's as simple as deleting any waypoint whose successor under the "target selection" logic has an earlier timestamp?
If you like my blog, please consider subscribing to the RSS feed or the mailing list: