Routing Telemetry by Geofence Membership
Query an STRtree for candidate fences, run the exact predicate only on those, and emit entries and exits rather than a membership set — a router that publishes membership on every ping multiplies volume by the ping rate instead of the crossing rate, and makes a vehicle parked on a boundary indistinguishable from one driving through it.
This guide sits under Sensor Data Routing Patterns, within Core Event Fundamentals & Architecture. It assumes positions have already been normalised to EPSG:4326 as described in CRS Normalization Strategies.
When to use this pattern
- Consumers subscribe to areas rather than to assets — a depot operator wants everything in their yard, not a named list of vehicles.
- The set of geofences is large enough that testing every ping against every fence is measurable, which starts at a few hundred fences.
- What matters downstream is the crossing, not the dwell: arrival notifications, zone-based billing, compliance boundaries.
Two indexes, and only one of them is exact
Complete runnable implementation
from dataclasses import dataclass, field
from shapely import STRtree, prepare, contains_xy
from shapely.geometry import shape
# Metres of hysteresis at the boundary. Must exceed measured GPS jitter, or a
# vehicle parked on a fence line produces an unbounded stream of crossings.
ENTER_BUFFER_M = 15.0
EXIT_BUFFER_M = 25.0
_M_PER_DEGREE = 111_320.0 # at the equator; fine for a jitter-scale buffer
@dataclass(slots=True)
class Geofence:
fence_id: str
consumers: tuple[str, ...]
geometry: object # shapely polygon, EPSG:4326
enter_zone: object = None # eroded — must be inside THIS to enter
exit_zone: object = None # dilated — must leave THIS to exit
class GeofenceRouter:
def __init__(self, fences: list[Geofence]) -> None:
for fence in fences:
fence.enter_zone = fence.geometry.buffer(-ENTER_BUFFER_M / _M_PER_DEGREE)
fence.exit_zone = fence.geometry.buffer(EXIT_BUFFER_M / _M_PER_DEGREE)
# Prepare once. Without this, every contains() call rebuilds the
# edge structure of a boundary that may have 10 000 vertices.
prepare(fence.enter_zone)
prepare(fence.exit_zone)
self._fences = fences
# Index the DILATED zones: anything that could still be inside must be
# a candidate, and the exit zone is the largest of the two.
self._tree = STRtree([f.exit_zone for f in fences])
self._membership: dict[str, set[str]] = {}
def route(self, asset_id: str, lon: float, lat: float) -> list[dict]:
"""Return entry/exit transitions for this ping. Usually empty."""
was_in = self._membership.get(asset_id, set())
now_in: set[str] = set()
for idx in self._tree.query(_point(lon, lat)):
fence = self._fences[idx]
inside_exit = contains_xy(fence.exit_zone, lon, lat)
inside_enter = contains_xy(fence.enter_zone, lon, lat)
# Hysteresis: entering needs the inner zone, staying only needs
# the outer one. That asymmetry is the whole anti-flap mechanism.
if fence.fence_id in was_in:
if inside_exit:
now_in.add(fence.fence_id)
elif inside_enter:
now_in.add(fence.fence_id)
self._membership[asset_id] = now_in
transitions = []
for fence_id in now_in - was_in:
transitions.append(self._event(asset_id, fence_id, "enter", lon, lat))
for fence_id in was_in - now_in:
transitions.append(self._event(asset_id, fence_id, "exit", lon, lat))
return transitions
def _event(self, asset_id: str, fence_id: str, kind: str,
lon: float, lat: float) -> dict:
fence = next(f for f in self._fences if f.fence_id == fence_id)
return {
"schema_version": 2,
"action": f"geofence_{kind}",
"asset_id": asset_id,
"fence_id": fence_id,
"consumers": list(fence.consumers),
"crs": "EPSG:4326",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
}
def _point(lon: float, lat: float):
from shapely.geometry import Point
return Point(lon, lat)
The membership dictionary is what turns a stateless predicate into a transition detector, and it is also the thing that makes this router stateful — which has consequences for how it is deployed, covered below.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
ENTER_BUFFER_M |
float |
Must exceed measured GPS jitter (typically 5–10 m urban) | 15.0 |
EXIT_BUFFER_M |
float |
Must exceed ENTER_BUFFER_M, or the dead band is inverted |
25.0 |
prepare() |
call | Once per fence at build time; re-preparing per ping defeats it | — |
| Tree contents | geometry list | The dilated zones, since they are the largest candidate set | — |
_membership |
dict[str, set] |
One entry per active asset; must be evicted or it grows forever | — |
| Buffer CRS | — | Degrees here; use a projected CRS where metre accuracy matters | EPSG:4326 |
Gotchas and spatial edge cases
-
Buffering in degrees is only approximately metres, and the error grows with latitude. A 15 m buffer expressed as degrees is 15 m north–south everywhere, but east–west it is 15 m at the equator and about 7.5 m at 60° north. For a jitter dead band that is acceptable; for a fence whose exact extent is contractual, buffer in a local projected CRS and transform back.
-
A negative buffer can erase a small fence entirely.
geometry.buffer(-15/111320)on a fence twenty metres across returns an empty polygon, and every ping then fails the entry test — the fence silently stops matching anything. Check for emptiness at build time and fall back to the unbuffered geometry with a logged warning. -
The membership dictionary makes the router stateful, so it cannot be scaled by adding replicas. Two instances each see half the pings and each has half the picture, so both emit spurious entries. Partition by asset identifier so every ping for one asset reaches the same instance, exactly as Spatial Partitioning Strategies describes for consumers generally.
-
Membership state has to be evicted. An asset that stops reporting keeps its entry forever, so a fleet with churn leaks memory in proportion to lifetime asset count. Expire entries after a multiple of the reporting interval, and treat expiry as an exit or not — deliberately, because both choices are defensible and only one of them notifies the depot that the vehicle left.
-
Overlapping fences are normal and must all fire. Administrative boundaries nest — a city inside a region inside a country — so one ping legitimately produces three entries. Do not stop at the first match, which is a tempting optimisation that quietly breaks nested subscriptions.
-
A restart replays the fleet’s entire membership as entries. With empty state, the first ping from every asset inside a fence looks like an arrival. Either persist membership, or mark the first transition after startup with a flag consumers can ignore, as Idempotent Consumers for Out-of-Order Spatial Events discusses.
Verification
import pytest
from shapely.geometry import Polygon
YARD = Polygon([(13.400, 52.520), (13.400, 52.524),
(13.406, 52.524), (13.406, 52.520)])
@pytest.fixture
def router():
return GeofenceRouter([Geofence("yard-1", ("depot-ops",), YARD)])
def test_entry_then_silence(router):
"""One entry, then nothing while the asset stays inside."""
assert [e["action"] for e in router.route("veh-1", 13.403, 52.522)] == \
["geofence_enter"]
assert router.route("veh-1", 13.4031, 52.5221) == []
def test_parked_on_the_boundary_emits_nothing(router):
"""The flapping case — jitter across the fence line, inside the band."""
router.route("veh-2", 13.4030, 52.5220) # establish: inside
events = []
for offset in (0.0000, 0.0001, -0.0001, 0.0001, -0.0001):
events += router.route("veh-2", 13.4060 + offset, 52.5220)
assert events == [], f"boundary jitter produced {len(events)} transitions"
def test_nested_fences_all_fire():
"""A city inside a region must produce two entries, not one."""
region = Polygon([(13.30, 52.45), (13.30, 52.60), (13.55, 52.60), (13.55, 52.45)])
r = GeofenceRouter([Geofence("yard-1", ("depot",), YARD),
Geofence("region-1", ("planning",), region)])
assert len(r.route("veh-3", 13.403, 52.522)) == 2
The middle test only fails when the hysteresis is removed, which is why it is worth writing with explicit coordinates rather than a helper: someone tuning the buffers needs to see the geometry that makes the assertion true.
Related
- Sensor Data Routing Patterns — the topic this guide belongs to
- Handling Out-of-Order Pings from Intermittent Devices — what a late ping does to the membership state above
- Spatial Partitioning Strategies — why this router has to be partitioned by asset rather than by area
- Time-Windowed Deduplication for Moving Assets — collapsing the ping volume before it reaches the router