Skip to content
C.W.K.
Stream
Lesson 02 of 04 · published

Off-Route Is Confirmed, Not Guessed

~15 min · swift, gps, navigation, debounce, testing

Level 0Lost
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

A Single Fix Is a Rumour

Drive between tall buildings, under an overpass or out of a tunnel and your phone's position will occasionally jump. Satellite signals bounce off glass and concrete before they reach the antenna, the receiver solves for a position using a path that is longer than the real one, and the dot lands a street or two away. The nasty part is that the fix does not always look bad. Pathfinder's own test for this case uses a fix 200 m off the road that claims 10 m accuracy.

If one such fix were enough to call the car off route, the app would say "길을 벗어났어. 다시 찾아볼게", ask the provider for a new route from a position the car never occupied, and possibly start the new route with a U-turn. In the middle of a perfectly correct drive. So GuidanceEngine treats a single miss as a rumour and asks for corroboration.

Three Gates

1. Is the fix usable at all? A fix whose accuracy is worse than 120 m (maxMatchAccuracy), or whose coordinate is invalid, is not matched. Notice exactly what that means: it does not count as a miss and it does not reset the miss streak. It is simply not evidence about the route. (Its speed, if any, still feeds the smoothed speed, because that update runs before the gate.)

2. Is it on the route? The fix is projected onto the route line, inside a search window around the last progress, and its lateral distance is compared with a threshold of max(45, accuracy + 20) metres. A fix that admits 60 m of uncertainty gets 80 m of slack; a sharp 5 m fix gets the floor of 45. Before the very first match the threshold is raised to at least 150 m (joinDistance), because a route begins on the nearest road and the car often begins in a car park beside it: joining is judged more loosely than leaving. One more way to miss: on the right road but heading at least 120° away from the route's direction at 4 m/s or more is off route too, because that is a U-turn the route did not ask for.

3. Is the miss confirmed? The engine counts consecutive misses and remembers when the streak began. It declares off-route only when both hold: at least 3 misses (offRouteConfirmations) and at least 4 seconds since the first of them (offRouteMinDuration). Any on-route fix resets both. The phase becomes offRoute and exactly one offRoute(lateral:) event goes out; further misses say nothing more.

ConstantDrivingWhy
offRouteDistance45 mcloser than this is on the route
accuracyMargin+20 ma vague fix gets accuracy + 20 of slack
maxMatchAccuracy120 mworse fixes are not matched at all
joinDistance150 mthe first match, from a car park
offRouteConfirmations3misses in a row
offRouteMinDuration4 ssince the first of them
oppositeCourseDegrees120°wrong way, trusted from 4 m/s

Why a Count and a Duration

Either gate alone is easy to fool. A count alone falls for a burst: three bad fixes inside one second from the same reflection. A duration alone falls for two isolated fixes that happen to be far apart in time. Together they ask for a sustained disagreement. At one fix per second the duration is the gate that binds (the fifth miss, four seconds after the first, confirms it). When fixes thin out, say one every five seconds near a tunnel mouth, the count binds instead: two misses five seconds apart are not enough, the third is. The sketch below runs exactly those cases.

What Happens After

The engine only reports. The app's session says the off-route line, logs the event with the lateral distance and asks the same provider for a new route in the drive's own mode, passing the car's course so the new route need not begin with a U-turn (MapKit takes no heading, a known limit). While off route the engine widens its search window, three times the look-behind and twice the look-ahead, so it notices the car rejoining. If it does, backOnRoute cancels the pending reroute. If the provider fails, "길을 못 찾았어" is said once. A failure that waiting cannot fix stops the reroute right there: an exhausted quota, missing credentials, a permission or IP restriction, an unsupported region or mode, or an answer for another provider or mode (mismatchedProvider). Any other failure (the service unavailable, no route, an unreadable response, a dropped connection) is retried every 15 seconds while the engine still says off route. When the new route arrives, the session builds a fresh engine on it and feeds it the latest fix at once.

Walking keeps the same three gates with its own numbers: 35 m, 10 seconds, a 100 m join, and a wrong-way angle of 181° so a pedestrian turning on the pavement is never off route. Track 5 takes those apart.

An alarm that acts on a guess teaches people to ignore it. Ask for agreement across both count and time before you act, and let a sample you cannot trust be silent rather than counted either way.

Code

GuidanceEngine.swift: GuidanceConfig's driving numbers (verbatim, trimmed at the end)·swift
public struct GuidanceConfig: Sendable {
  /// A fix closer than this to the route is on it (raised by poor accuracy).
  public var offRouteDistance: Double = 45
  /// Accuracy above this widens the off-route threshold to accuracy + margin.
  public var accuracyMargin: Double = 20
  /// Consecutive off-route fixes AND seconds before declaring off-route — a
  /// single wild fix in a tunnel or under an overpass is not a wrong turn.
  public var offRouteConfirmations: Int = 3
  public var offRouteMinDuration: TimeInterval = 4
  /// Driving against the route's direction at speed is off-route even when
  /// the road is the same road (a U-turn the route did not ask for).
  public var oppositeCourseDegrees: Double = 120
  public var courseTrustSpeed: Double = 4
  /// Fixes worse than this are not matched at all.
  public var maxMatchAccuracy: Double = 120
  /// Search window around the last progress.
  public var lookBehind: Double = 80
  public var minLookAhead: Double = 500
  /// Arrival: this close to the end of the route.
  public var arrivalDistance: Double = 35
  /// Before the first match, a fix this close to the route joins it.
  public var joinDistance: Double = 150
  // ... highway speed, chain distance, town plan (trimmed)
}
GuidanceEngine.swift: update(_:), with the on-route branch trimmed·swift
  public mutating func update(_ fix: GuidanceFix) -> [GuidanceEvent] {
    guard phase != .arrived else { return [] }
    var events: [GuidanceEvent] = []
    defer { lastFix = fix }
    updateSpeed(fix)
    guard fix.accuracy <= config.maxMatchAccuracy, fix.coordinate.isValid else { return events }

    let match = matchFix(fix)
    var threshold = max(config.offRouteDistance, fix.accuracy + config.accuracyMargin)
    if phase == .waitingForFix {
      // A route begins on the nearest road, and the car often begins in a car
      // park beside it: joining the route is judged more loosely than
      // leaving it.
      threshold = max(threshold, config.joinDistance)
    }
    var onRoute = (match?.lateral ?? .infinity) <= threshold
    if onRoute, let match, let course = fix.course, (fix.speed ?? 0) >= config.courseTrustSpeed,
      Geo.angleBetween(course, match.bearing) >= config.oppositeCourseDegrees
    {
      onRoute = false
    }

    if onRoute, let match {
      // ... progress, arrival and announcements (trimmed)
    } else {
      offRouteCount += 1
      if offRouteSince == nil { offRouteSince = fix.timestamp }
      let lasted = fix.timestamp.timeIntervalSince(offRouteSince ?? fix.timestamp)
      if phase != .offRoute, offRouteCount >= config.offRouteConfirmations,
        lasted >= config.offRouteMinDuration
      {
        phase = .offRoute
        events.append(.offRoute(lateral: match?.lateral ?? .infinity))
      }
    }
    return events
  }
A minimal sketch of the off-route rule with the driving numbers (compiled and run with swiftc)·swift
import Foundation

// A minimal sketch of Pathfinder's off-route rule, with GuidanceConfig's
// driving numbers. Input is already matched: how far the fix is from the
// route (lateral), how good the fix claims to be (accuracy), and when.
struct OffRouteJudge {
  var offRouteDistance = 45.0, accuracyMargin = 20.0
  var confirmations = 3, minDuration = 4.0
  var maxMatchAccuracy = 120.0, joinDistance = 150.0
  private(set) var joined = false, offRoute = false
  private var misses = 0
  private var since: Double?

  /// Returns true exactly once: when leaving the route is confirmed.
  mutating func update(lateral: Double, accuracy: Double, time: Double) -> Bool {
    guard accuracy <= maxMatchAccuracy else { return false }  // not matched at all
    var threshold = max(offRouteDistance, accuracy + accuracyMargin)
    if !joined { threshold = max(threshold, joinDistance) }   // car-park start
    if lateral <= threshold {
      joined = true; offRoute = false; misses = 0; since = nil
      return false
    }
    misses += 1
    if since == nil { since = time }
    if !offRoute, misses >= confirmations, time - since! >= minDuration {
      offRoute = true
      return true
    }
    return false
  }
}

func run(_ name: String, _ fixes: [(lateral: Double, accuracy: Double, time: Double)]) {
  var judge = OffRouteJudge()
  let fired = fixes.filter { judge.update(lateral: $0.lateral, accuracy: $0.accuracy, time: $0.time) }
  let line = name.padding(toLength: 34, withPad: " ", startingAt: 0)
  print(line, judge.joined ? "joined" : "never joined", "off-route at:", fired.map { "t=\($0.time)" })
}

run("first fix 110 m off (car park)", [(110, 15, 0)])
run("first fix 160 m off", [(160, 15, 0)])
run("one wild fix, accuracy 10", [(5, 5, 0), (200, 10, 1), (5, 5, 2), (5, 5, 3)])
run("3 misses inside 1.5 s", [(5, 5, 0), (90, 8, 1), (90, 8, 1.5), (90, 8, 2.5)])
run("left the road, 1 fix/s", [(5, 5, 0)] + (1...8).map { (60 + Double($0) * 12, 8, Double($0)) })
run("sparse fixes, 1 per 5 s", [(5, 5, 0), (90, 8, 5), (90, 8, 10), (90, 8, 15)])
run("accuracy 60: 70 m is still on", [(5, 5, 0), (70, 60, 1), (70, 60, 2), (70, 60, 3), (70, 60, 9)])
run("a 150 m-accuracy fix is ignored", [(5, 5, 0), (90, 8, 1), (90, 150, 2), (90, 8, 3), (90, 8, 5)])
// first fix 110 m off (car park)     joined off-route at: []
// first fix 160 m off                never joined off-route at: []
// one wild fix, accuracy 10          joined off-route at: []
// 3 misses inside 1.5 s              joined off-route at: []
// left the road, 1 fix/s             joined off-route at: ["t=5.0"]
// sparse fixes, 1 per 5 s            joined off-route at: ["t=15.0"]
// accuracy 60: 70 m is still on      joined off-route at: []
// a 150 m-accuracy fix is ignored    joined off-route at: ["t=5.0"]

External links

Exercise

Take any alarm in your own system that fires on one sample: a health check, a disk-usage alert, a 'user went idle' flag. Write a judge struct with a count gate and a duration gate, both configurable, plus a rule for samples you cannot trust. Build five synthetic traces (a single spike, a burst of three inside one second, sparse samples far apart, a sustained real failure, and a sustained failure with one untrustworthy sample in the middle) and record which ones fire under count-only, duration-only and both.
Hint
Keep the timestamp in the sample, as GuidanceFix does, so the traces are plain arrays. The interesting row is the burst: if count-only fires on it and both-gates does not, you have just measured why the second gate exists.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.