Skip to content

Add passive smart rerouting#346

Merged
bsudekum merged 24 commits into
masterfrom
smartreroute
Jul 26, 2017
Merged

Add passive smart rerouting#346
bsudekum merged 24 commits into
masterfrom
smartreroute

Conversation

@bsudekum
Copy link
Copy Markdown
Contributor

@bsudekum bsudekum commented Jul 7, 2017

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:

  • There much be an upcoming step.
  • There must be at least 70 seconds left on the current step to allow ample maneuvering for new route.
  • The current route must have at least 10 minutes left on it.
  • The new route must have an ETA that is at least 10% faster

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

@bsudekum bsudekum requested a review from 1ec5 July 7, 2017 19:31
var lastReRouteLocation: CLLocation?

var routeTask: URLSessionDataTask?
var lastLocationTimestamp: Date?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@1ec5 how could be force this? Make the first waypoint the upcoming maneuver?

Copy link
Copy Markdown
Contributor

@1ec5 1ec5 Jul 11, 2017

Choose a reason for hiding this comment

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

Yes, exactly.

guard let currentUpcomingManeuver = routeProgress.currentLegProgress.upComingStep else { return }

guard let lastLocationTimestamp = lastLocationTimestamp else {
self.lastLocationTimestamp = location.timestamp
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

firstWaypoint is guaranteed to be the one we created above using Waypoint(coordinate:). Pull it out into a local variable and reuse it below.

Copy link
Copy Markdown
Contributor

@1ec5 1ec5 left a comment

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rename the local variable to lastLocationDate for consistency.

}

// Only check ever 2 minutes for faster route
guard location.timestamp.timeIntervalSince(lastLocationTimestamp) > 120 else { return }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can be simplified algebraically:

if route.expectedTravelTime <= 0.9 * durationRemaining

This 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.

@bsudekum
Copy link
Copy Markdown
Contributor Author

@1ec5 updated

guard let lastLocationTimestamp = lastLocationDate else {
lastLocationDate = location.timestamp
guard let lastLocationDate = lastLocationDate else {
self.lastLocationDate = location.timestamp
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown
Contributor

@1ec5 1ec5 Jul 12, 2017

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: use trailing closure syntax.

})
} else {
// Only use new route if it's at least 10% faster
if route.expectedTravelTime <= 0.9 * durationRemaining {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@bsudekum
Copy link
Copy Markdown
Contributor Author

Ready for another round of reviews since tests are passing now.

@@ -351,10 +351,19 @@ extension RouteController: CLLocationManagerDelegate {
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Bobby Sudekum added 2 commits July 13, 2017 15:23
// 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 }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you're not yet at the maneuver location, wouldn't this trigger another reroute?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  • currentManeuver has a few miles left.
  • currentUpcomingManeuver is 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.

@ericrwolfe ericrwolfe added this to the v0.6.0-1 milestone Jul 19, 2017
@ericrwolfe ericrwolfe added feature New feature request. topic: traffic labels Jul 19, 2017
@ericrwolfe ericrwolfe modified the milestones: v0.6.0-2, v0.6.0-1 Jul 21, 2017
@willwhite
Copy link
Copy Markdown

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.

@ericrwolfe
Copy link
Copy Markdown
Contributor

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.

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.

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.

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.

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.

@1ec5
Copy link
Copy Markdown
Contributor

1ec5 commented Jul 25, 2017

more complicated to build right now given we'll need to build out support for the map-matching API in MapboxDirections.swift

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.

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

Labels

feature New feature request. topic: traffic

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants