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:
- 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.
- 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.
- 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.
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.
Related
- On-Device Geometry Filtering — the two-stage reject model this tuning layer sits on top of.
- Implementing Polygon Containment Checks in C++ — the compiled predicate the prefilter protects from unnecessary calls.
- Bounding Process Pool Memory for Spatial Workers — the matching deadline-driven backoff applied to worker dispatch rather than the prefilter stage.