Building a Geofence Index for Fleet-Scale Point-in-Polygon Tests
Testing whether a fix is inside a geofence is trivial. Testing four million fixes against twelve
thousand geofences is forty-eight billion tests, and a naive loop over polygon.contains(point) will
still be running tomorrow.
The fix is the standard two-stage spatial query: a cheap bounding-box filter that eliminates almost everything, then an exact test on the handful that survive. Both stages have implementation details that matter at fleet scale, and this page covers them alongside the assertions that keep the index trustworthy as the fence set changes. It supports the matching work in location typing and POI matching for stops.
Compatibility and Configuration Requirements
| Requirement | Value | Notes |
|---|---|---|
shapely |
≥ 2.0 | STRtree with vectorised query, and prepared geometries |
| CRS | One projected CRS for fences and fixes | Mixing degrees and metres silently returns nothing |
| Fence validity | All polygons valid | An invalid polygon gives undefined containment results |
| Memory | ~200 bytes per fence vertex | A twelve-thousand-fence set is tens of megabytes |
| Rebuild cadence | Whenever the fence set changes | The tree is immutable once built |
The CRS requirement is the one that bites. A fence set in EPSG:4326 queried with projected fixes produces zero matches and no error, because the coordinate ranges do not overlap. Asserting that both sides carry the same CRS before building the tree costs one line.
The Index
from __future__ import annotations
import numpy as np
import shapely
from shapely import STRtree
from shapely.geometry import Polygon
class GeofenceIndex:
"""Two-stage point-in-polygon over a fixed set of fences.
Stage one is an R-tree over bounding boxes; stage two is an exact
containment test against prepared geometries. Both fences and query
points must be in the same projected CRS.
"""
def __init__(self, fences: list[Polygon], fence_ids: list[str], crs_epsg: int):
invalid = [i for i, g in enumerate(fences) if not g.is_valid]
if invalid:
raise ValueError(f"{len(invalid)} invalid fence geometries, e.g. index {invalid[0]}")
self.crs_epsg = crs_epsg
self.ids = np.asarray(fence_ids)
self.geoms = np.asarray(fences, dtype=object)
shapely.prepare(self.geoms) # cache edge structure for fast contains
self.tree = STRtree(self.geoms)
def query(self, xs: np.ndarray, ys: np.ndarray) -> list[np.ndarray]:
"""Return the fence ids containing each point, as one array per point."""
pts = shapely.points(xs, ys)
# Stage one: bounding-box candidates for every point at once.
pt_idx, fence_idx = self.tree.query(pts, predicate="intersects")
# Stage two: exact test, only on the surviving pairs.
hit = shapely.contains_xy(self.geoms[fence_idx], xs[pt_idx], ys[pt_idx])
pt_idx, fence_idx = pt_idx[hit], fence_idx[hit]
out = [np.empty(0, dtype=self.ids.dtype) for _ in range(len(xs))]
for p in np.unique(pt_idx):
out[p] = self.ids[fence_idx[pt_idx == p]]
return out
Three choices do the work. shapely.prepare caches each polygon’s edge structure so repeated
containment tests skip the setup cost. tree.query is called with the whole point array, not per
point, which keeps the traversal in compiled code. And the exact test runs only on the surviving
candidate pairs, which after the bounding-box stage is typically one or two per point rather than
twelve thousand.
Batching and Throughput
Calling query per point defeats the vectorisation. The tree traversal is compiled code, and the cost
of crossing from Python into it once per fix dominates everything else at fleet scale.
BATCH = 500_000
def label_fixes(index: GeofenceIndex, fixes) -> list[np.ndarray]:
out = []
for start in range(0, len(fixes), BATCH):
chunk = fixes[start : start + BATCH]
out.extend(index.query(chunk["x"].to_numpy(), chunk["y"].to_numpy()))
return out
Half a million points per call is a reasonable batch: large enough that the per-call overhead disappears, small enough that the intermediate candidate arrays stay comfortably in memory. The candidate arrays are the memory peak, and they are roughly the number of points times the average bounding-box overlap — a few million entries, not a few hundred million.
Where the fixes are already partitioned by vehicle-day, batching by partition rather than by a fixed count is simpler and gives similar throughput.
Nesting and Priority
Fleet geofences nest by design: a loading bay inside a yard inside a site, or a customer zone inside a city zone. A fix inside the bay is inside all three, and the index correctly returns all three.
Collapsing to one at test time is the mistake. The hierarchy is information — a dwell in the bay and a dwell in the yard mean different things operationally — and it cannot be recovered once discarded. Return every hit and apply a priority rule downstream, where the consumer’s needs are known.
def most_specific(hits: np.ndarray, area_by_id: dict[str, float]) -> str | None:
"""Smallest containing fence — the usual 'where is it really' rule."""
return min(hits, key=lambda fid: area_by_id[fid], default=None)
Smallest-area is the right default because a nested fence is almost always more specific than its parent. Where fences overlap without nesting — two adjacent customer zones with a shared access road — smallest-area is arbitrary, and an explicit priority column on the fence set is the only honest answer.
Store all the hits, not just the winner. A stop attributed to a bay is also in the site, and site-level reporting should not have to re-run the spatial test to find that out.
Keeping the Index Honest
The index is rebuilt whenever the fence set changes, and three assertions catch the failures that matter.
Every fence is valid. An invalid polygon — self-intersecting, usually from a hand-drawn boundary — gives undefined containment results rather than an error. The constructor above rejects them, which is the right place because a build-time failure is far cheaper than silently wrong labels.
The CRS matches on both sides. Assert it rather than trusting it. The symptom of a mismatch is zero matches everywhere, which reads as “no vehicles visited any site” and is often blamed on the fixes.
Hit rate is stable across rebuilds. Compare the share of fixes matching at least one fence against the previous index. A step change means fences were added, removed or moved, and knowing which is much easier at rebuild time than a week later.
def assert_hit_rate_stable(new_rate: float, previous_rate: float, tol: float = 0.02) -> None:
if abs(new_rate - previous_rate) > tol:
raise SystemExit(
f"geofence hit rate moved from {previous_rate:.3f} to {new_rate:.3f} — "
"review the fence set change before promoting this index"
)
Test that assertion by rebuilding with a fence deliberately removed; it fires immediately. A check nobody has seen fail is not evidence that the index is correct.
Common Pitfalls Specific to This Technique
Building the tree per batch. The R-tree construction is the expensive part and the fence set does not change between batches. Build once, query many times.
Testing in degrees. A bounding-box query in EPSG:4326 works, but any buffer or distance applied to a fence is then in degrees, and the resulting fence is a different size at each latitude.
Forgetting shapely.prepare on large polygons. Site boundaries traced from imagery routinely have
hundreds of vertices, and the unprepared exact test on those is several times slower.
Assuming one fence per fix. Nested fences are normal, and code that takes hits[0] gets whichever
one the tree happened to return first, which is not stable across rebuilds.
Related
- Location typing and POI matching for stops — parent topic, and why nearest-centroid is not enough
- Geofence-intersection dwell detection in Python — the dwell state machine this index feeds
- Matching GPS stops to commercial POI databases in Python — the other half of location typing
- Coordinate reference system mapping for fleet data — choosing the projected CRS both sides must share
- Stop Detection & Dwell Time Analytics — parent section