Spatial Indexing on Constrained Devices

Within the Local Spatial Processing Patterns guide, this page covers the structure that decides whether a gateway can answer “which zones contain this point?” in microseconds or in milliseconds — the spatial index. Everything else in this section assumes one exists: the funnel in on-device geometry filtering needs a candidate set to filter, and the joins in spatial joins in constrained environments need somewhere to look features up. This page is about building and holding that index on hardware that cannot afford the desktop answer.

The desktop answer is a dynamic R-tree with insert, delete and rebalance, held in a language runtime’s heap. On a gateway that structure has three problems: its memory footprint is several times the data it indexes, its pointer chasing defeats a small cache, and rebalancing produces unpredictable pauses in a pipeline that has a deadline. The edge answer is almost always a static index — built once, laid out contiguously, and replaced wholesale rather than mutated.

Four index structures against the query shapes they serve A decision map over four structures. A uniform grid suits evenly distributed features and answers point queries in constant time with no comparisons. A packed Hilbert R-tree suits skewed or elongated features and answers both point and window queries in logarithmic time from a contiguous array. A cell-identifier scheme such as geohash or H3 suits key-value stores and distributed joins, trading exactness at boundaries for a plain string key. A linear scan remains correct and is the right answer below roughly two hundred features, where any index costs more than it saves. Pick from the data's shape and the query's shape, not from the algorithm's reputation uniform grid O(1) point lookup even distribution, small area wastes memory on empty cells packed Hilbert R-tree O(log n) point and window skewed or elongated features contiguous, no pointers cell ids · geohash, H3 O(1) into any key-value store joins across processes must probe neighbours linear scan O(n), zero build cost under ~200 features cache-friendly, always correct The two questions that settle it: are the features evenly spread, and does the query ask about a point or a window? Even + point → grid. Skewed → R-tree. Cross-process join → cell ids. Under two hundred features → scan, and revisit at a thousand. All four are static on a gateway: built at load, replaced wholesale, never mutated in place while a query is running.
Three of these are indexes and one is the absence of one — which on a device with forty zones is frequently the fastest option and always the simplest to get right.

Constraint mapping

Constraint Edge reality Direct effect on index design
RAM ceiling 256 MB – 2 GB, index competing with the pipeline Rules out node-per-object structures; favours flat arrays where the index is a few bytes per feature
Cache size 32–64 KB L1, 512 KB–2 MB L2 on Cortex-A Pointer chasing dominates runtime; contiguous layouts win by more than their asymptotic complexity suggests
Build time Index built at boot or after a sync A rebuild that takes 30 s delays first fix after every restart; build cost is a latency budget, not a one-off
Peak during rebuild Old and new index both resident Doubles the index’s memory cost at exactly the wrong moment — see the dedicated guide below
Flash Read-mostly, wear-limited An index that can be memory-mapped from a prebuilt file avoids both the build cost and the rebuild peak
Determinism Real-time telemetry deadline No rebalancing, no amortised operations, no allocation in the query path

The row that surprises people is cache size. On a Cortex-A53 with a 512 KB L2, a pointer-based R-tree over 40 000 features misses cache on nearly every node traversal, and each miss costs 80–150 cycles. The same tree packed into a contiguous array traverses several levels within a handful of cache lines. The measured difference is routinely three to five times, which is larger than the difference between the algorithms themselves.

Implementation: a static grid built once at load

For a zone set that fits a bounded area with reasonably even coverage, nothing beats a flat grid. The build is a single pass, the query is arithmetic, and the whole structure is two arrays.

# static_grid.py — build-once spatial grid over feature envelopes.
# No allocation in the query path; the two arrays are sized at build time.
# Threading: build on one thread, then treat as immutable and share freely.
import array
import math


class StaticGrid:
    __slots__ = ("min_x", "min_y", "cell", "nx", "ny", "starts", "items")

    def __init__(self, envelopes, cell_size):
        """envelopes: sequence of (min_x, min_y, max_x, max_y) in projected metres."""
        self.cell = float(cell_size)
        self.min_x = min(e[0] for e in envelopes)
        self.min_y = min(e[1] for e in envelopes)
        max_x = max(e[2] for e in envelopes)
        max_y = max(e[3] for e in envelopes)
        self.nx = max(1, int((max_x - self.min_x) / self.cell) + 1)
        self.ny = max(1, int((max_y - self.min_y) / self.cell) + 1)

        # Pass 1: count occupants per cell so the CSR arrays can be sized exactly.
        counts = array.array("i", [0]) * 0
        counts = array.array("i", bytes(4 * (self.nx * self.ny + 1)))
        spans = []
        for feature_id, (x0, y0, x1, y1) in enumerate(envelopes):
            cx0, cy0 = self._cell_of(x0, y0)
            cx1, cy1 = self._cell_of(x1, y1)
            spans.append((feature_id, cx0, cy0, cx1, cy1))
            for cy in range(cy0, cy1 + 1):
                base = cy * self.nx
                for cx in range(cx0, cx1 + 1):
                    counts[base + cx + 1] += 1

        # Prefix sum → start offsets (compressed sparse row layout).
        for i in range(1, len(counts)):
            counts[i] += counts[i - 1]
        self.starts = counts
        self.items = array.array("i", bytes(4 * counts[-1]))

        # Pass 2: place feature ids. cursor walks a copy of the start offsets.
        cursor = array.array("i", counts)
        for feature_id, cx0, cy0, cx1, cy1 in spans:
            for cy in range(cy0, cy1 + 1):
                base = cy * self.nx
                for cx in range(cx0, cx1 + 1):
                    slot = base + cx
                    self.items[cursor[slot]] = feature_id
                    cursor[slot] += 1

    def _cell_of(self, x, y):
        cx = min(self.nx - 1, max(0, int((x - self.min_x) / self.cell)))
        cy = min(self.ny - 1, max(0, int((y - self.min_y) / self.cell)))
        return cx, cy

    def query_point(self, x, y):
        """Candidate feature ids whose envelope covers this cell. No allocation
        beyond the returned memoryview slice."""
        cx, cy = self._cell_of(x, y)
        slot = cy * self.nx + cx
        return self.items[self.starts[slot]:self.starts[slot + 1]]

    def memory_bytes(self):
        return (len(self.starts) + len(self.items)) * 4

The layout is compressed sparse row: one integer per cell holding the start offset, and one integer per (feature, cell) pair. For 40 zones over a 20 km area at 500 m cells that is 1 600 cells and roughly 260 occupancy entries — under 8 KB, entirely cache-resident, with a query that is two multiplications and a slice. The structure has no Python objects in the hot path at all, which is what keeps the collector out of it.

Implementation: when the grid stops fitting

Grids fail on skew. A zone set that includes both a 50 m loading bay and a 200 km pipeline corridor cannot be served by a single cell size: fine cells make the corridor occupy thousands of them, coarse cells make the bay share with everything else. Two escapes exist.

The first is a two-level grid: a coarse grid whose dense cells hold a nested fine grid. This preserves the arithmetic query and handles moderate skew, at the cost of a branch per lookup and a build that has to decide which cells to subdivide.

The second is a packed Hilbert R-tree, which adapts to the data’s own distribution instead of imposing a uniform one. It is built by sorting features along a space-filling curve and packing them into fixed-size nodes bottom-up, producing a contiguous array with no pointers — a structure that suits flash and cache equally well and can be memory-mapped straight from a file. The construction and the query loop are covered in packed Hilbert R-tree in a static buffer.

Where a uniform grid breaks down on a skewed zone set A working area containing one large pipeline corridor and a cluster of small yard zones. With 500 metre cells the corridor's envelope spans 480 cells while the yard zones share a single cell, so a query in the yard returns eleven candidates and a query anywhere near the corridor returns one. With 50 metre cells the yard separates cleanly but the corridor spans 48 000 cells and the index grows from 8 kilobytes to 780. A packed R-tree indexes both at 14 kilobytes because its node extents follow the data. One cell size cannot serve a corridor and a yard 500 m cells · 8 KB · yard unresolved 50 m cells · 780 KB · corridor spans 48 000 packed R-tree · 14 KB · extents follow the data
The middle panel is the trap: it looks like the careful choice, and it turns an eight-kilobyte index into most of a megabyte to resolve three small zones.

Where the index lives: heap, file, or both

An index has to exist somewhere, and on a gateway the choice between the heap and a memory-mapped file changes far more than it appears to. It decides how long a restart takes, whether the index survives a watchdog reset, and whether the kernel is allowed to evict it under pressure.

Holding the index on the heap is the default and the least flexible option. It costs a build at every start-up, it is destroyed by every restart, and it is unevictable — the kernel cannot reclaim it under memory pressure, so it is the pages that force everything else out. Its advantage is simplicity and the fastest possible query, since nothing can fault.

Building the index at provisioning and shipping it as a file the device memory-maps inverts every one of those properties. Start-up costs one open and one mmap regardless of index size. A watchdog reset does not rebuild anything. The pages are file-backed and clean, so under pressure the kernel evicts them and re-faults them later instead of killing the process, which converts a hard failure into a soft slowdown. The cost is that the first query after a restart faults pages in from flash, and that the index cannot reflect a reference layer the device received itself.

The hybrid that most mature deployments arrive at is a mapped base index for the layer shipped at provisioning, plus a small heap-resident overlay for features synced since. The query consults both and merges the candidate sets, which costs one extra traversal against a structure that is usually two orders of magnitude smaller than the base. Periodically — at a maintenance window, or when the overlay exceeds a size threshold — the two are merged into a new base file and the overlay is emptied.

That structure has a property worth naming: the expensive operation, rebuilding the base, happens on a schedule the device chooses, while the cheap operation, extending the overlay, happens whenever data arrives. Nothing in the ingestion path ever waits for a full rebuild, and the memory spike covered in the rebuild guide occurs at a moment when the device is idle rather than at the moment a sync lands.

Base index on flash, overlay on the heap, merged at a maintenance window The base index is a memory-mapped file built at provisioning, holding 40 000 features and costing no start-up time. An overlay index on the heap holds the 340 features synced since, costing 6 kilobytes. A query traverses both and unions the candidates, adding about 2 microseconds. At a maintenance window the two are merged into a new base file, written and renamed atomically, and the overlay is emptied. A note records that a restart at any point loses only the overlay, which is rebuilt from the spool in under a second. The expensive rebuild happens when the device chooses, not when data arrives base · mmap from flash 40 000 features · 845 KB zero start-up cost, evictable overlay · heap 340 features since sync · 6 KB rebuilt from the spool on restart query both union the candidates +2 µs merge at maintenance write, fsync, rename overlay emptied new base mapped in place A restart at any moment loses only the overlay, which the spool can rebuild in under a second.
Two indexes are less work than one, because the expensive half stops being rebuilt for data that did not change it.

Configuration and tuning

  • Cell size on a grid: start at the median feature diagonal, then measure candidates returned per probe. The band that works is usually one to four times the median feature size; the full trade is charted in the spatial joins guide.
  • Node size on a packed R-tree: 16 entries is the usual sweet spot on ARM, because a 16-entry node of four floats each is 256 bytes — four cache lines — and the branching factor keeps the tree shallow.
  • Build trigger: rebuild on reference-layer change, never on a timer. An index rebuilt every hour against unchanged data is pure cost and a recurring memory spike.
  • Persistence: prefer building the index at provisioning and shipping it as a file the device memory-maps. It removes the build cost from every boot and makes the rebuild peak someone else’s problem.
  • Fallback: keep the linear scan implementation and a switch to it. When an index build fails on a malformed layer, degrading to a slower correct answer beats failing to answer at all.

The case for keeping the linear scan

Every index in this section replaces a loop over the features with something cleverer, and every deployment should keep that loop. Not as a comment about what the index replaced — as running, tested, switchable code.

Three situations make it the correct choice rather than a fallback. Below roughly two hundred features a scan over a contiguous array of envelopes beats every index on this page, because the whole array fits in L1 and the branch predictor learns it; the index’s traversal costs more than the comparisons it avoids. During an index rebuild, a scan lets queries continue against the new layer immediately rather than waiting for a build to finish. And when an index build fails — a malformed layer, a memory budget refused — a scan is the difference between a device that answers slowly and one that does not answer.

The implementation is short enough that keeping it costs nothing:

def scan_candidates(envelopes, x, y, out):
    """Linear envelope test. `envelopes` is a flat array of 4 floats each;
    `out` is reused across calls so the hot path allocates nothing."""
    out.clear()
    for i in range(0, len(envelopes), 4):
        if (envelopes[i] <= x <= envelopes[i + 2]
                and envelopes[i + 1] <= y <= envelopes[i + 3]):
            out.append(i >> 2)
    return out

What makes it valuable is that it is also the reference implementation. Any index in this section can be validated against it directly: run both over the same query set, and assert the candidate sets agree. That test finds coordinate-ordering mistakes, off-by-one errors in cell arithmetic and traversal bugs, all of which otherwise present as intermittently missing results rather than as failures.

Wire the switch as configuration rather than as a code path chosen at build time, and export which mode is active in the health snapshot. A device that has quietly fallen back to scanning because its index build failed is a device with a latency problem and a working answer, and the operator needs to know which of those they are looking at.

Verification and field diagnostics

Instrument three numbers and the index stops being opaque. Candidates per probe is the index’s actual selectivity — if it drifts upward after a layer update, the new features are shaped differently than the cell size assumes. Build time and build peak tell you whether a restart will meet its deadline and whether the rebuild will fit in memory. Query time percentiles catch the case where a well-behaved median hides a tail caused by one enormous cell.

A useful field command dumps the occupancy histogram: number of cells holding zero, one, two to four, five to sixteen, and more than sixteen features. A healthy grid is dominated by zero and one; a long tail past sixteen means the cell size no longer matches the data, and it means it before any latency alert fires.

Failure modes specific to this pattern

Failure mode How it presents Detection Safe recovery
Cell size mismatched to updated layer Query latency creeps up; CPU rises with no traffic change Candidates-per-probe metric, occupancy histogram Rebuild with a recomputed cell size derived from the new median feature
Rebuild peak exceeds free memory OOM kill during a sync window, always at the same point Memory watermark around the rebuild Build incrementally or from a memory-mapped file — see the rebuild guide
Index queried during replacement Intermittent empty candidate sets, no errors Impossible to detect after the fact; prevent it Atomic pointer swap; never mutate a live index
Coordinates outside the index extent Silent misses near the boundary Extent guard on every probe Clamp and log, or rebuild the extent to include the new area
Skewed layer defeats the grid One cell holds most features; p99 latency explodes Occupancy histogram tail Switch to the packed R-tree; the grid cannot be tuned out of it

A short checklist before shipping an index

Five questions, asked once, prevent most of the failures catalogued above.

Have you measured candidates per probe against real traffic? Not against a synthetic uniform sample — against a recorded hour from a device in the field. Real traffic concentrates, and an index tuned against uniform queries over-returns for the corridor the fleet actually drives.

Do you know the build peak, and is it inside the budget on the smallest device in the fleet? The rebuild is the largest allocation the process makes, and the smallest device is the one that will meet it first.

Can the index be replaced while queries run? If the answer involves a lock held across the rebuild, queries stall for the duration; if it involves mutating in place, some query will see a half-built structure.

What happens if the build fails? A device with no index and no fallback answers nothing. A device that silently keeps a stale index answers wrongly. Only the fallback-to-scan path degrades honestly.

Is the active mode visible from the console? Grid or tree, base-plus-overlay or single, indexed or scanning — a field diagnosis that has to infer this from latency is a diagnosis that takes a day instead of a minute.

None of the five requires new code beyond what this section already describes. They require deciding the answers before the deployment rather than during the incident.