Dead reckoning gap fill during GNSS outages
When the sky view disappears — a tunnel, a warehouse, a canyon between buildings — the receiver stops producing fixes and the pipeline stops having anything to filter. Dead reckoning fills that interval by integrating motion sensors the device already carries, and it does so with an error that grows without bound. This guide implements the integration, quantifies the growth honestly, and defines the point at which the output must stop being presented as a position. It belongs to trajectory simplification and smoothing within Local Spatial Processing Patterns.
What is actually being integrated
Two sensor sets are realistic on a field gateway. A wheel-odometry plus heading setup — an odometer pulse train or CAN speed signal, and a magnetometer or gyro for heading — integrates distance along a bearing and is by far the more accurate of the two: error grows with distance travelled, at roughly 1–3% of distance for a calibrated setup. An inertial-only setup — a MEMS accelerometer and gyroscope — has to integrate acceleration twice to get position, and the error grows with the square of elapsed time, which is why an uncorrected MEMS IMU is useless for position beyond about a minute.
The distinction matters enormously and is often glossed over. A vehicle with a speed signal can dead-reckon through a two-kilometre tunnel and emerge within 40 m of truth. The same vehicle relying on its MEMS accelerometer alone will be several hundred metres out after ninety seconds. Any design conversation about gap filling has to start with which of these the hardware actually provides.
The implementation
# dead_reckon.py — odometry-and-heading dead reckoning with honest uncertainty.
# State is carried in projected metres. Every emitted position is tagged with
# its source and a growing sigma so nothing downstream mistakes it for a fix.
import math
from dataclasses import dataclass
@dataclass(slots=True)
class Position:
east: float
north: float
sigma_m: float
source: str # "gnss" | "dr" | "stale"
age_s: float
class DeadReckoner:
"""Integrates distance along heading between GNSS fixes.
scale_error: fractional odometry error (0.02 = 2%, from calibration)
heading_sigma_deg_per_s: gyro drift; a cheap MEMS part is 0.5–2 °/s
"""
__slots__ = ("east", "north", "heading", "sigma", "elapsed",
"scale_error", "drift", "active")
def __init__(self, scale_error: float = 0.02,
heading_sigma_deg_per_s: float = 1.0):
self.scale_error = scale_error
self.drift = math.radians(heading_sigma_deg_per_s)
self.east = self.north = self.heading = 0.0
self.sigma = 0.0
self.elapsed = 0.0
self.active = False
# --- called on every good GNSS fix ---------------------------------
def anchor(self, east: float, north: float, heading_rad: float,
fix_sigma_m: float) -> Position:
self.east, self.north, self.heading = east, north, heading_rad
self.sigma = fix_sigma_m
self.elapsed = 0.0
self.active = False
return Position(east, north, fix_sigma_m, "gnss", 0.0)
# --- called on every odometry sample while GNSS is missing ---------
def step(self, distance_m: float, yaw_rate_rad_s: float,
dt: float) -> Position:
self.active = True
self.elapsed += dt
# Integrate heading first, then move along the mid-interval heading.
# Using the midpoint rather than the start halves the turn error.
half_turn = yaw_rate_rad_s * dt * 0.5
mid_heading = self.heading + half_turn
self.heading += yaw_rate_rad_s * dt
self.east += distance_m * math.sin(mid_heading)
self.north += distance_m * math.cos(mid_heading)
# Uncertainty: scale error acts along track, heading drift across it.
# Both accumulate; the cross-track term dominates after ~30 s.
along = distance_m * self.scale_error
across = distance_m * self.drift * self.elapsed
self.sigma = math.hypot(self.sigma, math.hypot(along, across))
return Position(self.east, self.north, self.sigma, "dr", self.elapsed)
# --- zero-velocity update ------------------------------------------
def zupt(self):
"""Called when the asset is known stationary (no odometry pulses for
a full second). Heading drift continues, but position error stops
accumulating — the single cheapest accuracy improvement available."""
self.elapsed = 0.0
The zero-velocity update deserves its own note because it is nearly free and disproportionately effective. Most assets spend a large fraction of any outage stationary — queuing in a tunnel, waiting at a loading bay — and during those intervals the naïve integrator continues to accumulate error from sensor bias while the asset does not move. Detecting a stop and freezing the position removes that entire error source. On a vehicle with an odometer the detection is trivial: no pulses for a second means stopped.
Constraint validation
| Constraint | Expected impact | Mitigation built into the code |
|---|---|---|
| RAM | Integration state must be tiny to run per asset | Eight floats per reckoner; no history, no buffer |
| CPU | Runs at sensor rate, not fix rate | Two trigonometric calls per step; ~1.5 µs on a Cortex-A53 at 10 Hz is negligible |
| Latency | Output feeds the display and the decision stage | Constant-time, causal, no lookahead |
| Accuracy | Error growth must be visible, not hidden | Every position carries sigma_m, source and age_s |
| Power | Sensors kept awake during the outage cost energy | The reckoner runs only while GNSS is absent; the duty-cycle policy in power and duty cycling governs the sensors themselves |
Gotchas and edge cases
- A dead-reckoned position is not a fix, and the difference must survive to the consumer. The
sourcefield is not decoration. A downstream system that averages GNSS and DR positions, or stores them in the same column without a flag, will eventually produce an analysis nobody can defend. - Magnetometer heading is unreliable exactly where dead reckoning is needed. Tunnels, warehouses and vehicle bodies contain steel and current-carrying cable, and a magnetometer inside them reads the local field, not the earth’s. Prefer a gyro integrated from the last known GNSS heading, and treat magnetometer heading as a coarse sanity check.
- Odometry scale drifts with tyre pressure and load. A 2% scale error calibrated in summer on an empty vehicle can be 4% in winter fully laden. Re-estimate the scale continuously from the ratio of odometry distance to GNSS distance while fixes are available, and carry the current estimate into the outage.
- The rejoin is a discontinuity. When GNSS returns, the reckoned position and the fix disagree by whatever the accumulated error is, and snapping produces a visible jump. Blend over a few seconds for display purposes — but record the raw fix, not the blend, because the blend is a presentation artefact.
- Do not let dead reckoning drive geofence decisions past its budget. A crossing declared from a position with 300 m of uncertainty is noise wearing a decision’s clothes. Suppress crossing events once sigma exceeds the fence’s own hysteresis band, which is the coupling that threshold-based event mapping expects.
Verification on the bench
Dead reckoning is one of the few things in this discipline that can be validated properly without a field trip. Drive a known loop with GNSS available, record both the fixes and the sensor stream, then replay the sensor stream with GNSS suppressed for controlled intervals and compare the reckoned track against the recorded fixes. That gives an empirical error-growth curve for the actual hardware, actual mounting and actual asset, which is worth more than any published sensor specification.
Run the same replay after every firmware change that touches the sensor path. The failure this catches — a sample rate change, a unit change, an axis swap after a board revision — is silent in normal operation because GNSS corrects it away, and appears only during an outage, which is to say only in the field.
Calibrating the odometry scale from GNSS
The single largest error term in odometry dead reckoning is the scale factor — how many metres one pulse or one CAN speed sample represents — and it is the one term that can be estimated continuously for free while fixes are available.
The estimator is a ratio: over a window where GNSS is healthy and the asset is moving, divide the distance the fixes travelled by the distance the odometry reported. Smooth that ratio heavily — it is a slowly varying physical property, not a measurement — and carry the current value into the next outage. A simple exponential moving average with a time constant of an hour is sufficient and costs two floats.
Two guards keep the estimate honest. Only update while the asset is moving above a threshold, because at low speed the GNSS distance is dominated by noise and the ratio becomes meaningless. And clamp the ratio to a plausible band — say 0.85 to 1.15 of the nominal — so that a burst of bad fixes cannot poison a value the device will rely on for the next tunnel.
The payoff is concrete: a scale error that would otherwise drift from 2% to 4% across a season stays near 1%, which halves the position error at the end of every outage without any additional hardware.
One more operational note: log the outage itself, not just the positions it produced. A record carrying the outage start, its duration, the accumulated sigma at the end and the reason it ended — reacquisition, or the device giving up — is a few dozen bytes and turns a fleet’s outage behaviour into something measurable. Sites where every vehicle loses GNSS for four minutes at the same place are a mapped fact rather than a rumour, and they are the sites where a repeater or a different antenna mount pays for itself.
Related
- Trajectory Simplification & Smoothing — the pipeline this fits into and the gap policy it implements.
- Kalman filtering noisy GPS fixes on a gateway — the filter that supplies the anchor and its heading.
- Building offline routing fallbacks for disconnected field devices — the tier ladder this is the last rung of.