Geofence-Intersection Dwell Detection in Python
The time-window based dwell calculation approach segments a trace purely from speed and gap thresholds — it never asks where a vehicle stopped. That works when you only care about “was the vehicle stationary,” but fleet operators usually need something more specific: how long did this truck sit inside this customer’s fenced yard, versus idling at the curb outside it. When the set of relevant locations is already known — depots, cross-docks, named customer sites with drawn property lines — geofence intersection is a better fit than density-based discovery. Instead of clustering GPS points and hoping the resulting centroid falls inside a facility, you test each point against the facility polygon directly and derive dwell from the enter/exit crossings.
This page gives a self-contained shapely-based implementation that walks a time-ordered trace once, tracks which geofence (if any) each vehicle is inside, and produces enter, exit, and dwell records per visit — including debounce logic so a GPS point that jitters two meters outside the fence line for one ping does not fracture a single visit into three.
Compatibility and Configuration
| Requirement | Minimum version / value | Notes |
|---|---|---|
| Python | 3.10 | dataclasses and zoneinfo usage in examples |
| shapely | 2.0 | vectorised contains / prepared geometries; 1.x API differs for STRtree |
| geopandas | 0.14 | optional — for loading geofence polygons from GeoJSON/Parquet with a CRS |
| pyproj | 3.5 | required if buffering in metres rather than degrees |
| Coordinate format | WGS84 (lon, lat) for shapely.Point |
shapely takes (x, y) = (lon, lat), the reverse of the (lat, lon) convention used elsewhere on this site |
| Polygon source | one Polygon per geofence, ideally in a projected CRS for buffering |
project to a local UTM zone via the WGS84-to-local CRS workflow before buffering, then reproject back to WGS84 for the point-in-polygon test |
Geofences are almost always stored in WGS84 (EPSG:4326) for portability, but buffering a WGS84 polygon by “10 meters” is meaningless — degrees are not a constant distance. Project to a metric CRS, apply .buffer(buffer_m), then reproject back before running the trace through the detector below.
The Detector
The class below processes one vehicle’s trace at a time against a dictionary of named geofence polygons. It uses shapely.prepared.prep so repeated contains checks against the same polygon are fast even for traces with tens of thousands of points, and it tracks per-geofence session state so overlapping visits to different sites are handled independently.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
from shapely.geometry import Point, Polygon
from shapely.prepared import prep, PreparedGeometry
@dataclass
class DwellSession:
"""An in-progress or completed dwell inside a single geofence."""
geofence_id: str
enter_time: datetime
last_inside_time: datetime
pending_exit_time: Optional[datetime] = None
point_count: int = 1
@dataclass
class DwellEvent:
"""A finalized geofence visit ready for reporting."""
vehicle_id: str
geofence_id: str
enter_time: datetime
exit_time: datetime
dwell_seconds: float
point_count: int
class GeofenceDwellDetector:
"""
Compute enter/exit/dwell events from a time-ordered GPS trace by
intersecting each point against a set of named polygon geofences.
Parameters
----------
geofences : dict[str, Polygon]
Mapping of geofence_id -> shapely Polygon in WGS84 (lon, lat).
Polygons should already be buffered by GPS error tolerance;
see buffer_m below for the recommended buffering step.
dwell_min_seconds : float
Minimum session duration to emit as a real visit. Filters out
boundary flicker where a trace clips a polygon corner for a
few seconds without an operational stop. Default 120 (2 min).
exit_debounce_seconds : float
How long a point may sit outside the polygon before the session
is actually closed. A vehicle that steps 3 m outside a fence
line for one GPS ping (satellite geometry wobble) should not
fracture one visit into two. Default 60 seconds; raise to
120-180 for noisy consumer GNSS or dense urban canyons.
buffer_m : float
Documented here for reference only — apply this buffer to the
polygon *before* constructing the detector, in a projected CRS.
Typical value: 8-15 meters for commercial telematics hardware.
"""
def __init__(
self,
geofences: dict[str, Polygon],
dwell_min_seconds: float = 120.0,
exit_debounce_seconds: float = 60.0,
buffer_m: float = 10.0,
):
self.geofences = geofences
self.dwell_min_seconds = dwell_min_seconds
self.exit_debounce_seconds = exit_debounce_seconds
self.buffer_m = buffer_m # informational; see docstring
# Prepared geometries make repeated `contains` checks much faster
# than re-preparing the polygon on every point.
self._prepared: dict[str, PreparedGeometry] = {
gid: prep(poly) for gid, poly in geofences.items()
}
def _containing_geofence(self, point: Point) -> Optional[str]:
"""
Return the id of the smallest-area geofence containing point,
or None if the point falls outside every polygon. Ranking by
area resolves nested/overlapping geofences (e.g. a depot yard
inside a larger campus boundary) deterministically.
"""
matches = [
gid for gid, prepared in self._prepared.items()
if prepared.contains(point)
]
if not matches:
return None
return min(matches, key=lambda gid: self.geofences[gid].area)
def detect(
self,
vehicle_id: str,
trace: list[tuple[datetime, float, float]],
) -> list[DwellEvent]:
"""
Walk a single time-ordered trace and emit dwell events.
Parameters
----------
vehicle_id : str
trace : list of (timestamp, lon, lat) tuples, sorted ascending
by timestamp. Caller is responsible for sorting and
de-duplication before calling this method.
Returns
-------
list[DwellEvent], one entry per completed, qualifying visit.
"""
events: list[DwellEvent] = []
session: Optional[DwellSession] = None
debounce = timedelta(seconds=self.exit_debounce_seconds)
for ts, lon, lat in trace:
point = Point(lon, lat)
gid = self._containing_geofence(point)
if session is None:
if gid is not None:
# Fresh entry into a geofence — open a session.
session = DwellSession(
geofence_id=gid, enter_time=ts, last_inside_time=ts
)
continue
if gid == session.geofence_id:
# Still inside the same geofence: extend and clear any
# pending exit that was accumulating from a brief dropout.
session.last_inside_time = ts
session.pending_exit_time = None
session.point_count += 1
continue
# Point is outside the active geofence (or inside a
# different one). Start or continue the debounce timer.
if session.pending_exit_time is None:
session.pending_exit_time = ts
elapsed_outside = ts - session.pending_exit_time
if elapsed_outside >= debounce or gid is not None:
# Debounce window exceeded, or the vehicle has clearly
# entered a different geofence: close the session now.
self._close_session(session, vehicle_id, events)
session = (
DwellSession(geofence_id=gid, enter_time=ts, last_inside_time=ts)
if gid is not None
else None
)
# Trace ended while a session was still open — close it out
# using the last confirmed inside timestamp, not the debounce
# window (there is no later point to confirm a real exit).
if session is not None:
self._close_session(session, vehicle_id, events)
return events
def _close_session(
self, session: DwellSession, vehicle_id: str, events: list[DwellEvent]
) -> None:
dwell_sec = (session.last_inside_time - session.enter_time).total_seconds()
if dwell_sec < self.dwell_min_seconds:
return # boundary flicker, not a real visit
events.append(
DwellEvent(
vehicle_id=vehicle_id,
geofence_id=session.geofence_id,
enter_time=session.enter_time,
exit_time=session.last_inside_time,
dwell_seconds=dwell_sec,
point_count=session.point_count,
)
)
Usage against a loaded set of facility polygons:
from shapely.geometry import Polygon
geofences = {
"DEPOT-04": Polygon([(-122.421, 37.775), (-122.419, 37.775),
(-122.419, 37.777), (-122.421, 37.777)]),
"CUSTOMER-A": Polygon([(-122.410, 37.780), (-122.408, 37.780),
(-122.408, 37.781), (-122.410, 37.781)]),
}
detector = GeofenceDwellDetector(
geofences=geofences,
dwell_min_seconds=120,
exit_debounce_seconds=60,
)
events = detector.detect(vehicle_id="VH-1042", trace=sorted_trace_tuples)
for e in events:
print(e.geofence_id, e.enter_time, e.exit_time, round(e.dwell_seconds, 1))
Expected output shape: a list of DwellEvent objects, one per qualifying visit, each carrying vehicle_id, geofence_id, enter_time, exit_time, dwell_seconds, and point_count. Convert to a pandas.DataFrame with pd.DataFrame([e.__dict__ for e in events]) for downstream aggregation.
Execution and Tuning Guidelines
Run detect() once per vehicle per day (or per trip segment) rather than across a vehicle’s entire multi-week history in one call — this keeps memory bounded and lets you parallelize across vehicles trivially with multiprocessing or a Spark mapPartitions call.
dwell_min_seconds— the floor below which a session is discarded as noise rather than a real visit. Raise this to 300+ seconds for sites where a driver signature or loading-dock scan should always accompany a visit longer than a few minutes; lower it toward 30-60 seconds for curbside drop-offs where a legitimate stop can be brief.exit_debounce_seconds— how long the detector tolerates a point outside the polygon before treating the visit as over. Too low (under 20 s at typical 30-second sampling) means a single noisy ping near the fence line splits one visit into two or three. Too high (over 300 s) risks merging a real departure-and-quick-return with an unrelated later visit. Start at 60 seconds and validate against known depot camera or gate-log timestamps.buffer_m— applied to the polygon before it reaches the detector, not a runtime parameter of the class itself. Under-buffering causes the same boundary flicker thatexit_debounce_secondsis meant to absorb, so the two knobs interact: a tightly-surveyed polygon with no buffer needs a longer debounce window to compensate, while a generously buffered polygon can use a shorter one.
For large geofence sets (hundreds of polygons per region), replace the linear scan in _containing_geofence with a shapely.STRtree spatial index built once at construction time, querying candidate polygons before running the exact contains check — this turns an O(N) scan per point into an O(log N) index query plus a handful of exact tests.
Common Pitfalls
Boundary flicker inflates visit counts
Cause: a GPS point lands exactly on or near the polygon edge, and successive pings alternate between inside and outside due to normal positional noise rather than actual vehicle movement.
Symptom: a single physical stop produces three or four short DwellEvent records instead of one, each a few seconds to a couple of minutes long, all at the same geofence.
Fix: increase buffer_m on the source polygon (project to a metric CRS first) and raise exit_debounce_seconds. The debounce timer in the state machine above already absorbs single-ping dropouts; if flicker persists, the polygon itself is too tight relative to GPS accuracy.
Overlapping geofences produce ambiguous or double-counted dwell
Cause: two polygons overlap — a depot yard nested inside a wider campus boundary, or adjacent customer lots sharing a fence line — and a point can legitimately satisfy contains() for more than one geofence.
Symptom: dwell time for the outer geofence appears to include time that should belong exclusively to the inner one, or a vehicle parked exactly on a shared boundary alternates its assigned geofence_id between two neighboring sites.
Fix: the detector above resolves this by always selecting the smallest-area matching polygon, which favors the more specific site over its container. For genuinely ambiguous adjacent lots with no area difference, add an explicit priority field to your geofence records and sort by that instead of area.
GPS drift causes a trace to clip a geofence corner without a real visit
Cause: a vehicle driving past a facility on an adjacent road has its GPS trace momentarily clip the corner of a poorly-drawn or over-buffered polygon due to positional error, without the vehicle ever actually entering the property.
Symptom: short DwellEvent records (often near the dwell_min_seconds floor) appear at sites the vehicle never visited, typically clustered near a specific corner of the polygon.
Fix: tighten the polygon at the offending corner, reduce buffer_m, or add a minimum point_count filter alongside dwell_min_seconds — a genuine stop accumulates many pings clustered in time, while a drive-by clip produces very few. Cross-reference against speed profiling from raw GPS coordinates to confirm the vehicle’s speed actually dropped near zero during the flagged window rather than staying at road speed throughout.
Up: Time-Window Based Dwell Calculation | Stop Detection & Dwell Time Analytics
Related
- Time-Window Based Dwell Calculation — the temporal-windowing approach this page complements when facility boundaries are already known
- Calculating accurate dwell times across timezone shifts — apply once geofence enter/exit timestamps are in hand and need local-time reporting
- Location Typing and POI Matching for Stops — the semantic-enrichment step for stops that fall outside any known geofence
- DBSCAN for Fleet Stop Clustering — density-based discovery of stop locations when no polygon exists yet