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.

  1. 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.
  2. 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.

Two-stage containment filter A telemetry point enters a cheap Stage 1 bounding-box test that runs in constant time on raw floats. Points outside the box are discarded zero-copy. Survivors reach a Stage 2 point-in-polygon ray-cast that runs in linear time; points outside the polygon are also discarded, while points inside are emitted to the output queue. The two reject paths converge on a single discard node. Telemetry point STAGE 1 · O(1) Inside bounding box? float compare, no alloc STAGE 2 · O(n) Point-in-polygon? ray-cast survivors only Emit to queue Discard (zero-copy) yes yes no no

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:

a=sin2 ⁣(Δφ2)+cosφ1cosφ2sin2 ⁣(Δλ2),d=2ratan2 ⁣(a,1a)a = \sin^2\!\left(\tfrac{\Delta\varphi}{2}\right) + \cos\varphi_1\,\cos\varphi_2\,\sin^2\!\left(\tfrac{\Delta\lambda}{2}\right), \qquad d = 2r\,\operatorname{atan2}\!\left(\sqrt{a},\,\sqrt{1-a}\right)

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 batch granularity 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 upstream asyncio.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 -fPIC for the shared object; add -mfpu=neon (or -march=…) on Cortex-A to let the compiler vectorize the edge loop. Avoid -ffast-math near boundary edges — it perturbs the crossing comparison.
  • Process isolation — run the filter as a dedicated systemd service with MemoryMax=200M and CPUQuota=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, and processing_ms. Pipe to journald or a local ring buffer rather than the SD card to avoid flash wear.
  • Queue-depth monitoring — sample input_queue.qsize() and output_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 profilingpy-spy dump --pid <pid> or strace -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 MemoryMax guard turns an unbounded crash into a restart; the real fix is a maxlen/maxsize derived 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.