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

Cheap filter, exact refine Four thousand geofences are indexed in an STRtree keyed on bounding boxes. A ping arrives and the tree is queried, which examines a logarithmic number of nodes and returns three candidate fences whose bounding boxes contain the point. Only those three run the exact point-in-polygon predicate, and of them one actually contains the point: the other two are bounding-box overlaps, which are common for administrative boundaries because a long diagonal coastline has an enormous rectangle containing mostly sea. Without the tree, all four thousand exact predicates run per ping, and each one on a complex boundary walks thousands of edges. The two-stage structure is the standard filter-and-refine pattern and the reason it matters here is the shape of real geofences — bounding boxes are a terrible approximation of administrative geometry, so the filter stage must be followed by an exact test rather than trusted on its own. one ping against 4 000 geofences: filter on bounding boxes, then refine exactly one ping lat/lon, EPSG:4326 4 000 fences in the system STRtree query bounding boxes only → 3 candidates prepared contains() exact, on 3 polygons → 1 real member route to that fence's consumers why the filter alone is not enough a diagonal coastline's bounding box is mostly sea what each stage costs per ping tree query: a logarithmic number of node visits exact test: 3 prepared predicates, not 4 000 unprepared, each of those 3 walks the ring again
Figure 1. Bounding boxes are a poor approximation of administrative geometry, so the filter stage narrows the work but can never be trusted on its own.

Complete runnable implementation

python
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.

One threshold flaps; two do not A vehicle is parked directly on a geofence boundary and reports every ten seconds with about five metres of GPS jitter. With a single threshold — the fence line itself — successive readings fall alternately inside and outside, so the router emits an entry, an exit, an entry and an exit indefinitely, and each of those events is indistinguishable downstream from a real crossing: a billing rule charges for every entry, a notification rule wakes somebody up each time, and an arrival dashboard shows the vehicle arriving forty times an hour. With two thresholds, entry requires the position to be fifteen metres inside the fence and exit requires it to be twenty-five metres outside, so the jittering readings between those lines change nothing at all. A vehicle genuinely driving through crosses both bands within one or two pings, so the cost of the hysteresis is a delay of a single reporting interval, which is negligible against a crossing anyone cares about. vehicle parked on the boundary · ±5 m GPS jitter · 10 s reporting one threshold — the fence line fence line enter · exit · enter · exit · enter · exit · enter · exit — 40 "arrivals" an hour, each one billable two thresholds — enter 15 m inside, exit 25 m outside dead band every reading is in the dead band · zero events · state unchanged A vehicle genuinely driving through crosses both bands within a ping or two, so the hysteresis costs one reporting interval.
Figure 2. The two events on the top line are not noise a consumer can filter — each is byte-identical to a real crossing, so the fix has to be in the router.

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

Two replicas, two half-pictures, and both emit spurious crossings The geofence router holds per-asset membership in memory, which makes it stateful. Deployed as two replicas behind a round-robin distribution, a single vehicle's pings alternate between them: replica one sees pings one, three and five, replica two sees pings two, four and six. Each replica therefore has a membership set built from half the vehicle's history. When the vehicle sits inside a fence, replica one records an entry on its first ping; replica two, having never seen that entry, records its own entry on the next ping — so the depot receives two arrivals for one vehicle. Worse, when the vehicle leaves, whichever replica happens not to see the departing ping keeps the vehicle marked as inside indefinitely. Partitioning by asset identifier instead sends every ping for one vehicle to the same replica, so each asset's membership is complete in exactly one place; the replicas then scale by asset count rather than by ping count, which is the property the stateful design requires. round-robin across replicas — one vehicle, two half-pictures veh-1 pings: replica 1 · membership from pings 1, 3, 5 replica 2 · membership from pings 2, 4, 6 both record an entry → the depot sees two arrivals on departure, whichever misses the ping keeps it inside partitioned by asset id — one complete picture per vehicle replica 1 · every ping for veh-1 replica 2 · every ping for veh-2 membership is complete in exactly one place replicas scale by asset count, not by ping count
Figure 3. The router looks stateless from the outside — one ping in, transitions out — which is why it is usually the first service someone scales by adding replicas.

Verification

python
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.