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.

Position error growth by sensor set during an outage Error against time since the last GNSS fix. Odometry with a gyro heading grows roughly linearly with distance, reaching about 20 metres at one minute and 90 at five. A MEMS inertial measurement unit alone grows quadratically, passing 100 metres by 45 seconds and 900 by three minutes. A gyro-aided odometry setup with a periodic zero-velocity update stays under 30 metres for the full five minutes. A horizontal line marks the 50 metre threshold beyond which lane-level guidance stops being safe to display. Which sensors you have decides how long the gap can be 1000 m500 m200 m50 m guidance cut-off · 50 m MEMS IMU only — quadratic odometry + gyro heading odometry + gyro + zero-velocity updates 02.5 min5 min
The three curves differ by an order of magnitude at every point. Choosing the sensor set is the design decision; the integration code is the easy part.

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 source field 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.
What each consumer is allowed to do as uncertainty grows Four bands of positional uncertainty with the consumers permitted in each. Below 10 metres, everything is allowed: display, geofence decisions, dwell detection and storage. Between 10 and 50 metres, display and storage continue but geofence crossings are suppressed. Between 50 and 200 metres, only a coarse display with a visible uncertainty circle and storage flagged as dead reckoned. Above 200 metres, the position is logged but not displayed as a location, and the interface shows a last-known position with an age instead. The output does not become wrong at a threshold — its permitted uses narrow σ < 10 m display · geofence crossings · dwell detection · storage indistinguishable in practice from a GNSS fix, but still flagged as DR 10–50 m display · storage — geofence crossings suppressed a crossing here cannot be distinguished from the error 50–200 m coarse display with a visible uncertainty circle · storage flagged the circle is the message, not the dot σ > 200 m logged, not displayed as a location show the last known fix with its age instead — an honest absence beats a confident guess
Every band still produces a position. What changes is who is allowed to act on it, which is a policy the device can enforce because it knows its own sigma.

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.

Error accumulation with and without zero-velocity updates during a mixed outage A twelve minute outage in which the asset moves for four minutes, waits for six and moves for two. Without zero-velocity updates the error grows throughout, including during the stationary period, reaching 96 metres. With zero-velocity updates the error is frozen while the asset is stopped and reaches 41 metres — a difference produced entirely by not accumulating error during the interval when nothing happened. Six of the twelve minutes were spent stationary 100 m70 m35 m stationary · 6 min 96 m without ZUPT 41 m with ZUPT Detecting the stop costs one comparison per sample. Not detecting it costs 55 m of error the asset never earned.
The flat segment is the whole trick: sensor bias keeps integrating whether or not the asset moves, and the only defence is knowing that it did not.

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.