Delta Sync for Spatial Datasets

Delta sync for spatial datasets transmits only the geometric and attribute changes between observation cycles, so a field gateway holds shared state with the cloud without re-sending whole feature collections over a metered link. Within the broader Bandwidth & Async Sync Optimization practice, this is the pattern that decides whether a Cortex-A node survives a day of LTE throttling on a single megabyte budget or saturates its uplink re-shipping points it already sent. This page covers how to detect spatial change cheaply, pack it into a wire-efficient frame, move it through a non-blocking pipeline, and prove on a deployed device that the two sides have not drifted apart.

The decision flow below is the heart of the technique: every incoming observation is classified as a create, an update, or noise to be dropped, and only the first two ever reach the transmission buffer.

Delta generation: emit only the changes that exceed the movement threshold.

Delta generation decision flow Each incoming point (id, latitude, longitude) is classified. A first-seen point emits a create delta. Otherwise the equirectangular metres moved is computed: if it exceeds the movement threshold an update delta is emitted, otherwise the point is skipped as within the sensor noise floor. Create and update deltas enter a bounded deque ring buffer that is flushed for asynchronous transmission; skipped points never reach the buffer. yes no yes no Incoming point id · lat · lon First seen? Compute metres moved equirectangular Δ Δ > threshold? Emit create delta Emit update delta Skip — noise floor nothing to transmit Bounded deque buffer maxlen ring Flush for async transmission

Constraint Mapping: What Actually Limits Delta Sync

Before choosing a diff strategy, fix the envelope it has to run inside. Delta sync looks trivial on a workstation, but on a gateway the bottleneck is rarely the diff arithmetic — it is heap fragmentation, garbage-collector pauses, and the radio’s duty cycle. These ceilings are the same first-class design parameters covered under device constraints and resource limits, and they dictate buffer depth, flush cadence, and how aggressively you raise the movement threshold.

Constraint Typical edge ceiling How it bites delta sync Mitigation in this pattern
RAM 256 MB–2 GB shared with the OS Per-feature dict allocation fragments the heap; GeoJSON re-parsing spikes RSS __slots__, bounded deque, last-state cache keyed by id only
CPU ARM Cortex-A53/A72, 0.6–1.5 GHz Haversine/GEOS calls dominate at 100+ Hz Equirectangular approximation, optional C/Rust FFI for the hot path
Uplink 50 kbps–2 Mbps, metered, bursty Whole-payload resends saturate the link and trip the watchdog Transmit only Δ fields, batch-flush, compress at transport
Power Solar / battery, mA-hour budgets Radio TX is the largest single draw Fewer, larger flushes; skip-on-noise to suppress idle traffic
Flash endurance eMMC/SD, finite write cycles Spill-to-disk on every delta wears the card Append-only WAL, coalesced flushes, LRU eviction

The throughput win comes entirely from the right column of that table. If you never resolve the movement threshold correctly, every subsequent optimisation — binary packing, compression, batching — is applied to traffic that should not have existed in the first place.

Memory-Constrained Delta Generation

Spatial delta generation requires deterministic change detection at the coordinate level. Avoid diffing raw GeoJSON or Shapefile payloads; instead compute deltas against a lightweight last-state cache and a fixed-size coordinate buffer. The implementation below is optimised for ARM-based edge hardware: it uses a rolling deque, holds last-seen state in a flat dict keyed by feature id, and computes minimal deltas with an equirectangular metre approximation before serialisation — deliberately bypassing heavy GEOS/PyGEOS calls to prevent heap fragmentation and GC pauses.

The distance test uses the local equirectangular projection, which is accurate to well under a metre over the sub-second displacements seen between cycles and costs one cos instead of the trig-heavy haversine:

dR(Δϕ)2+(cosϕΔλ)2d \approx R \sqrt{(\Delta\phi)^2 + (\cos\phi \cdot \Delta\lambda)^2}

where RR is the Earth radius in metres, ϕ\phi is latitude and λ\lambda is longitude in radians. Scaling longitude by cosϕ\cos\phi corrects for meridian convergence; the code below inlines the constant R=6,371,000R = 6{,}371{,}000 m as 111_320 metres-per-degree to skip the radian conversion on latitude entirely.

import struct
import time
import math
from collections import deque
from typing import List, Dict, Any, Optional

class SpatialDeltaTracker:
    # Single-threaded by design: drive it from one asyncio event loop.
    # Never share an instance across threads without an external lock.
    __slots__ = ("buffer", "threshold", "_cache", "_last_flush")
    # Memory footprint: ~1.2 KB per 1000-point buffer
    # CPU impact: O(N) per cycle, avoids heavy GEOS/PyGEOS overhead

    def __init__(self, buffer_size: int = 1000, threshold_m: float = 0.5):
        self.buffer: deque = deque(maxlen=buffer_size)
        self.threshold = threshold_m
        self._cache: Dict[str, tuple] = {}
        self._last_flush: float = time.monotonic()

    def ingest(self, point_id: str, lat: float, lon: float, ts: float) -> Optional[List[Dict[str, Any]]]:
        prev = self._cache.get(point_id)
        if prev is None:
            self._cache[point_id] = (lat, lon, ts)
            delta = {"id": point_id, "lat": lat, "lon": lon, "ts": ts, "op": "create"}
            self.buffer.append(delta)
            return [delta]

        prev_lat, prev_lon, _ = prev
        # Equirectangular metres: longitude scaled by cos(latitude).
        dx = (lon - prev_lon) * 111_320 * math.cos(math.radians(lat))
        dy = (lat - prev_lat) * 111_320
        dist = math.sqrt(dx * dx + dy * dy)

        if dist > self.threshold:
            self._cache[point_id] = (lat, lon, ts)
            delta = {"id": point_id, "lat": lat, "lon": lon, "ts": ts, "op": "update"}
            self.buffer.append(delta)
            return [delta]
        return None  # within the noise floor: nothing to transmit

    def flush_buffer(self) -> List[Dict[str, Any]]:
        """Extract pending deltas for async transmission. Safe inside a single event loop."""
        if not self.buffer:
            return []
        pending = list(self.buffer)
        self.buffer.clear()
        self._last_flush = time.monotonic()
        return pending

Two design choices matter for memory stability. The deque(maxlen=...) gives an O(1) bounded ring buffer that drops the oldest delta under pressure instead of growing without limit, and __slots__ removes the per-instance __dict__, which keeps a fleet of trackers (one per sensor) off the fragmenting small-object heap. The last-state cache stores only three floats per id, so a gateway tracking 10,000 features holds roughly 240 KB of state — comfortably inside the RAM budget above.

The movement threshold is doing the same job that on-device geometry checks do upstream: it is a gatekeeper that decides which observations are worth the radio. Choosing it well is the single highest-leverage tuning decision on this page, and it is closely related to the threshold-based event mapping patterns used to fire spatial triggers — both turn a continuous coordinate stream into a sparse stream of meaningful events.

For sub-millisecond processing at 100+ Hz, Python’s object-allocation overhead becomes the bottleneck. Offload coordinate diffing to C or Rust via ctypes or pybind11, and serialise straight into contiguous binary buffers with struct.pack('<ddd', lat, lon, ts), bypassing the per-delta dict entirely. The official Python struct module reference documents the byte-order and alignment rules you must pin down before a C consumer reads those frames.

Binary Packing and Delta Encoding

A create/update dict is convenient in memory but wasteful on the wire: repeated keys, full-precision floats, and absolute coordinates dominate the payload. The complementary technique is to delta-encode — transmit Δlat\Delta\text{lat}, Δlon\Delta\text{lon}, Δt\Delta t as small fixed-point integers relative to the last sent value — and pack them into a fixed-length frame. Because successive fixes from a stationary or slow-moving asset differ in only the last few decimal places, the high bytes are zero and compress to almost nothing.

The packer below quantises coordinates to a 1e-7 degree grid (about 11 mm at the equator, finer than any consumer GNSS fix) and emits a 13-byte frame per delta. Pre-allocating the output bytearray and reusing it across flush cycles keeps the allocator quiet in the hot path.

import struct
from typing import List, Dict, Any

# Frame layout (little-endian): op(1) + id(4) + dlat(i32) + dlon(i32) = 13 bytes
_FRAME = struct.Struct("<B I i i")
_SCALE = 10_000_000  # 1e-7 deg fixed point ~= 11 mm

def pack_deltas(deltas: List[Dict[str, Any]], ref: Dict[str, tuple],
                out: bytearray) -> bytearray:
    """Delta-encode against per-id reference state into a reusable buffer.
    `ref` is mutated in place so the next batch encodes against the latest sent value."""
    out.clear()
    for d in deltas:
        pid = d["id"]
        base_lat, base_lon = ref.get(pid, (0.0, 0.0))
        dlat = round((d["lat"] - base_lat) * _SCALE)
        dlon = round((d["lon"] - base_lon) * _SCALE)
        op = 0 if d["op"] == "create" else 1
        # Hash string ids to a stable 32-bit slot; keep a separate id<->slot map on both ends.
        slot = hash(pid) & 0xFFFFFFFF
        out += _FRAME.pack(op, slot, dlat, dlon)
        ref[pid] = (d["lat"], d["lon"])
    return out

Thirteen bytes per update versus roughly 90 bytes for the equivalent JSON object is a ~7x reduction before any general-purpose compression runs. That makes binary packing the right place to hand off to the transport layer rather than trying to squeeze JSON. The packed stream is an ideal input for the compression strategies for geospatial payloads that apply Zstandard or Brotli at the frame-batch level; on metered LTE the combined effect routinely lands payloads 60–80% smaller than a naive feature dump. One caveat: fixed-point quantisation interacts with your coordinate reference system, so settle the grid resolution and datum question covered in coordinate reference systems at the edge before you freeze the wire format — a re-projection after deployment means re-flashing every node.

Async Pipeline and Queue Backpressure

Generated deltas must be routed through an asynchronous pipeline so the diff and pack stages never block the sensor polling loop. Implement a bounded queue with explicit backpressure: when the cellular link degrades or a satellite window closes, the queue spills to persistent storage with LRU eviction rather than dropping packets silently. This is where delta sync hands off to message queue management at the edge, whose job is to keep bursty telemetry from starving control-plane commands or watchdog timers.

import asyncio

class DeltaPipeline:
    def __init__(self, maxsize: int = 4096):
        # Bounded queue is the backpressure mechanism: a full queue is a signal,
        # not an error. put_nowait raises QueueFull, which we treat as "spill".
        self._q: asyncio.Queue = asyncio.Queue(maxsize=maxsize)

    async def submit(self, frame: bytes, spill) -> None:
        try:
            self._q.put_nowait(frame)
        except asyncio.QueueFull:
            await spill(frame)  # append to disk-backed WAL, evict LRU if needed

    async def drain(self, send) -> None:
        while True:
            frame = await self._q.get()
            try:
                await send(frame)        # transport with mTLS
            finally:
                self._q.task_done()

The put_nowait-then-spill shape is deliberate: it never awaits on a full queue, so a slow radio cannot apply backpressure all the way up into the polling loop and stall sensor reads. When send raises because the link is down, retransmission policy is not this layer’s concern — it belongs to retry and backoff for unstable networks, which wraps the transmit call in exponential backoff and a circuit breaker. Keeping the diff, the queue, and the retry policy as separate stages is what lets each one degrade independently under field conditions, an approach shared with the wider async execution for spatial workloads patterns.

Async delta pipeline with backpressure firewall The sensor poll loop feeds the delta tracker, then a binary packer, then a bounded asyncio.Queue, then transport over mTLS. The non-blocking submit uses put_nowait, which never awaits, so the queue forms a backpressure firewall: a full queue spills frames to a disk-backed write-ahead log with LRU eviction and replays them on recovery, while backpressure is never allowed to propagate upstream into the sensor poll loop. non-blocking zone · sensor reads never stall backpressure firewall · put_nowait() never awaits backpressure must not reach the poll loop submit QueueFull → spill replay Sensor poll ingest() Delta tracker diff vs cache Binary packer 13-byte frames asyncio.Queue bounded · maxsize Transport mTLS send() Disk-backed WAL spill · LRU evict

Configuration and Tuning

Delta sync exposes a handful of knobs whose correct values are entirely deployment-specific. Tune them against the constraint table, not against lab defaults.

  • Movement threshold (threshold_m). The noise floor of the sensor sets the lower bound. A bare consumer GNSS module drifts 1–3 m even when stationary; setting the threshold below that floods the buffer with phantom updates. Start at one standard deviation of the static-position scatter and raise it until idle traffic stops. Survey-grade RTK can justify a 0.05 m threshold; a livestock tracker rarely needs better than 5 m.
  • Buffer depth (buffer_size). Size it to flush_interval × peak_event_rate × safety_factor. Too small and bursts overwrite undrained deltas; too large and a stalled flush pins megabytes of RAM. The maxlen deque makes overflow a defined, observable event rather than an OOM kill.
  • Flush cadence. Fewer, larger flushes save power (the radio amortises its wake-up cost) but raise worst-case staleness. Drive the flush from time.monotonic() deltas, never wall-clock, so an NTP step or RTC correction cannot trigger a spurious flush or freeze the cadence.
  • Fixed-point scale (_SCALE). Match it to your precision standard — over-fine quantisation wastes the high bits of every frame, while too-coarse a grid silently snaps distinct features together. Anchor the choice to the spatial data precision standards for your survey class.

For the C/Rust hot-path build, compile the diff kernel with -O2 -fno-exceptions -ffast-math and pin the toolchain to the gateway’s -march (e.g. -march=armv8-a+crc on Cortex-A53) so the sqrt and cos lower to hardware instructions instead of libm calls. If deltas spill to SQLite, run the WAL in PRAGMA journal_mode=WAL with PRAGMA synchronous=NORMAL to bound fsync stalls, and cap growth with PRAGMA wal_autocheckpoint. On Linux, raise the modem’s socket send buffer via /proc/sys/net/core/wmem_max only as far as your RAM budget allows — an oversized kernel buffer just hides queue backpressure you need to see.

Verification and Field Diagnostics

A field technician must validate sync integrity without cloud access. Three checks cover the common failure surface:

  1. Sequence and checksum. Stamp each flushed batch with a monotonic sequence number and a lightweight checksum (CRC32 or SipHash) over the packed frames, and log both to /var/log/edge_sync.log. A gap in sequence numbers proves loss; a checksum mismatch on replay proves corruption. The two together localise a fault to either the link or the encoder.
  2. Syscall latency. Use strace -p <pid> -e trace=write,sendto -T on the gateway process to see per-call timing and spot a modem that is accepting bytes but stalling on sendto. Pair it with event-loop lag measured as the delta between successive loop.time() samples — a loop that drifts past its flush interval is starving on I/O, not CPU.
  3. Adversarial link. Before deployment, inject realistic degradation with tc qdisc add dev eth0 root netem loss 10% delay 50ms 20ms distribution normal and confirm the queue spills, recovers, and replays without duplicate or missing sequence numbers. A delta sync that is correct only on a clean link is not field-ready.

The companion walkthrough, implementing delta sync for GPS coordinate streams, carries a complete instrumented example through Kalman pre-filtering and epoch alignment for high-rate streams.

High-Frequency and DGPS Streams

High-frequency GNSS streams accumulate coordinate drift if every raw fix is diffed naively, because multipath noise crosses the movement threshold even when the asset is still. Add stateful windowing — a short moving-average or Kalman filter — ahead of the tracker so the delta engine sees a denoised position. For RTK/DGPS base stations the differential corrections must be delta-encoded on a separate channel from the position stream to preserve carrier-phase integrity; collapsing the two loses the very precision the corrections provide. Pack GNSS corrections in their native RTCM3 binary framing rather than re-serialising them into JSON, which both wastes bandwidth and risks bit-level corruption of the correction payload.

Failure Modes and Recovery

Delta sync has a small set of characteristic failures, each with a defined detection and recovery path:

  • State divergence. If a flush is lost and the next batch encodes against a reference the cloud never received, every subsequent delta is silently wrong. Detect it with the sequence-number gap check; recover by forcing a full-state reconciliation — re-send absolute coordinates for all live features — rather than continuing to patch deltas onto a phantom base.
  • Buffer overflow. Sustained event rate above flush throughput overruns the maxlen deque, dropping the oldest deltas. The deque makes this observable: compare ingested count against flushed count. Recovery is to widen the flush, raise the threshold, or trip into reconciliation if loss exceeds tolerance.
  • Clock drift. A wandering RTC corrupts the ts field and can mis-order deltas at the consumer. Anchor cadence to time.monotonic() and carry a per-batch wall-clock anchor only for the consumer to reconcile against.
  • Quantisation snap. Two distinct features rounding to the same fixed-point cell merge on the wire. Detect with an id-collision counter in the packer; recover by raising _SCALE resolution and re-baselining.

The safe default for any ambiguous state is the same: stop patching, send a full snapshot, and resume delta encoding from a reference both sides demonstrably share. A periodic forced full sync — say every N batches or after any detected gap — bounds how far the two halves can ever drift.