Fallback Routing & Offline Navigation

Within the Core Edge GIS Fundamentals framework, this guide covers how a field device keeps navigating, dispatching, and logging spatial telemetry when its backhaul link drops — degrading routing gracefully instead of halting the mission.

Field operations rarely guarantee continuous backhaul connectivity. When IoT gateways, ruggedized tablets, or autonomous field sensors lose cellular or satellite links, spatial routing must degrade gracefully rather than stall. The decision is not whether the link will drop but how the stack behaves the moment it does: which solver runs, where coordinates are projected, what gets buffered, and how the device reconciles state when the link returns. This guide details the connectivity state machine, the constraint envelope that shapes every choice, the memory-mapped graph and coordinate pipeline that make local routing affordable, and the field diagnostics that catch a degraded device before a crew does.

The Fallback Decision Flow

Offline routing is a state-management problem before it is a pathfinding problem. The device continuously samples link health and moves between operating modes, swapping its routing source, telemetry policy, and power profile at each transition. Cloud routing stays authoritative while the link is healthy; a local solver takes over the moment latency or loss crosses a threshold; and a reconciliation pass flushes buffered telemetry and replays graph deltas when backhaul returns.

Connectivity state machine: CONNECTED, DEGRADED, OFFLINE, and RECOVERING The device boots into CONNECTED, where cloud routing is authoritative and the local graph is kept warm by live deltas. High latency or packet loss moves it to DEGRADED, where a local A* solver takes over and telemetry is queued while the link is held half-open; if the link recovers it returns to CONNECTED. Zero backhaul moves DEGRADED to OFFLINE, which locks the coordinate system to the cached grid, disables tile fetches, and throttles power aggressively. When backhaul is restored the device enters RECOVERING to flush queued telemetry and reconcile graph deltas; reconciled deltas return it to CONNECTED, while a failed sync drops it back to OFFLINE. CONNECTED cloud routing · live graph deltas power profile: nominal DEGRADED local A* solver · telemetry queued power: nominal · link half-open OFFLINE CRS locked · tiles off · queue spills power: aggressive throttle RECOVERING flush telemetry · reconcile deltas power: nominal high latency / packet loss link recovered zero backhaul backhaul restored sync failed deltas reconciled

Connectivity state machine for offline navigation.

A production-ready fallback state machine tracks four states:

  1. CONNECTED — cloud routing active, local graph kept warm via WebSocket/MQTT deltas.
  2. DEGRADED — high latency or packet loss detected; switch to the local A* solver and queue telemetry locally while keeping the link half-open.
  3. OFFLINE — zero backhaul; lock the coordinate pipeline to the cached grid, disable tile fetches, and enable aggressive battery throttling.
  4. RECOVERING — backhaul restored; flush queued telemetry, reconcile graph deltas, then transition back to CONNECTED.

The transitions matter as much as the states. Bouncing between CONNECTED and DEGRADED on a flapping link wastes power and thrashes the solver, so the health check should hysteresis-gate the transition — require N consecutive healthy probes before promoting back, and use the same retry discipline described in retry and backoff for unstable networks so the device does not hammer a marginal modem.

Constraint Mapping: What the Hardware Forces

Every technique below is chosen because it fits inside a fixed envelope. A fallback stack that runs comfortably on a 2 GB Cortex-A72 gateway will OOM-kill itself on a 256 MB Cortex-A53. Before selecting a solver or a tile cache size, map the target against the limits catalogued in Device Constraints & Resource Limits — fallback routing is one of the heaviest line items inside that budget because it runs a graph search, a coordinate transform, and a tile renderer concurrently.

Constraint Edge reality Direct effect on fallback routing
RAM ceiling 256 MB–2 GB, usually no swap A 500 MB road network cannot live in heap; forces memory-mapped graphs and subgraph windowing
CPU / GIL 2–4 cores, single Python GIL Blocking Dijkstra/A* stalls GNSS polling and telemetry ingestion; pushes the solver into C/FFI on a dedicated thread
Thermal envelope Passive cooling, throttle ~75–85 °C Sustained pathfinding competes with the modem and GNSS for the thermal budget; demands duty-cycling under load
Flash / eMMC Tens to hundreds of MB free, finite write endurance Tile and telemetry buffers must use strict LRU eviction and bounded queues to avoid filling the rootfs
Power budget Battery or solar, fixed duty cycle OFFLINE mode must lower solver thread priority and widen tolerance to extend runtime

These envelopes interact: trimming RAM pressure raises CPU load, sustained CPU load raises die temperature, and thermal throttling lengthens solver latency until a watchdog fires. The patterns that follow each pick a side of these trade-offs deliberately.

Memory-Mapped Graphs & the Compute Envelope

Edge routing engines must operate within strict computational envelopes. Unlike cloud solvers that scale horizontally, an IoT gateway runs on an ARM SoC with limited RAM, a constrained thermal envelope, and intermittent power. The first design rule is to never load an entire road network into heap.

A practical pattern precomputes routing graphs during provisioning and loads only the relevant subgraph into memory at runtime. Using memory-mapped files for traversal lets the kernel page graph regions in on demand and evict them under pressure. For a typical 500 MB regional network, a memory-mapped adjacency structure holds peak resident memory under 120 MB while keeping sub-50 ms pathfinding latency on a quad-core Cortex-A55. Because the mapping is read-only and shared, it also survives a worker restart without re-parsing — important when a thermal event forces a process bounce.

import mmap
import struct
import os
from pathlib import Path

class EdgeRoutingGraph:
    """Read-only, zero-copy graph view. No per-query heap allocation; the
    kernel pages regions in on demand and reclaims them under memory pressure.
    Not GC-managed beyond the mmap object itself — call close() deterministically."""

    def __init__(self, graph_path: Path):
        self._fd = os.open(str(graph_path), os.O_RDONLY)
        self._mmap = mmap.mmap(self._fd, 0, access=mmap.ACCESS_READ)
        # Header: 4 bytes (node_count), 4 bytes (edge_count)
        self.node_count = struct.unpack_from('I', self._mmap, 0)[0]
        self.edge_count = struct.unpack_from('I', self._mmap, 4)[0]
        self._edge_offset = 8  # Start of edge array

    def get_neighbors(self, node_id: int) -> list[tuple[int, float]]:
        """Zero-copy edge traversal. Layout: 4-byte node_id + 4-byte float
        weight = 8 bytes per neighbour, two neighbours per fixed record."""
        offset = self._edge_offset + (node_id * 16)
        raw = self._mmap[offset:offset + 16]
        if not raw:
            return []
        n0_id, n0_w, n1_id, n1_w = struct.unpack('IfIf', raw)
        return [(n0_id, n0_w), (n1_id, n1_w)]

    def close(self):
        self._mmap.close()
        os.close(self._fd)

Reference: the Python mmap documentation covers platform-specific flags and memory locking. On Linux gateways, advising the kernel with madvise(MADV_RANDOM) on the mapping prevents wasteful read-ahead when graph access is pointer-chasing rather than sequential.

Deterministic CRS Alignment in the Routing Loop

Spatial accuracy at the edge depends on consistent coordinate handling. Field devices ingest GNSS NMEA streams in WGS84, while offline routing graphs are stored in a local projected frame — UTM or State Plane — to minimise distortion and accelerate distance maths. Misaligned transforms during fallback routing introduce cumulative drift that compounds over a long traversal and silently corrupts every snapped position.

A lightweight, deterministic transformation pipeline keeps the coordinate reference systems at the edge synchronised between the live GNSS receiver and the cached routing layers. Pre-transform every graph node to the target CRS during provisioning, pin a single pyproj transformer at module scope to avoid re-paying C-FFI setup, and apply live GNSS-to-UTM conversion in the routing loop with an explicit drift tolerance. The tolerance — how far a fix may sit from its nearest graph node before the match is rejected — is a spatial precision decision, not a guess: set it from your GNSS error model, not a round number.

from pyproj import Transformer
import numpy as np

# Pinned at module scope: built once, zero network I/O, no per-call FFI setup.
WGS84_TO_UTM = Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)

def transform_live_gnss(lat: float, lon: float) -> tuple[float, float]:
    return WGS84_TO_UTM.transform(lon, lat)  # always_xy => (lon, lat) order

def validate_position_match(gnss_utm: tuple, graph_node_utm: tuple,
                            tolerance_m: float = 15.0) -> bool:
    """Reject a snap when the live fix is further than tolerance from the node.
    tolerance_m should come from the device's GNSS CEP, not a round number."""
    dx = gnss_utm[0] - graph_node_utm[0]
    dy = gnss_utm[1] - graph_node_utm[1]
    return np.hypot(dx, dy) <= tolerance_m

Reference: the PROJ coordinate transformation engine handles grid shifts and CRS metadata. Bundle only the regional datum grids in the firmware image and set PROJ_NETWORK=OFF so a transform never blocks on an HTTP grid fetch while the link is down.

Async I/O & FFI Solver Integration

Python’s GIL bottlenecks real-time routing when high-frequency GNSS updates contend with telemetry ingestion. Heavy Dijkstra/A* work belongs in compiled C/C++ reached through FFI (cffi, ctypes, or pybind11), running in a dedicated executor while asyncio owns the I/O-bound tasks — GNSS polling, sensor telemetry, tile reads. This is the same discipline applied across async execution for spatial workloads: any blocking FFI call must be pushed off the event loop so the ingestion path never misses a fix.

import asyncio
from concurrent.futures import ThreadPoolExecutor
from ctypes import CDLL, c_int, c_double, POINTER

# FFI binding to a precompiled A* solver. The .so releases the GIL around the
# search (see compile flags below), so a single executor thread is enough.
_solver = CDLL("./libedge_astar.so")
_solver.solve_route.argtypes = [c_int, c_int, c_double, c_double, POINTER(c_int)]
_solver.solve_route.restype = c_int

async def async_routing_loop(gnss_stream: asyncio.Queue, route_queue: asyncio.Queue):
    executor = ThreadPoolExecutor(max_workers=1)  # serialise solver calls
    loop = asyncio.get_running_loop()

    while True:
        lat, lon = await gnss_stream.get()
        path_buffer = (c_int * 1024)()  # caller-owned, fixed: no hot-path malloc
        route_len = await loop.run_in_executor(
            executor,
            _solver.solve_route,
            int(lat * 1e6), int(lon * 1e6),  # fixed-point micro-degrees
            0.0, 0.0, path_buffer,
        )
        if route_len > 0:
            await route_queue.put(list(path_buffer[:route_len]))

The buffer is allocated once per call by the caller and handed to the solver, so the hot path never mallocs inside C. Reference: the Python asyncio documentation covers executor integration and loop lifetime. The routed path then flows to the operator UI alongside the cached base map described next.

Base Map Caching & Tile Orchestration

Routing topology needs visual context for operator situational awareness. Caching vector tiles for offline field navigation keeps the UI coherent when backhaul drops. Store tiles in MBTiles/SQLite, render with MapLibre or OpenLayers, and compress with zstd or gzip. Pre-fetch tiles for the mission bounding box during provisioning and enforce a strict LRU eviction policy so the cache cannot exhaust the eMMC or SD card.

Handle tile expiration gracefully: check ETag headers during sync windows and apply delta updates rather than full re-downloads — the same wire-efficiency mindset as delta sync for spatial datasets. When the device goes fully OFFLINE, disable tile-refresh timers and lock the viewport to the cached extent so the map does not jitter chasing tiles that will never arrive.

Configuration & Tuning

Fallback routing lives or dies on a handful of environment-specific knobs. These are the settings worth pinning in the deployment manifest.

Compile flags for the FFI solver. Build the A* shared object for size and determinism, drop exception machinery, and release the GIL around the search so the executor thread does not block the event loop:

gcc -O2 -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections \
    -fvisibility=hidden -shared -fPIC \
    -o libedge_astar.so edge_astar.c

SQLite PRAGMAs for the tile and telemetry stores. Put the MBTiles cache and the telemetry queue in WAL mode so reads never block the writer, cap the WAL so it cannot grow without bound on a power loss, and trade a little durability for flash longevity:

PRAGMA journal_mode = WAL;        -- concurrent readers during writes
PRAGMA synchronous = NORMAL;      -- fewer fsyncs; safe with WAL on UPS-less nodes
PRAGMA wal_autocheckpoint = 1000; -- bound WAL growth (~4 MB at 4 KB pages)
PRAGMA mmap_size = 67108864;      -- 64 MB memory-mapped I/O for tile reads
PRAGMA cache_size = -2000;        -- 2 MB page cache ceiling, not unbounded

Sysfs and runtime knobs. Read die temperature from /sys/class/thermal/thermal_zone*/temp and the CPU governor from /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor. In OFFLINE mode, lower the solver thread’s niceness, widen the A* heuristic tolerance, and lengthen the GNSS poll interval to stretch the power budget. Bound the telemetry queue explicitly — an unbounded asyncio.Queue will OOM a 256 MB gateway after a long outage, so cap it and apply the overflow policy from message queue management at the edge.

Verification & Field Diagnostics

Offline routing failures are rarely algorithmic; they are environmental — thermal throttling, a corrupted graph file, CRS misalignment, or GNSS multipath. The device must be self-describing without a network. Emit structured JSON logs with rotation enabled so a technician can read the last known state off local flash:

{"ts": "2026-06-15T08:12:44Z", "state": "OFFLINE", "solver_latency_ms": 42, "mem_rss_mb": 118, "crs_drift_m": 2.1, "thermal_throttle": false}

Deploy a lightweight local diagnostic endpoint — for example a FastAPI app bound to 127.0.0.1:8080 — so a field technician can query the device over USB or a local AP without any external network:

  • /debug/graph — current subgraph bounds, node/edge counts, and mmap residency.
  • /debug/position — live GNSS fix versus projected UTM, with the running drift metric.
  • /debug/solver — last route cost, executor queue depth, and FFI error codes.

To confirm the fallback path actually works on a deployed unit, force the transitions rather than waiting for a real outage: drop the modem interface (ip link set wwan0 down) and assert the state machine reaches OFFLINE within the health-check window, that /debug/solver shows the local solver answering, and that telemetry is accumulating in the SQLite queue rather than being lost. Bring the interface back up and confirm RECOVERING flushes the queue and reconciles graph deltas before returning to CONNECTED.

Failure Modes Specific to This Pattern

Failure mode How it presents Detection Safe recovery
Graph file corruption Solver returns empty paths or segfaults the .so Boot-time checksum + node/edge count assertion Fall back to a smaller bundled subgraph; flag for re-provision
Thermal throttle under load solver_latency_ms spikes, clocks scale down thermal_zone*/temp over 85 °C Lower solver priority, widen tolerance, duty-cycle the search
CRS drift accumulation Snapped positions wander off the road network crs_drift_m trending above tolerance Re-pin transformer, re-validate against control points
GNSS multipath / loss of fix Jumping positions, failed node matches Drop in fix quality + validate_position_match rejects Dead-reckon from last good fix; hold viewport until reacquired
Telemetry queue saturation Rising mem_rss_mb, eMMC filling Bounded-queue depth metric Spill to SQLite with LRU, shed non-critical telemetry
State-machine flapping Rapid CONNECTEDDEGRADED churn Transition-rate counter Hysteresis gate: require N healthy probes before promotion

Fallback routing at the edge is a systems-engineering problem, not a pure GIS problem. Success comes from tight coupling between memory management, the coordinate pipeline, async I/O, and deterministic FFI execution — wired into a state machine that knows exactly what to do the moment the link disappears. The orchestration layer that ties these together is covered in detail in building offline routing fallbacks for disconnected field devices.