Bounding-Box Prefilter Tuning for Telemetry Streams

Within the Local Spatial Processing Patterns guide, this page is about a knob the parent guide on on-device geometry filtering assumes is already set correctly: how wide to draw the axis-aligned bounding box that rejects points before the expensive containment test ever runs. Getting the envelope test itself right is easy; getting its size right against a live telemetry stream is where most deployments either leak false negatives at the boundary or waste CPU letting too many points through to the precise stage. This page treats the prefilter as something to measure and tune continuously against field conditions, not a fixed constant chosen once in a lab.

The telemetry streams in question are the same as elsewhere in this guide: GPS fixes, asset pings, and sensor-derived coordinates arriving at anywhere from a few points per second to short 50–100 Hz bursts, on a gateway that cannot afford to run a full polygon test against every one of them.

Envelope tuning selection rationale

An axis-aligned bounding-box test is four floating-point comparisons — cheap enough to run against every incoming point regardless of stream rate. The reason it exists at all is that the containment test behind it is not: a ray-casting or GEOS-backed predicate costs an order of magnitude more per call, scales with the polygon’s vertex count, and is the resource the rest of the pipeline (workers, FFI contexts, async dispatch) is built to protect. The prefilter’s entire job is to keep the ratio of points reaching that expensive stage as low as possible without ever discarding a point that the precise test would have accepted.

Three variables determine whether a given envelope setting achieves that:

  1. Epsilon expansion for GPS drift. A raw bounding box drawn tight around a polygon’s vertices will reject points that are truly inside the zone but whose GNSS-reported position has drifted a few meters past the true boundary on a given fix. Expanding the box by a drift epsilon before comparison converts a hard boundary into a tolerance band — the same idea used inside the containment predicate itself, applied one stage earlier so a drifted-but-valid fix is not thrown away before it ever reaches the test that would have caught it.
  2. Hit-rate measurement, not assumption. The value of the prefilter is entirely empirical: what fraction of incoming points does it actually reject? A well-tuned filter on typical field telemetry rejects 85–95% of points before the containment test runs; a filter rejecting under 50% is providing little protection and its epsilon or its underlying polygon set needs review. This number has to be measured against live or replayed field data, not assumed from a synthetic test with uniformly random points.
  3. Ordering and batch sizing. The AABB check must run first, always, and cheaply enough that running it against every point in a batch is never itself a bottleneck. Batch size interacts with this: a larger batch amortizes function-call and memory-access overhead for the AABB pass, but holds more points in flight if the downstream containment stage is momentarily behind, so the two constants are tuned together, not independently.
Instrumented prefilter stage between telemetry ingestion and containment A telemetry batch arrives and each point is tested against a bounding box expanded by a drift epsilon. Survivors advance to the containment predicate while rejects are counted by a hit-rate monitor, which feeds back to adjust the epsilon and batch size for the next cycle. Telemetry batch N points Expand by ε drift tolerance AABB test 4 compares/point Containment test survivors only Hit-rate monitor tunes ε + batch

The hit-rate monitor closes the loop: it observes the reject ratio and adjusts epsilon and batch size for the next cycle rather than leaving both fixed forever.

The complete implementation

The instrumented prefilter below wraps a compiled AABB-plus-containment predicate (the C++ implementation from the parent guide’s companion page) with the measurement and adaptive tuning logic that a fixed constant cannot provide on its own:

# prefilter_tuning.py — instrumented bounding-box prefilter with adaptive
# epsilon and batch sizing, driving a compiled containment predicate via ctypes.

import ctypes
import time
from collections import deque
from dataclasses import dataclass

# Loaded once at startup; exposes the AABB-plus-ray-casting predicate compiled
# from geofence_containment.hpp with -O2 -fno-exceptions -fno-rtti.
_geofence = ctypes.CDLL("./libgeofence.so")
_geofence.point_in_polygon.restype = ctypes.c_bool

@dataclass
class PrefilterStats:
    window: deque  # rolling window of (tested, rejected_by_aabb) tuples

    def record(self, tested: int, rejected: int):
        self.window.append((tested, rejected))

    def hit_rate(self) -> float:
        """Fraction of points rejected by the AABB alone over the rolling
        window. Target band is 0.85-0.95; outside it, epsilon or the
        registered polygon set needs review, not just the batch size."""
        tested = sum(t for t, _ in self.window)
        rejected = sum(r for _, r in self.window)
        return rejected / tested if tested else 0.0


def aabb_reject(lat: float, lon: float, box, epsilon: float) -> bool:
    """Four comparisons, expanded by epsilon on every edge. Returns True if
    the point can be rejected without ever calling the containment predicate."""
    return (lat < box.min_lat - epsilon or lat > box.max_lat + epsilon or
            lon < box.min_lon - epsilon or lon > box.max_lon + epsilon)


def process_batch(points: list[tuple[float, float]], box, polygon,
                   epsilon: float, stats: PrefilterStats) -> list[bool]:
    """Runs the AABB reject over the whole batch first, then the compiled
    containment predicate only on survivors. Records hit-rate for tuning."""
    results = [False] * len(points)
    survivors = []
    rejected_count = 0
    for i, (lat, lon) in enumerate(points):
        if aabb_reject(lat, lon, box, epsilon):
            rejected_count += 1
            continue
        survivors.append(i)
    for i in survivors:
        lat, lon = points[i]
        results[i] = _geofence.point_in_polygon(
            ctypes.c_double(lat), ctypes.c_double(lon), polygon, box,
            ctypes.c_double(epsilon))
    stats.record(len(points), rejected_count)
    return results


def tune_epsilon(stats: PrefilterStats, epsilon: float,
                  observed_drift_p95_m: float) -> float:
    """Adjust epsilon from a measured GPS drift percentile rather than a
    guess. 1 degree of latitude is ~111,320 m; converting the field-measured
    p95 drift to degrees keeps epsilon anchored to reality, not intuition."""
    required_epsilon = observed_drift_p95_m / 111_320.0
    # Never shrink faster than 50% per adjustment; avoid oscillation.
    return max(required_epsilon, epsilon * 0.5)


def tune_batch_size(current_size: int, hit_rate: float,
                     cycle_time_s: float, deadline_s: float) -> int:
    """Grow the batch while there is deadline headroom and the hit rate is
    healthy; shrink it the moment a cycle approaches the deadline, mirroring
    the thermal-driven chunk-size backoff used for worker dispatch."""
    if cycle_time_s > deadline_s * 0.8:
        return max(16, current_size // 2)
    if hit_rate >= 0.85 and cycle_time_s < deadline_s * 0.4:
        return min(current_size * 2, 2048)
    return current_size

tune_epsilon deliberately anchors its adjustment to a measured drift percentile rather than letting the epsilon creep upward from noise: a p95 figure captures the tail of GNSS behavior without letting one wild multipath outlier permanently widen the box for every subsequent fix. tune_batch_size mirrors the deadline-driven backoff described for worker dispatch in the parent guide’s sibling pattern — a prefilter that spends too long per cycle is exactly as dangerous to a watchdog-governed loop as a worker that overruns its timeout.

Constraint validation table

Constraint Expected impact Mitigation built into the code
RAM A prefilter with no rolling-window cap on its stats buffer grows unbounded over a long-running process PrefilterStats.window is a deque; bound it with maxlen sized to the tuning cadence, not left open-ended
CPU An undersized epsilon lets fewer points through and rejects more up front, but an oversized one drives the hit rate down and pushes load onto the expensive containment stage tune_epsilon grows the box only as far as measured drift requires, keeping the reject ratio near the 85–95% target band
Latency A batch sized without regard to cycle time can push per-batch processing time past the caller’s deadline under thermal throttle tune_batch_size halves the batch the moment cycle time approaches 80% of the deadline, before a miss actually occurs
Power Running the AABB test with unnecessarily high floating-point precision, or against an unnecessarily large registered polygon set, wastes cycles per point on a passively cooled SoC The AABB stage stays four comparisons regardless of polygon complexity; only survivors pay for the vertex-count-dependent containment cost

Gotchas and edge cases

  • A hit rate near 100% is a red flag, not a success. It usually means the epsilon has been drawn far wider than actual drift requires, or the registered polygon covers a much larger area than the zones of interest, both of which quietly waste the prefilter’s purpose by pushing very little filtering benefit relative to its own overhead.
  • Epsilon tuned from a single test run under-covers real drift. Multipath and atmospheric conditions vary by time of day, weather, and terrain; measure the drift percentile from a rolling field sample spanning at least a full day-night cycle before trusting a fixed epsilon in production.
  • Batch growth without a deadline check causes exactly the watchdog risk it is meant to avoid. Doubling the batch size on a healthy hit rate alone, without checking cycle time against the deadline, can silently grow a batch until a single thermal-throttled cycle overruns — always gate growth on measured cycle time, never on hit rate alone.
  • The prefilter and the containment predicate must agree on epsilon semantics. If the AABB expansion and the containment predicate’s own vertex-tolerance epsilon (from the polygon containment implementation) diverge, a point can pass the prefilter but fail the precise test for reasons that look like a bug rather than a deliberate two-stage tolerance difference — keep both epsilons derived from the same drift measurement.
  • Coordinate wrap and pole cases still apply to the bounding box itself. A zone whose envelope crosses the antimeridian needs a box represented as two disjoint ranges, not a single min/max pair that silently wraps incorrectly; the same caution documented for coordinate handling elsewhere in this guide applies here without modification.

Integrating with the telemetry loop

The instrumented prefilter sits ahead of the containment predicate in the ingestion path, reporting its hit rate and cycle time on the same cadence the loop already uses for other field diagnostics:

async def telemetry_cycle(batch: list[tuple[float, float]], box, polygon,
                           state: dict):
    start = time.monotonic()
    results = process_batch(batch, box, polygon, state["epsilon"], state["stats"])
    elapsed = time.monotonic() - start

    state["epsilon"] = tune_epsilon(state["stats"], state["epsilon"],
                                     state["observed_drift_p95_m"])
    state["batch_size"] = tune_batch_size(state["batch_size"],
                                          state["stats"].hit_rate(),
                                          elapsed, state["deadline_s"])
    return results

Feeding observed_drift_p95_m requires an independent drift estimate — typically computed upstream from repeated fixes at a known stationary reference point during commissioning, then refreshed periodically from field telemetry. Log state["epsilon"], state["batch_size"], and the rolling hit rate together on every adjustment; a drifting epsilon with a flat hit rate points at genuinely changing GNSS conditions, while a flat epsilon with a falling hit rate points at zone geometry that no longer matches the deployed environment.