On-Device Geometry Filtering
On-device geometry filtering evaluates incoming coordinate streams against spatial boundaries directly on the gateway, discarding out-of-interest points before they ever reach persistence or the cellular uplink. Within the Local Spatial Processing Patterns family, it is the first computational tier: the gatekeeper that turns raw NMEA/GPS tuples, LiDAR returns, and telemetry payloads into a thin, relevant stream. Transmitting every fix to centralized cloud infrastructure introduces unacceptable latency, burns metered bandwidth, and makes the whole site dependent on a backhaul link that disappears for hours. Pushing the spatial decision to the network edge removes all three problems at once.
The target hardware shapes every choice here. On ARM Cortex-A/M SoCs, thermal envelopes cap sustained clock speed, RAM ceilings sit well below 256 MB, and the CPU shares cycles with a modem and a sensor bus. A filtering pipeline that works on a workstation will stall the event loop, fragment the heap, and get OOM-killed in the field. The patterns below are built for deterministic execution, zero-allocation streaming, and non-blocking I/O instead.
Decision and Data Flow
Edge filtering cannot lean on a monolithic GIS framework. The production shape is a two-stage evaluation model that minimizes CPU cycles per coordinate: a cheap rejection that throws away the overwhelming majority of points, followed by a precise test that runs only on the survivors.
- Bounding-box pre-filter (O(1)) — fast float comparisons against
minx, miny, maxx, maxy. Discards 90% or more of out-of-bounds points without ever instantiating a geometry object. - Precise topological check (O(n)) — ray-casting or winding-number evaluation, executed only on bbox candidates. This is what prevents CPU saturation during high-frequency telemetry bursts (50–100 Hz GNSS polling, denser still for point clouds).
Two-stage containment: a cheap bounding-box reject before the precise point-in-polygon test.
Synchronous blocking calls will stall the main reactor and create MQTT/CoAP queue backpressure. Filter logic must run in isolated threads or async tasks, yielding control back after each batch. Allocation must be strictly bounded with circular buffers or pre-allocated arrays so that intermittent connectivity — and the burst of buffered traffic that follows a reconnect — cannot fragment the heap or trip the OOM killer.
Constraint Mapping
Each hardware limit maps to a concrete decision in the filter. Sizing the pipeline against the real budget of the device — not a generous assumption — is covered in depth under device constraints and resource limits; the table below is the subset that bears directly on geometry filtering.
| Constraint | Typical edge ceiling | Effect on the filter | Mitigation |
|---|---|---|---|
| RAM | 128–256 MB shared | No room for a full GeoDataFrame or in-memory tile cache | Generator parsing, bounded deque, prepared geometry loaded once at boot |
| CPU / thermal | 1–4 cores, throttles under sustained load | Per-point topology checks saturate the core during bursts | bbox reject first; escalate the hot path to compiled code |
| GIL (CPython) | Single bytecode lane | Precise checks serialize against async I/O above ~200 Hz | Run compiled containment in a thread-pool executor |
| UART / GNSS buffer | 1–4 KB ring | Dropped sentences if parsing blocks | Non-blocking read, fixed-size batches, task_done() per batch |
| Coordinate precision | float32 vs float64 paths | Drift and rounding near boundary edges cause false hits | Integer microdegrees, agreed CRS, edge tolerance band |
Coordinate handling deserves its own note: a point only means something relative to a declared reference system, so the boundary and the incoming fixes must share one. Pin the CRS and the on-the-wire encoding using the rules in coordinate reference systems at the edge, and where you can tolerate it, quantize to the fixed grid described in spatial data precision standards. Integer microdegrees turn the bbox comparison into pure integer math and sidestep the float-equality traps that haunt boundary edges.
Implementation: Two-Stage Containment in Python
Python gateways need generator-driven parsing so they never load a full geometry table into memory. The implementation below integrates with asyncio queues, applies bbox rejection inline, and delegates the precise check to a prepared geometry. The boundary is compiled once at boot; nothing in the hot loop allocates beyond the bounded deque. Refer to the official asyncio Queue documentation for the backpressure semantics this relies on.
import asyncio
from collections import deque
from shapely.geometry import Point
from shapely.wkt import loads as load_wkt
from shapely.prepared import prep
# Boundary loaded once at boot; prepared object caches the spatial index
# for fast, repeated containment queries. Lives for the process lifetime,
# so it never enters the GC's per-cycle scan of the hot path.
AOI_WKT = "POLYGON ((-122.419 37.774, -122.405 37.774, -122.405 37.785, -122.419 37.785, -122.419 37.774))"
AOI_BOUNDARY = prep(load_wkt(AOI_WKT))
AOI_MINX, AOI_MINY, AOI_MAXX, AOI_MAXY = AOI_BOUNDARY.context.bounds
async def filter_telemetry_stream(input_queue: asyncio.Queue,
output_queue: asyncio.Queue,
buffer_size: int = 64) -> None:
"""Non-blocking geometry filter with a bounded memory footprint.
Runs on the asyncio event loop. The precise contains() call is pure
Python here; under sustained load escalate it to a thread-pool
executor (see "FFI Escalation") so it cannot block the reactor.
"""
valid_buffer = deque(maxlen=buffer_size) # fixed capacity, no heap growth
while True:
batch = await input_queue.get() # pull a batch, not per-item
if batch is None: # sentinel for graceful shutdown
break
for lat, lon, ts, payload in batch:
# Stage 1: bbox rejection — pure float math, no object created.
if not (AOI_MINX <= lon <= AOI_MAXX and AOI_MINY <= lat <= AOI_MAXY):
continue
# Stage 2: precise containment — only reached by bbox survivors.
if AOI_BOUNDARY.contains(Point(lon, lat)):
record = (lat, lon, ts, payload)
valid_buffer.append(record)
await output_queue.put(record)
input_queue.task_done()
The shapely.prepared object caches an internal spatial index, cutting repeated topology cost by roughly 40% on constrained hardware. For the exact API surface and its memory behaviour, consult the Shapely documentation. Two details matter for edge correctness: build Point(lon, lat) only after the bbox passes (object creation is the dominant cost in the loop), and keep the output a plain tuple so the downstream stage owns serialization rather than the filter.
Implementation: Radial Pre-Reject and FFI Escalation
Not every region of interest is a polygon. A radial geofence — “within R metres of this asset” — is cheaper to evaluate and pairs well with the bbox stage as a second coarse reject before any polygon work. Compute great-circle distance with the haversine relation, where φ is latitude, λ is longitude, and r is the Earth radius:
For a tight operational zone you can skip the trig entirely and compare squared planar distance in scaled integer microdegrees against a precomputed radius, falling back to haversine only near the rim. That keeps the common case branch-free.
At sustained ingestion above ~200 Hz, CPython’s GIL and per-object allocation become the bottleneck and pure-Python contains() can no longer keep the reactor fed. Escalate the precise stage to compiled code via cffi or ctypes: build the boundary logic as a shared object with a strict C ABI exposing a synchronous point_in_polygon over raw float arrays. Run it inside a thread-pool executor so it releases the GIL and overlaps with async I/O, and pass contiguous array.array or NumPy buffers so no copy happens at the boundary. A minimal ray-casting kernel, header-only and exception-free, looks like this:
/* geofilter.c — crossing-number point-in-polygon, edge-safe hot path.
Build: cc -O2 -fno-exceptions -fPIC -shared -o libgeofilter.so geofilter.c
No allocation, no errno, no libm calls; safe to call under the GIL release. */
int point_in_polygon(const float *vx, const float *vy, int n,
float px, float py) {
int inside = 0;
for (int i = 0, j = n - 1; i < n; j = i++) {
/* Does the horizontal ray at py cross edge (j -> i)? */
if (((vy[i] > py) != (vy[j] > py)) &&
(px < (vx[j] - vx[i]) * (py - vy[i]) / (vy[j] - vy[i]) + vx[i])) {
inside = !inside;
}
}
return inside; /* 0 = outside, 1 = inside */
}
The compile flags, memory-alignment strategy, and winding-number variants for self-intersecting boundaries are worked through in implementing polygon containment checks in C++. Wiring the executor handoff so it never serializes against the loop is the broader subject of async execution for spatial workloads.
Configuration and Tuning
The same code performs very differently depending on a handful of knobs. Tune these against the real device, not the lab.
- Batch size — the
batchgranularity above amortizes async overhead. 32–128 points per batch is the usual sweet spot; larger batches raise tail latency, smaller ones spend more time in await machinery than in filtering. - Buffer capacity — set the
deque(maxlen=…)and the upstreamasyncio.Queue(maxsize=…)from the measured peak burst, not the average. A bounded queue is what gives you backpressure instead of an unbounded heap. - Compile flags —
-O2 -fno-exceptions -fPICfor the shared object; add-mfpu=neon(or-march=…) on Cortex-A to let the compiler vectorize the edge loop. Avoid-ffast-mathnear boundary edges — it perturbs the crossing comparison. - Process isolation — run the filter as a dedicated
systemdservice withMemoryMax=200MandCPUQuota=60%so a runaway burst degrades this unit alone rather than the modem manager beside it. - Prepared-geometry refresh — when the boundary is dynamic, swap the prepared object atomically (build the new one, then rebind the module global) so the hot loop never sees a half-built index.
- Edge tolerance — define an explicit boundary band (a few microdegrees) so jitter near the rim resolves deterministically instead of flickering in and out.
Verification and Field Diagnostics
Field deployments never match the lab telemetry profile. GPS multipath, coordinate drift, and cellular handoffs deliver malformed and delayed packets, so the filter has to be observable on a device you cannot attach a debugger to.
- Structured telemetry logging — emit JSON lines carrying
lat,lon,bbox_hit,topo_result, andprocessing_ms. Pipe tojournaldor a local ring buffer rather than the SD card to avoid flash wear. - Queue-depth monitoring — sample
input_queue.qsize()andoutput_queue.qsize(). A steadily climbing input depth means the precise stage is the bottleneck and it is time to escalate to FFI; a climbing output depth means the consumer downstream is the limit. The queue itself is governed by the patterns in message queue management at the edge. - Rate verification — log accepted-vs-rejected counts per minute. A bbox accept rate near 100% means the box is too loose (or the device drifted outside the zone); a precise-stage accept rate near zero on bbox hits points at a CRS or winding-order mismatch in the boundary.
- Live profiling —
py-spy dump --pid <pid>orstrace -p <pid>during a field visit will surface an unexpected blocking syscall or a hot Python frame faster than any log will.
Failure Modes and Recovery
This pattern degrades in a small number of predictable ways. Detect each one cheaply and keep a safe fallback.
- Queue backpressure under burst — the precise stage falls behind a reconnect flush and input depth climbs toward
maxsize. Detect on the depth threshold; recover by dropping the sample rate (for example to 1 Hz) until depth falls below ~50%, shedding load deterministically rather than buffering into an OOM. - Boundary-edge flicker — raw GNSS jitter near the rim produces alternating accept/reject. Apply a light moving-average or Kalman smoother before the bbox stage and lean on the edge-tolerance band; never act on a single straddling fix.
- CRS / winding mismatch — a boundary authored in the wrong reference system or vertex order silently rejects everything (or accepts everything). The rate-verification counters above are the canary; recover by reloading a validated boundary and re-confirming bounds.
- Thermal throttle — sustained precise checks heat the SoC, the governor drops the clock, and latency doubles. Watch
processing_ms; shed to bbox-only screening (mark survivors “unconfirmed” for later re-evaluation) until the die cools. - Heap fragmentation / OOM — usually a bounded buffer that was sized for the average instead of the peak. The
MemoryMaxguard turns an unbounded crash into a restart; the real fix is amaxlen/maxsizederived from the measured worst case.
Once geometries clear the filter, the next stage usually turns them into actions — radial alerts, dwell events, corridor crossings — which is the job of threshold-based event mapping. High-density inputs add one wrinkle: LiDAR returns and stereo-depth maps arrive as dense arrays, so tile the cloud into fixed-size voxel blocks, reject any block whose AABB misses the geofence box, and only then run per-point tests on the survivors. When confirmed points must be cross-referenced against a local asset registry, avoid a full table scan and use the L2-cache-friendly grid and R-tree approximations from spatial joins in constrained environments.
Related
- Delta sync for spatial datasets — shipping only the filtered, changed geometries home instead of the full stream.
- Retry and backoff for unstable networks — how the filtered stream survives the cellular handoffs that distort field telemetry.
- Fallback routing and offline navigation — keeping spatial decisions running while the backhaul link is gone.
- Core edge GIS fundamentals — the coordinate systems and resource budgets every filter is sized against.