Point-in-Polygon Geofencing for Streaming Sensor Events with Shapely
Geofencing a sensor stream means answering one question for every event as fast as it arrives: which monitoring zone contains this coordinate? The naïve approach — testing each point against every zone polygon — is O(zones) per event and collapses once a network has a few hundred zones and thousands of sensors reporting per minute. The fix is a prebuilt spatial index: a shapely.STRtree constructed once over your zone polygons answers each query in expected O(log zones) time by discarding every polygon whose bounding box cannot contain the point. This page builds a self-contained, restartable geofencing class that handles the two things that break naïve implementations in production — boundary points and NaN coordinates — and slots directly into the spatial windowing and geofencing stage of the pipeline.
Why a Prebuilt Index Changes the Complexity
A point-in-polygon test is not cheap. Shapely’s exact predicate walks the polygon’s edges and runs a ray-crossing or winding-number computation whose cost scales with vertex count — and administrative or watershed boundaries routinely carry hundreds of vertices. Running that test against every zone for every event multiplies two large numbers together.
An STRtree (Sort-Tile-Recursive tree) attacks the problem in two phases. First it packs every zone’s bounding box into a balanced R-tree at construction time. Then, for a query point, it descends the tree and returns only the zones whose bounding box contains the point — typically one or two candidates instead of the whole registry. The exact, expensive geometry test then runs only on those few survivors. The bounding-box filter is what makes streaming geofencing tractable: it converts the per-event cost from “test every polygon” to “test the handful that could plausibly match.”
Two properties make the tree a natural fit for streams. It is immutable once built, so it is safe to share read-only across worker threads without locking. And because zone registries change on the scale of weeks, the tree’s construction cost is paid once at startup and amortized across millions of events — never per batch.
The bounding-box filter also explains why zone shape matters for throughput. A long, thin, diagonal boundary — a river-following watershed edge, say — has a bounding box that covers a large rectangle of empty space, so the tree returns it as a candidate for many points that ultimately fail the exact test. Compact, convex zones produce tight boxes and fewer false candidates. You cannot always change the geometry, but you can simplify dense boundaries and split pathologically elongated zones so the coarse filter stays selective. This is the geofencing analogue of choosing a good partition key: the index only helps if its coarse phase actually eliminates most of the work.
One subtlety that trips up first implementations: an STRtree gives you a fast candidate set, not a fast answer. The exact predicate still runs on every survivor, so a query point sitting inside three overlapping zones triggers three full geometry tests. That is fine — three is not three hundred — but it means the tie-break logic that picks a single winner is not an afterthought. It runs on the hot path for every ambiguous point, which is why the implementation below resolves overlaps with a cheap min over integer-indexed ids rather than a second round of geometry.
Production-Ready Implementation
The class below loads and repairs zone polygons, builds the tree once, guards each incoming coordinate, and resolves overlaps deterministically so the same coordinate always maps to the same zone across restarts.
# python 3.11 · shapely==2.0.4 · geopandas==0.14.3 · pyproj==3.6.1
from __future__ import annotations
import math
from shapely import STRtree, Point, make_valid
from shapely.geometry.base import BaseGeometry
class StreamGeofencer:
"""Assign streaming sensor events to monitoring zones via a prebuilt STRtree.
Build once at process start; call ``locate`` per event. The tree is
immutable and safe to share read-only across worker threads.
"""
def __init__(
self,
polygons: list[BaseGeometry],
zone_ids: list[str],
predicate: str = "covers",
) -> None:
if len(polygons) != len(zone_ids):
raise ValueError("polygons and zone_ids must be the same length")
repaired: list[BaseGeometry] = []
clean_ids: list[str] = []
for geom, zid in zip(polygons, zone_ids):
fixed = make_valid(geom) # repair self-intersections
if fixed.is_empty or not fixed.is_valid:
continue # drop unrecoverable geometry
repaired.append(fixed)
clean_ids.append(zid)
self._zone_ids = clean_ids
self._geoms = repaired
self._predicate = predicate
self._tree = STRtree(repaired) # O(n log n), built exactly once
@staticmethod
def _valid_coord(lat: float | None, lon: float | None) -> bool:
"""Reject None, NaN, Inf, out-of-range, and null-island coordinates."""
if lat is None or lon is None:
return False
if not (math.isfinite(lat) and math.isfinite(lon)):
return False
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
return False
return not (lat == 0.0 and lon == 0.0)
def locate(self, lat: float, lon: float) -> str | None:
"""Return the zone_id containing (lat, lon), or None.
On overlapping zones, returns the lexicographically smallest zone_id
so the mapping is deterministic across restarts. Invalid coordinates
return None without raising.
"""
if not self._valid_coord(lat, lon):
return None
pt = Point(lon, lat) # Shapely order is (x=lon, y=lat)
idx = self._tree.query(pt, predicate=self._predicate)
if len(idx) == 0:
return None
return min(self._zone_ids[i] for i in idx)
Usage is a single construction followed by a hot per-event call:
from shapely.geometry import Polygon
zones = [
Polygon([(-0.15, 51.50), (-0.05, 51.50), (-0.05, 51.55), (-0.15, 51.55)]),
Polygon([(-0.05, 51.50), ( 0.05, 51.50), ( 0.05, 51.55), (-0.05, 51.55)]),
]
fencer = StreamGeofencer(zones, zone_ids=["central", "east"])
# Boundary point shared by both squares -> deterministic 'central'
print(fencer.locate(lat=51.52, lon=-0.05)) # "central"
print(fencer.locate(lat=51.52, lon=-0.10)) # "central"
print(fencer.locate(lat=float("nan"), lon=-0.10)) # None
print(fencer.locate(lat=51.52, lon=12.34)) # None (outside all zones)
The query call returns integer positions into the geometry list, which is Shapely 2.0 behaviour; the min over the matched zone_ids gives a stable tie-break for the boundary case where a point sits on the shared edge of two squares.
Parameter Tuning Guide
The predicate and how you pre-process zones matter more than any numeric knob. Pick per sensor deployment:
| Sensor / zone type | Predicate | Zone pre-processing | Tie-break |
|---|---|---|---|
| Air quality by district | covers |
make_valid, dissolve slivers |
lowest zone_id |
| Water quality by watershed | covers |
simplify to ~5 m tolerance | lowest zone_id |
| Industrial fenceline exclusion | covers |
buffer 0 to close gaps | any match = alert |
| Overlapping regulatory + alert zones | intersects |
keep all layers | emit all matches |
| Point-source proximity (metres) | buffered covers |
reproject to metric CRS | nearest source |
Two tuning notes. Simplifying dense boundaries with shapely.simplify to a few metres of tolerance cuts vertex count — and therefore per-test cost — with negligible effect on which zone a sensor falls in. And for proximity checks measured in metres, do not buffer in degrees; reproject the point and source to a metric CRS with pyproj first, because one degree of longitude is a different distance at every latitude.
Verification and Testing
Geofencing bugs are silent — a swapped axis or an excluded boundary produces valid-looking output that is simply wrong. Test the two failure modes explicitly.
import math
import pytest
from shapely.geometry import Polygon
def make_fencer():
a = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
b = Polygon([(10, 0), (20, 0), (20, 10), (10, 10)])
return StreamGeofencer([a, b], zone_ids=["west", "east"])
def test_interior_point_matches_expected_zone():
f = make_fencer()
assert f.locate(lat=5, lon=5) == "west"
assert f.locate(lat=5, lon=15) == "east"
def test_shared_boundary_is_deterministic():
f = make_fencer()
# x=10 is the shared edge; covers matches both, tie-break -> 'east'? no: min('east','west')='east'
assert f.locate(lat=5, lon=10) == "east"
# repeated calls never flip
assert all(f.locate(5, 10) == "east" for _ in range(100))
def test_invalid_coordinates_return_none():
f = make_fencer()
assert f.locate(lat=math.nan, lon=5) is None
assert f.locate(lat=math.inf, lon=5) is None
assert f.locate(lat=0.0, lon=0.0) is None # null island
assert f.locate(lat=95.0, lon=5) is None # out of range
def test_point_outside_all_zones_returns_none():
f = make_fencer()
assert f.locate(lat=5, lon=99) is None
The boundary test is the important one: covers matches both squares along x=10, and min("east", "west") resolves to "east" every time. Assert the exact winner so a future change to the tie-break rule is caught rather than shrugged off.
Gotchas
Latitude/longitude axis swap. Point(lat, lon) type-checks, runs, and returns confidently wrong zones. Shapely is (x, y) = (lon, lat). Build Point(lon, lat) and keep the assertion at the call site — this is the most common geofencing defect and it never raises on its own.
contains drops boundary points. With contains, a sensor sitting on the exact edge between two zones matches neither and reports as unassigned. Use covers so boundary points are always claimed, then break ties deterministically.
Rebuilding the tree per batch. The tree’s whole value is amortized construction. Rebuild only when the zone registry version changes; a per-batch rebuild reintroduces the O(z log z) cost on every window and erases the benefit over a plain loop.
Invalid polygons fail silently. A self-intersecting zone makes covers return arbitrary results for that zone with no exception. Run make_valid at load — as the constructor above does — and drop any geometry still invalid afterward instead of trusting it.
GPS jitter flips a stationary sensor between adjacent zones. A fixed sensor whose reported position wobbles a few metres around a shared boundary will match central one minute and east the next, producing phantom zone transitions that pollute per-zone counts. For sensors at known fixed locations, snap the event to the registered coordinate before the geofence query rather than trusting each transmitted fix. Reserve live coordinates for genuinely mobile platforms — drifting buoys, vehicle-mounted air monitors, wildlife trackers.
Metric buffers built in degrees are wrong everywhere but the equator. A “within 500 m of this outfall” check implemented by buffering the source point by 0.0045 degrees assumes a fixed degrees-to-metres ratio that only holds near the equator. At 55° latitude a degree of longitude is barely half its equatorial length, so the buffer becomes an east-west-squashed ellipse. Reproject both the point and the source to a local metric CRS with pyproj before any distance-based geofence.
Related
- Spatial Windowing and Geofencing in Sensor Streams — parent overview covering zone assignment, hex binning, and combined spatial-temporal windows
- H3 Hexagonal Binning for Real-Time Sensor Density Maps — the density-grid counterpart to zone geofencing, used together to enrich each event
- Windowed Aggregation for Time-Series — the time-window stage that consumes zone-tagged events to produce per-zone rollups