Workstation4 / Blog / Swift
Swift CoreMotion CoreLocation Sensors

Keeping the speed honest when GPS goes blind.

A speedometer that only trusts GPS has a quiet failure mode: it works beautifully outdoors, then sits at a flat 0.0 the moment you step inside. Walk a hallway, cross a parking garage, browse a hardware aisle, and the location stream either stops calling back or keeps handing you the same stale fix. The number freezes even though you are obviously moving. So I taught the app to lean on the sensors that still work when the sky disappears, and spent most of the effort making sure that fallback never pretended to be something it was not.

Why GPS quits indoors

Satellite positioning needs a line of sight to several satellites at once. A few floors of concrete and steel are enough to drown that signal. The frustrating part is how it fails: CLLocationManager does not hand you a polite error. It usually just stops delivering fresh locations, keeps returning a location whose timestamp is several seconds old, or reports a speed of zero with a horizontal accuracy that has quietly ballooned past 100 meters. A naive speedometer takes that at face value, parks itself at 0.0, and stays there.

That is technically correct from the satellite's point of view and useless from the person's. The whole job of the big number on screen is to reflect what is actually happening, and if you are walking a corridor at a normal pace, zero is a lie. I wanted the readout to keep telling the truth even when the most trusted sensor went dark, and to be plain about when it was estimating.

Two sensors, two jobs

The fallback uses two distinct pieces of CoreMotion, each answering a different question. Keeping them separate turned out to matter, because their personalities are opposite.

  • Are you moving at all? A CMMotionManager device-motion stream runs at 60 Hz and watches the magnitude of userAcceleration, which is acceleration with gravity already removed. I take the vector length across all three axes and compare it to a small threshold. Crossing it flips an isMoving flag. This is instant and cheap, but it only ever says yes or no.
  • How fast are you moving? Separately, CMPedometer reports currentPace in seconds per meter. That is the reciprocal of what a speedometer wants, so I invert it back into meters per second. This is a real measurement of your walking speed, but it updates more slowly and needs a few steps before it produces anything.

The accelerometer reacts instantly but cannot tell you a speed. The pedometer gives a genuine speed but lags and starts empty. Pairing them so the flag decides whether to show motion and the pedometer decides what to show let each cover the other's weakness.

The unit trap that bit me

The single sneakiest bug here was units. CMPedometer gives pace, not speed, and pace is the inverse of speed. Forget that flip and you get numbers that look completely plausible and are completely wrong: a brisk walk would read as a crawl and a slow stroll as a sprint. The conversion is one line, but it is the load-bearing line of the whole feature.

// CMPedometer reports pace in seconds per meter.
// Speed is its reciprocal: meters per second = 1 / pace.
if let pace = data.currentPace?.doubleValue, pace > 0 {
    let speedMps = 1.0 / pace
    DispatchQueue.main.async { self.currentPedometerSpeed = speedMps }
}

The pace > 0 guard is not decorative. A zero pace would divide to infinity, and a missing currentPace (it is an optional NSNumber) means the pedometer simply has not gathered enough steps yet. When that happens I let currentPedometerSpeed fall back to zero and lean on a fixed estimate instead, which I will get to below.

Deciding who wins, per reading

GPS, when it is healthy, is still the best source I have, so the arbitration is deliberately conservative. Every time a usable location arrives, the speed calculation runs in three steps. First it pulls a raw GPS speed, preferring location.speed but falling back to distance over time between the current and previous fix when the built-in speed is missing. Then it decides who to trust:

let threshold = 0.5  // m/s, roughly 1.1 mph

if rawGPSSpeed >= threshold {
    return (rawGPSSpeed, false)        // GPS sees real motion, trust it
} else if isMoving {
    if currentPedometerSpeed > 0 {
        return (currentPedometerSpeed, true)   // estimate from steps
    }
    return (indoorWalkingSpeed, true)  // 1.12 m/s placeholder, ~2.5 mph
} else {
    return (0.0, false)                // genuinely stationary
}

GPS wins whenever it shows meaningful movement above the 0.5 m/s floor. Only when GPS reads near zero and the motion flag says you are moving does the app substitute an estimate, and even then it prefers the measured pedometer speed and only falls back to the fixed 1.12 m/s when the pedometer has nothing yet. That constant is a calm average walking pace, treated explicitly as a placeholder rather than a measurement. Critically, the function returns a tuple, and that second boolean, isEstimated, follows the number everywhere it goes.

Two paths into the fallback

There is a subtlety I did not expect at first. The per-reading calculation above only runs when GPS is still delivering locations. But indoors the more common failure is that the location callbacks stop arriving altogether, so there is nothing to trigger that code. To cover that, a separate Timer fires every two seconds and watches how long it has been since the last location of any kind.

  • It gives GPS a 5 second warm-up window after launch before it is willing to declare anything wrong.
  • If more than 8 seconds pass with no location at all, it concludes the signal is genuinely gone, sets hasValidGPS to false, and drives the readout straight from the motion flag and pedometer using the same estimate logic.
  • The instant a fresh, accurate location arrives again, the location handler clears the "no GPS signal" message and GPS resumes leading.

So there are two doors into the estimated state: GPS that is present but reads zero, and GPS that has stopped talking entirely. Both converge on the same honest output.

Trusting fewer readings, not more

A lot of the reliability came from being picky about which GPS fixes to accept in the first place. The location handler runs each incoming fix through a short gauntlet: ignore the first 2 seconds after launch, reject any fix whose timestamp is more than 15 seconds old (stale cached locations are exactly what makes a frozen speedometer look alive), reject negative or very poor horizontal accuracy (over 100 meters, loosened to 200 during startup), and during the first 10 seconds reject suspiciously high speeds that are almost always a cached reading from a previous drive. Rejecting bad data early meant the fallback only ever had to choose between sources it could actually trust.

Telling the user it is an estimate

A fallback that silently impersonates GPS felt worse than no fallback at all, because it would erode trust in every number the app shows. So whenever the estimated path is active, isSpeedEstimated becomes true and the UI shows a small figure.walk.motion glyph in the toolbar. It is a quiet, persistent signal: this number came from your steps and your phone's motion sensors, not from satellites. When GPS recovers and drives the readout again, the flag clears and the glyph disappears.

The number is allowed to be an estimate. It is just not allowed to be a secret estimate.

That same boolean does one more important job. The fastest-speed tracker, which persists across launches in UserDefaults, refuses to record anything when isEstimated is true. A personal best is therefore always a real GPS reading and never an inflated walking guess. The estimate is good enough to keep the live display useful; it is not good enough to become a permanent record.

What I took from it

The walking-speed fallback is a small feature, but it had an outsized effect on how trustworthy the app feels. A few things stuck:

  • Sensors have personalities. The accelerometer is fast but vague; the pedometer is precise but slow to start. Building each to cover the other's blind spot was the fun part.
  • Invert units carefully. Pace is the reciprocal of speed, and that one line is the difference between a useful number and a confidently wrong one.
  • Failure is often silence, not an error. The two-second watchdog timer exists entirely because the most common indoor failure is callbacks that simply stop, with no exception to catch.
  • Reject bad input early. Most of the reliability came from filtering stale and inaccurate fixes before they ever reached the speed math.
  • Mark your estimates, and let the mark do real work. One boolean both shows the glyph and protects the high-score record from being polluted by a guess.

The goal was never a perfectly accurate indoor speedometer, which the hardware cannot really deliver. The goal was a number that keeps telling the truth, even when the truth is that this is an estimate and you are simply walking.

W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →