Add passive smart rerouting#346
Conversation
| var lastReRouteLocation: CLLocation? | ||
|
|
||
| var routeTask: URLSessionDataTask? | ||
| var lastLocationTimestamp: Date? |
There was a problem hiding this comment.
If it’s a Date, let’s call it a date instead of a timestamp.
|
|
||
| func checkForFasterRoute(from location: CLLocation) { | ||
|
|
||
| // If the user is on a short route, don't check for faster alternatives |
There was a problem hiding this comment.
This comment is a bit misleading: we’re actually bailing if the user is less than 10 minutes from their destination, regardless of the length of the overall trip. Is the rationale that it’s less likely for there to be any viable alternatives towards the end of the trip? In that case, perhaps this heuristic should be based on distance rather than time.
There was a problem hiding this comment.
I'm not certain I see why distance remaining would be more useful here. If there is not much distance remaining on a route, but the duration remaining is high, we should still be looking for a new route.
| guard routeProgress.durationRemaining > 600 else { return } | ||
|
|
||
| // If the user is approaching a maneuver, don't check for a faster alternatives | ||
| guard routeProgress.currentLegProgress.currentStepProgress.durationRemaining > 70 else { return } |
There was a problem hiding this comment.
Since we’re rerouting automatically instead of prompting the user, we could wind up in the following scenario:
- As soon as the user turns right, we say, “Continue on Main Street for 1 mile” (
alertLevel = .low). - Nary a second later, we abruptly say, “Turn left onto Commercial Street” (
alertLevel = .high), surprising the user.
This makes me wonder whether we should be rerouting based on the upcoming maneuver point, to avoid this scenario, until we’re able to give the user a choice. That would be subtle enough that we can remove the 70-second heuristic, as well as the complicated logic below that suppresses redundant announcements.
There was a problem hiding this comment.
@1ec5 how could be force this? Make the first waypoint the upcoming maneuver?
| guard let currentUpcomingManeuver = routeProgress.currentLegProgress.upComingStep else { return } | ||
|
|
||
| guard let lastLocationTimestamp = lastLocationTimestamp else { | ||
| self.lastLocationTimestamp = location.timestamp |
There was a problem hiding this comment.
Nit: remove self., since the local variable is only available outside this guard statement.
| options.waypoints = [Waypoint(coordinate: location.coordinate)] + routeProgress.remainingWaypoints | ||
| if let firstWaypoint = options.waypoints.first, location.course >= 0 { | ||
| firstWaypoint.heading = location.course | ||
| firstWaypoint.headingAccuracy = 90 |
There was a problem hiding this comment.
There’s quite a bit of duplicated code between this method and reroute(from:). Let’s extract that shared code into a separate method instead of copy-pasting.
|
|
||
| strongSelf.lastLocationTimestamp = nil | ||
|
|
||
| if let route = routes?.first { |
There was a problem hiding this comment.
A guard statement would simplify this closure a bit.
| strongSelf.lastLocationTimestamp = nil | ||
|
|
||
| if let route = routes?.first { | ||
| let percentDifference = ((durationRemaining - route.expectedTravelTime) / route.expectedTravelTime) * 100 |
There was a problem hiding this comment.
If the user is currently 10 minutes from their destination and the new route is 9 minutes long, that’s a 10% improvement, but percentDifference is 11.1 because it puts the new route’s time in the denominator instead of the old route’s time.
| let percentDifference = ((durationRemaining - route.expectedTravelTime) / route.expectedTravelTime) * 100 | ||
|
|
||
| // Only use new route if it's at least 10% faster | ||
| if percentDifference > 10 { |
There was a problem hiding this comment.
The comment says 10% faster is adequate, but this if statement disagrees.
| } | ||
|
|
||
| // Only check ever 2 minutes for faster route | ||
| guard location.timestamp.timeIntervalSince1970 - lastLocationTimestamp.timeIntervalSince1970 > 120 else { return } |
There was a problem hiding this comment.
Use Date.timeIntervalSince(_:).
|
|
||
| let options = routeProgress.route.routeOptions | ||
| options.waypoints = [Waypoint(coordinate: location.coordinate)] + routeProgress.remainingWaypoints | ||
| if let firstWaypoint = options.waypoints.first, location.course >= 0 { |
There was a problem hiding this comment.
firstWaypoint is guaranteed to be the one we created above using Waypoint(coordinate:). Pull it out into a local variable and reuse it below.
1ec5
left a comment
There was a problem hiding this comment.
Interested to see what the refactored reroute(from:) ends up looking like.
|
|
||
| guard let currentUpcomingManeuver = routeProgress.currentLegProgress.upComingStep else { return } | ||
|
|
||
| guard let lastLocationTimestamp = lastLocationDate else { |
There was a problem hiding this comment.
Rename the local variable to lastLocationDate for consistency.
| } | ||
|
|
||
| // Only check ever 2 minutes for faster route | ||
| guard location.timestamp.timeIntervalSince(lastLocationTimestamp) > 120 else { return } |
There was a problem hiding this comment.
The comment implies that 120 seconds is enough for a new route, but this line disagrees.
| let percentDifference = ((durationRemaining - route.expectedTravelTime) / durationRemaining) * 100 | ||
|
|
||
| // Only use new route if it's at least 10% faster | ||
| if percentDifference >= 10 { |
There was a problem hiding this comment.
This can be simplified algebraically:
if route.expectedTravelTime <= 0.9 * durationRemainingThis is also safer, on the off-chance that we get into this code when the user is already at the destination. With the current code, there would be a division by zero.
|
@1ec5 updated |
| guard let lastLocationTimestamp = lastLocationDate else { | ||
| lastLocationDate = location.timestamp | ||
| guard let lastLocationDate = lastLocationDate else { | ||
| self.lastLocationDate = location.timestamp |
There was a problem hiding this comment.
Huh, I wouldn’t’ve expected that self. would be required here. 🤷♂️
| let durationRemaining = routeProgress.durationRemaining | ||
| let currentAlertLevel = routeProgress.currentLegProgress.alertUserLevel | ||
|
|
||
| getDirections(from: location) { [weak self] (route, error) in |
There was a problem hiding this comment.
We should reroute from the upcoming maneuver’s location, not from the user’s current location: #346 (comment).
|
|
||
| // Check for faster route given users current location | ||
| // | ||
| // If the user does not have much time left on the route, don't check for faster alternatives |
There was a problem hiding this comment.
This double-negative combined with the guard statement below make the logic a bit confusing. Here’s a less confusing way to describe what’s going on:
Only check for faster alternatives if the user has plenty of time left on the route.
| guard let route = route else { return } | ||
|
|
||
| if route.legs[0].steps[1].expectedTravelTime <= 70 { | ||
| let maneuverLocation = CLLocation(coordinate: currentUpcomingManeuver.maneuverLocation, altitude: 0, horizontalAccuracy: 1, verticalAccuracy: 1, course: currentUpcomingManeuver.finalHeading ?? currentUpcomingManeuver.initialHeading ?? location.course, speed: 10, timestamp: Date()) |
There was a problem hiding this comment.
This is an extremely long line. The course argument should be broken out into a local variable.
| // Only use new route if it's at least 10% faster | ||
| if route.expectedTravelTime <= 0.9 * durationRemaining { | ||
| // If the upcoming maneuver in the new route is the same as the current upcoming maneuver, don't announce it | ||
| strongSelf.routeProgress = RouteProgress(route: route, legIndex: 0, alertLevel: currentUpcomingManeuver.description == route.legs[0].steps[1].description ? currentAlertLevel : .none) |
There was a problem hiding this comment.
RouteStep.description is really just an alias for RouteStep.instructions, which isn’t guaranteed to be unique among RouteSteps. For example, what if currentUpcomingManeuver is a ramp step and the new route starts with a different ramp step? In both cases, instruction might be:
Take the ramp on the right
but the new step could be much closer. (instruction doesn’t include the “In distance” prefix.)
For the purposes of this feature, two steps are equivalent if they’re located in the same place and have the same maneuver type and direction. mapbox/mapbox-directions-swift#151 would implement a custom == operator for RouteStep, but there’s nothing stopping us from implementing the same logic here for now.
| if route.legs[0].steps[1].expectedTravelTime <= 70 { | ||
| let maneuverLocation = CLLocation(coordinate: currentUpcomingManeuver.maneuverLocation, altitude: 0, horizontalAccuracy: 1, verticalAccuracy: 1, course: currentUpcomingManeuver.finalHeading ?? currentUpcomingManeuver.initialHeading ?? location.course, speed: 10, timestamp: Date()) | ||
|
|
||
| strongSelf.getDirections(from: maneuverLocation, completion: { (route, error) in |
There was a problem hiding this comment.
Nit: use trailing closure syntax.
| }) | ||
| } else { | ||
| // Only use new route if it's at least 10% faster | ||
| if route.expectedTravelTime <= 0.9 * durationRemaining { |
There was a problem hiding this comment.
There’s a fair amount of duplication within this method. This if statement represents the case where the new route leaves enough time to maneuver. Since it appears twice in the same method, let’s factor it out into a local closure, then call it in both places.
| } | ||
|
|
||
| // Only check ever 2 minutes for faster route | ||
| guard location.timestamp.timeIntervalSince(lastLocationDate) >= 120 else { return } |
There was a problem hiding this comment.
Let's change this to once every 5 minutes. Eventually, we could adapt this for longer segments (e.g. >50 mile highway driving) and space this out to once every 10-15 minutes.
|
Ready for another round of reviews since tests are passing now. |
| @@ -351,10 +351,19 @@ extension RouteController: CLLocationManagerDelegate { | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
newRoute is just an unwrapped version of route, so it’s fine to shadow the original variable and call the new local variable route as well.
There was a problem hiding this comment.
A “compare” function returns whether something is greater, equal, or less than another thing, whereas this method potentially reroutes the user. A more accurate name would be rerouteIfFaster(onto:).
There was a problem hiding this comment.
We need to check whether the steps are equivalent, not just whether they occur at the same location. What if the current step turns left at this location, whereas the new route’s first step turns right at the same location? See mapbox/mapbox-directions-swift#151 for a rough sketch of what RouteStep equality would look like.
| // Only check for faster alternatives if the user has plenty of time left on the route. | ||
| guard routeProgress.durationRemaining > 600 else { return } | ||
| // If the user is approaching a maneuver, don't check for a faster alternatives | ||
| guard routeProgress.currentLegProgress.currentStepProgress.durationRemaining > 70 else { return } |
There was a problem hiding this comment.
Can we use the existing low alert level constant here?
| guard let firstLeg = route.legs.first, firstLeg.steps.count > 2 else { return } | ||
| strongSelf.lastLocationDate = nil | ||
|
|
||
| if firstLeg.steps[1].expectedTravelTime <= 70 { |
There was a problem hiding this comment.
Can we use the existing low alert level constant here?
| let course = currentUpcomingManeuver.finalHeading ?? currentUpcomingManeuver.initialHeading ?? location.course | ||
| let maneuverLocation = CLLocation(coordinate: currentUpcomingManeuver.maneuverLocation, altitude: 1, horizontalAccuracy: 1, verticalAccuracy: 1, course: course, speed: 1, timestamp: Date()) | ||
|
|
||
| strongSelf.getDirections(from: maneuverLocation) { (route, error) in |
There was a problem hiding this comment.
If you're not yet at the maneuver location, wouldn't this trigger another reroute?
There was a problem hiding this comment.
That’s entirely up to the logic that follows for determining whether the new route is distinct from the existing route. Currently it doesn’t even check whether the two routes are distinct; it just goes with the new one as long as it’s faster. Conceptually, we should only use the new route if doing so would keep the contents of the turn banner the same for now.
Moreover, in terms of the alert level, it compares the currentUpcomingManeuver’s coordinates with those of firstLeg.steps[1], which I think is incorrect. Consider the following scenario:
currentManeuverhas a few miles left.currentUpcomingManeuveris a right turn at Albuquerque.firstLeg.steps[1]is a departure at the user’s current location.firstLeg.steps[2]is a right turn at Albuquerque.
As I pointed out in #346 (comment), we should be comparing the maneuver type, direction, and coordinates of currentUpcomingManeuver and firstLeg.steps[2]. Otherwise, we are indeed guaranteed a reroute.
|
The behavior here could be a little funny/unexpected unless we also update the current route - for example using map matching. On longer trips, overall traffic conditions across all possible routes can significantly improve across the board and you'd be doing your 10% comparison against a stale version of your current route, putting it at a big disadvantage even thought it might still be a reasonable way to go. The opposite can also be true - traffic conditions for all possible routes can get significantly worse causing us to never pass the 10% threshold. In this situation we'd never update the ETA or congestion information on the current route. Updating the ETA (and congestion information) on the existing route as you drive feels like the higher priority. |
It high priority, but also more complicated to build right now given we'll need to build out support for the map-matching API in MapboxDirections.swift.
In this case, the new route will exceed the 10% threshold and the user will continue on the fastest route, now with an accurate ETA.
In this case, nothing changes from what we have now. We should ship this before ETAs since it's a quick win that will help overall, and in the 2 cases @willwhite provided we're no worse off than where we're at now. |
After sketching out a design mapbox/mapbox-directions-swift#158, this task doesn’t seem all that daunting. But it would be higher-hanging fruit than what we’ve got here. |
This PR adds the ability for the route controller to check for routes with better ETAs while the user is navigating. There are some checks in place to make sure we do this only when necessary:
If the new route has the same upcoming maneuver as the current route, the step is not announced since it already has been announced.
There currently is no UI around this. This is okay since the heuristics are extremely stiff; triggering a new route in it's current implementation will be extremely rare IMO.
/cc @willwhite @ericrwolfe @1ec5 @frederoni