Configuring spatial thresholds for sensor event triggers

This page solves one concrete problem: turning a high-frequency GNSS coordinate stream into clean ENTER/EXIT events against radial boundaries, entirely on a fanless ARM gateway running embedded Linux, in pure-standard-library Python 3 — no PostGIS, no Shapely, no cloud round trip. Within the Local Spatial Processing Patterns practice, and specifically as a concrete build of threshold-based event mapping, this is the stage that decides — per sample, in well under a millisecond — whether a tracked asset has crossed a geofence and an event payload should be queued for backhaul. Deploying this logic at the network edge eliminates cloud-side evaluation latency, spares metered LTE backhaul, and keeps the geofence verdict alive through the hours-long uplink outages that field nodes routinely face.

The deployment context is unforgiving. The engine shares a single core with a cellular modem and a serial poller, runs against a 4–8 MB application heap, and ingests 10–100 Hz of raw fixes carrying 2–5 m of GNSS drift. A naive distance-vs-radius comparison thrashes wildly whenever a sensor lingers on a boundary, flooding the bandwidth and async sync layer with duplicate alerts. Everything below is built to survive that environment: bounded memory, no per-sample allocation in the steady state, and drift tolerance baked in rather than bolted on.

Why a stateful hysteresis filter fits the constraint envelope

Spatial threshold evaluation at the gateway is best modelled as a stateful event filter, not a continuous spatial join. Each telemetry packet carries latitude, longitude, and a monotonic timestamp; the gateway holds a small set of active boundaries — circular here, though the same state machine drives the polygonal case handled by on-device geometry filtering. The job is to emit an event when a boundary is crossed, suppress redundant emissions while the sensor sits near that boundary, and queue only state changes for downstream routing.

The dominant field failure is false-positive triggering caused by coordinate jitter exceeding the threshold margin. Receivers operating under canopy or near reflective surfaces routinely show 2–5 m RMS drift, which makes a single-radius test oscillate rapidly as the measured distance crosses back and forth over the boundary. The fix is a dual-threshold (Schmitt-trigger) model: a tight activation radius r_act to fire ENTER, and a wider deactivation radius r_deact = r_act + hysteresis to fire EXIT. As long as the hysteresis band comfortably exceeds the receiver’s drift envelope, the state cannot thrash. This is why the pattern is preferred over a debounce-only timer: hysteresis is a property of the geometry, so it holds regardless of sample rate.

Distance itself uses the haversine great-circle formula, which stays accurate at the small radii typical of asset and zone monitoring without pulling in a projection library. For activation distance d against earth radius R:

d=2Rarcsin ⁣sin2 ⁣(Δφ2)+cosφ1cosφ2sin2 ⁣(Δλ2)d = 2R\,\arcsin\!\sqrt{\sin^2\!\left(\tfrac{\Delta\varphi}{2}\right) + \cos\varphi_1\cos\varphi_2\,\sin^2\!\left(\tfrac{\Delta\lambda}{2}\right)}

Coordinates are clamped to valid WGS84 ranges before any trig runs, following the spatial data precision standards used across the gateway, so a malformed fix can never propagate a NaN into the comparison. Any projection work — should you swap haversine for a local ENU plane inside a confined zone — is precomputed per coordinate reference systems at the edge, never recomputed per sample.

Dual-threshold hysteresis prevents state thrashing near a boundary.

Dual-threshold (Schmitt-trigger) hysteresis for radial geofences On the left, a circular zone has two radii: a tight inner activation radius r_act and a wider outer deactivation radius r_deact, with the annular gap between them forming the hysteresis band. A scatter of jittery GNSS samples sits inside this band; because the band is wider than the drift envelope, the state does not flip. On the right, a two-state machine: from an initial point the system starts OUTSIDE; it transitions to INSIDE only when distance falls to or below r_act, firing ENTER, and returns to OUTSIDE only when distance exceeds r_deact, firing EXIT. Geometry of the threshold band zone centre r_act (ENTER) r_deact (EXIT) GNSS drift lives in the band — distance wobbles, state holds Resulting state machine init OUTSIDE INSIDE dist ≤ r_act → ENTER dist > r_deact → EXIT r_deact = r_act + hysteresis

Self-contained threshold engine

The module below is a complete, dependency-free spatial threshold evaluator. It is single-threaded by design and intended to run inside one asyncio event loop — the ingestion coroutine awaits the modem/serial source, then calls process_telemetry() synchronously because the evaluation is pure CPU and finishes in microseconds, so it never needs its own task or a lock. Memory is bounded by a fixed-length deque; in the steady state (no crossing) the hot path allocates nothing and creates no garbage, so the CPython cyclic garbage collector never wakes mid-burst. A dict is built only on an actual ENTER/EXIT, which is rare by construction.

import math
import logging
from collections import deque
from typing import Dict, List, Optional, Callable

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

_MAX_HISTORY_LEN = 128          # ~1.5 KB of coordinate ring buffer
_EARTH_RADIUS_M = 6_371_000.0


class SpatialThresholdEngine:
    """Memory-aware radial threshold evaluator for ARM-based IoT gateways.

    Single-threaded; drive it from one asyncio loop. Implements dual-threshold
    hysteresis, drift clamping, and per-threshold debounce. No external GIS deps.
    """

    def __init__(self, max_thresholds: int = 64, cooldown_s: float = 30.0):
        self.thresholds: Dict[str, dict] = {}
        self.history: deque = deque(maxlen=_MAX_HISTORY_LEN)  # bounded => no leak
        self.event_callbacks: List[Callable] = []
        self._max_thresholds = max_thresholds
        self._cooldown_s = cooldown_s

    def add_threshold(self, threshold_id: str, lat: float, lon: float,
                      activation_radius_m: float, hysteresis_m: float) -> bool:
        if len(self.thresholds) >= self._max_thresholds:
            logging.warning("Threshold capacity reached; rejecting %s", threshold_id)
            return False
        # r_deact MUST exceed r_act for monotonic state transitions.
        self.thresholds[threshold_id] = {
            "lat": lat, "lon": lon,
            "r_act": activation_radius_m,
            "r_deact": activation_radius_m + hysteresis_m,
            "state": False,            # False = OUTSIDE, True = INSIDE
            "last_trigger_ts": 0.0,
        }
        return True

    @staticmethod
    def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
        """Great-circle distance in metres using only the stdlib math module."""
        lat1, lon1, lat2, lon2 = map(math.radians, (lat1, lon1, lat2, lon2))
        dlat, dlon = lat2 - lat1, lon2 - lon1
        a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
        return _EARTH_RADIUS_M * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))

    def process_telemetry(self, lat: float, lon: float, ts: float) -> List[dict]:
        """Evaluate one fix against every active threshold.

        Returns a list of crossing events (possibly empty). Pure CPU; safe to
        call directly from the ingestion coroutine without offloading.
        """
        # Clamp out-of-range / NaN-adjacent values before any trig runs.
        lat = max(-90.0, min(90.0, lat))
        lon = max(-180.0, min(180.0, lon))
        self.history.append((ts, lat, lon))

        events: List[dict] = []
        for tid, cfg in self.thresholds.items():
            dist = self._haversine_m(lat, lon, cfg["lat"], cfg["lon"])

            # Schmitt-trigger transition: enter tight, leave wide.
            if not cfg["state"] and dist <= cfg["r_act"]:
                crossing = "ENTER"
                cfg["state"] = True
            elif cfg["state"] and dist > cfg["r_deact"]:
                crossing = "EXIT"
                cfg["state"] = False
            else:
                continue  # no transition for this threshold

            # Debounce: keep the state change, but suppress a repeat *emission*
            # inside the cooldown window so backhaul never sees duplicate alerts.
            if (ts - cfg["last_trigger_ts"]) < self._cooldown_s:
                continue
            cfg["last_trigger_ts"] = ts
            events.append({
                "type": crossing,
                "threshold_id": tid,
                "distance_m": round(dist, 2),
                "timestamp": ts,
                "coords": (lat, lon),
            })

        for ev in events:                      # fan out only on real crossings
            for cb in self.event_callbacks:
                cb(ev)
        return events

    def register_callback(self, func: Callable) -> None:
        self.event_callbacks.append(func)

Constraint validation

Each constraint that defines this device class — drawn from the broader device constraints and resource limits profile — maps to a specific mitigation already present in the code above.

Constraint Expected impact Mitigation built into the code
RAM (4–8 MB app heap) Per-sample object churn fragments the heap and risks OOM over multi-day runs deque(maxlen=128) caps history at ~1.5 KB; steady-state path allocates nothing — a dict is built only on a crossing
CPU (single core, shared with modem/poller) Floating-point geometry saturates the core during 50–100 Hz bursts One _haversine_m call per active threshold; transition test is a pair of comparisons; no projection recompute per sample
Latency (< 15 ms ingest cycle) Blocking work in the eval loop stalls ingestion and drops fixes process_telemetry() is pure CPU and synchronous (~0.8 ms/threshold on Cortex-A53); no I/O, no locks
Power (battery / solar, duty-cycled) Waking the radio on every sample drains the budget Events emit only on state change; the cooldown window suppresses lingering chatter so the modem stays asleep
Connectivity (hours-long outages) A live-uplink assumption loses events during a drop The engine never transmits; callbacks hand events to a durable local buffer that drains when the link returns

Gotchas and edge cases

Drift tolerance is a config value, not a constant. Size the hysteresis band to the receiver, not a textbook number. For standard L1 receivers, hysteresis_m ≈ 1.5 × expected_drift (15–25 m in urban canyons) keeps the EXIT radius clear of the jitter envelope. Sub-5 m activation radii are only honest with RTK/PPK hardware — civilian single-frequency GPS lands at 3–10 m under open sky per the GPS.gov accuracy specifications, so a 3 m geofence on an L1 receiver is noise, not a boundary.

Pre-filter garbage fixes before the engine sees them. A 2D fix without adequate satellite lock can jump tens of metres in one sample and punch straight through the hysteresis band. Discard telemetry with HDOP > 2.5 or sat_count < 6 upstream; the threshold engine assumes its input is a believable position.

Coordinate-system assumptions. Inputs are WGS84 decimal degrees. Haversine is great-circle distance on a sphere — fine to well under a metre at these radii, but if you swap in a planar approximation, do it on a per-zone projected frame, never on raw lon/lat differences near the poles or the antimeridian.

Timestamp monotonicity. The cooldown math assumes ts never goes backwards. On gateways that take wall-clock time from an NTP step or the GNSS receiver itself, a clock correction can make ts - last_trigger_ts negative and briefly disable debounce. Feed process_telemetry() a monotonic source (time.monotonic()), and carry wall-clock time only inside the payload.

State survives restarts only if you persist it. cfg["state"] lives in RAM. A power cycle re-initialises every threshold to OUTSIDE, which will re-fire ENTER for any asset already inside a zone. Persist the last known states to non-volatile storage (e.g. /var/lib/gateway/threshold_state.json) and reload on boot if duplicate ENTERs on reboot are unacceptable. Validate hot-swapped configurations against a schema and run a dry-run pass that logs distances without mutating cfg["state"] before going live.

A noisy distance signal through the hysteresis band and dwell timer Distance from a zone boundary over four minutes, with a jittering signal that crosses the boundary repeatedly. The enter threshold sits below the boundary and the exit threshold above it, forming a band. The signal's first excursion into the band does not fire because it retreats before the 30 second dwell timer expires. The second excursion crosses the enter threshold and holds, so an entry event fires 30 seconds later. Later chatter inside the band produces no further events because the state is already inside. Two parameters, four minutes, one event exit +25 mboundaryenter −25 m enters the band, retreats at 18 s — dwell never completes ENTRY fires — 30 s held below the enter threshold chatter inside the band changes nothing: the state is already "inside" 02 min4 min
The band suppresses noise; the timer suppresses brief but genuine excursions. Both are needed because a receiver produces one and a loading bay produces the other.

Calling it from the event-mapping pipeline

Wire the engine into the ingestion coroutine and route its output to the sync layer. The callback is where a crossing becomes a queued payload — typically published with a delivery guarantee per MQTT QoS levels for telemetry drops, or folded into a delta sync for GPS coordinate streams when events ride alongside position updates.

import asyncio
import time

engine = SpatialThresholdEngine(max_thresholds=32, cooldown_s=30.0)
engine.add_threshold("bonded-zone-A", 51.5074, -0.1278,
                     activation_radius_m=25.0, hysteresis_m=20.0)

async def ingest(gnss_source, outbound_queue):
    """Pull fixes, evaluate, and hand crossings to the durable sync buffer."""
    engine.register_callback(lambda ev: outbound_queue.put_nowait(ev))
    async for fix in gnss_source:                 # awaits modem / serial I/O
        # Pure-CPU eval: synchronous, sub-millisecond, no offload needed.
        engine.process_telemetry(fix.lat, fix.lon, time.monotonic())
        await asyncio.sleep(0)                     # yield to keep the loop fair

A full bench harness lives one level up in threshold-based event mapping; the outbound_queue is drained by the sync layer, which applies its own retry policy when the link is flaky.

Event-mapping data flow from GNSS fix to backhaul Raw fixes flow left to right along the top row: the GNSS source feeds the ingest coroutine, which calls process_telemetry on the SpatialThresholdEngine. That engine box is annotated as pure CPU at about 0.8 milliseconds per threshold and holds the hysteresis state. On a real crossing the engine fans out through a callback, which drops down to the bottom row. The bottom row flows right to left: the callback enqueues the event into the outbound queue, annotated as a durable buffer that survives outages, and the sync layer then drains the queue to the modem for backhaul when the uplink returns. Crossing pipeline: fix in, event out GNSS source modem · serial, 10–100 Hz ingest() coroutine awaits I/O, one asyncio loop process_telemetry() hysteresis state · pure CPU ~0.8 ms / threshold, no offload callback fires only on a crossing outbound_queue durable buffer survives hours-long outages sync layer → modem drains + retries on uplink