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.

Geofence-intersection dwell state machine Three states — Outside, Inside (dwell open), and Pending Exit — with transitions labelled by point-in-polygon test results and the exit debounce timer. Outside no active session Inside dwell session open Pending Exit debounce timer running Dwell Record enter · exit · duration point-in-polygon = true point-in-polygon = false debounce elapsed → close dwell_sec >= dwell_min_seconds re-enter within debounce window

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 that exit_debounce_seconds is 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