Stop Detection & Dwell Time Analytics

Without reliable stop detection, every downstream operational system degrades simultaneously. Billing platforms over-charge or under-charge because dwell boundaries are wrong. Driver utilization dashboards show impossibly high active-driving percentages. SLA violation alerts fire on traffic stops and miss genuine delivery delays. Route optimization engines receive inflated service-time estimates that cascade into infeasible schedules across an entire fleet.

These failures share a root cause: treating raw GPS pings as a direct proxy for vehicle behavior rather than as noisy measurements that require statistical segmentation. This guide covers the full engineering path from raw telemetry to production-grade stop events—algorithmic foundations, Python implementation patterns, scaling architecture, and validation methodology.

Where Stop Detection Sits in the Telematics Pipeline

Before GPS data preprocessing and cleaning has run, stop detection will consistently misclassify urban-canyon drift as movement and multipath-induced jumps as micro-trips. The correct pipeline position is strictly downstream of signal cleaning:

Telematics data pipeline stages Five pipeline stages shown as connected boxes: Ingestion & Normalisation, Signal Preprocessing, Stop Segmentation, Enrichment & Scoring, Output & Storage. Arrows connect each stage left to right. Ingestion & Normalisation Signal Preprocessing Stop Segmentation ← current section Enrichment & Scoring Output & Storage

Each stage has a clear contract: the Ingestion & Normalisation stage standardises coordinates to WGS84, converts timestamps to UTC, and logs sampling irregularities. Signal Preprocessing applies Kalman filtering for GPS noise reduction and outlier removal in raw telematics streams to produce a clean, temporally ordered trace. Stop Segmentation then operates on this clean trace, isolating stationary phases and computing dwell boundaries. Enrichment and storage are purely downstream consumers of the stop events that segmentation produces.

Skipping any upstream stage corrupts the segmentation. A single multipath jump—where GPS briefly reports 40 m displacement during a stationary period—will falsely split a genuine stop into two events if timestamp synchronization for multi-device GPS logs has not already flagged the anomalous point.

What Goes Wrong Without Reliable Stop Detection

The consequences of poor segmentation are concrete and measurable:

False delivery confirmations. A driver stuck in a loading-bay queue for 8 minutes, never reaching the dock, triggers a stop event that billing interprets as a completed service visit. The customer disputes the charge; the operations team has no algorithmic audit trail.

Inflated service-time estimates. When traffic pauses and genuine stops are conflated, the median service time at a customer location grows artificially. Route optimizers ingest these inflated averages and produce daily schedules that are 15–25 minutes longer than achievable, compounding across a 50-vehicle fleet into hundreds of wasted hours per week.

Compliance reporting failures. Hours-of-service and rest-period rules require precise break-start and break-end timestamps. If dwell boundaries are off by 3 minutes per event, a driver’s compliant 30-minute break can appear as two non-compliant 14-minute pauses.

Dead zones in POI matching. Location enrichment pipelines that run before stop segmentation is complete have no stable centroid to query against. The entire POI-matching stage produces null results or, worse, spurious classifications from transient cluster centroids.

The Algorithmic Landscape

Three families of algorithms address stop detection in fleet telematics. Choosing between them requires weighing accuracy, latency, infrastructure cost, and auditability against the specific data characteristics of your fleet.

Approach Accuracy Latency Infra cost Auditability Best fit
Speed & radius thresholding Medium Very low Minimal High Homogeneous fleets, reliable speed sensors, >1 Hz
Spatial clustering (DBSCAN) High Low–medium Low High Mixed fleets, irregular sampling, unreliable speed
Change-point detection Very high Medium–high Moderate Medium High-frequency (1 Hz+), complex manoeuvres, billing-critical
ML sequence labelling Very high High High Low Large labelled datasets available, edge-case-dense routes

Speed and Radius Thresholding

The simplest approach flags a stop whenever speed drops below a configurable floor (typically ≤ 3 km/h) for a minimum number of consecutive samples within a bounding radius. Implemented as vectorized Pandas or Polars operations with rolling windows, it is extremely cheap to compute and produces fully deterministic, auditable output.

Static thresholds fail on heterogeneous fleets. A refrigerated articulated truck and a cargo bicycle have entirely different idle signatures, turning radii, and GPS positional variance. Heavy vehicles in particular exhibit low-speed crawl during dock manoeuvring that is not a stop. Per-vehicle-class threshold calibration—derived from historical driving profiles—is required for any fleet with more than one vehicle type.

Urban canyon environments introduce a second failure mode: GPS drift pushes stationary vehicles above the speed threshold intermittently, fragmenting a 20-minute stop into several false micro-trips. This is why thresholding alone is always paired with downstream merging logic.

Spatial Clustering with DBSCAN

When speed data is unreliable or sampling intervals exceed 10 seconds, density-based spatial clustering provides superior segmentation. DBSCAN for fleet stop clustering groups consecutive GPS points that fall within a dense spatial neighbourhood, treating the centroid of each identified stop as the stop location and the temporal extent as the dwell period.

DBSCAN requires no prior assumption about the number of stops and naturally handles non-uniform point distributions. Its two parameters—eps (maximum distance between points in a dense neighbourhood, in metres) and min_samples (minimum point count for a dense neighbourhood)—map directly to operational semantics: eps encodes the spatial footprint of a typical loading dock or delivery bay, while min_samples encodes the minimum dwell duration at the sampling rate in use.

The standard approach pairs DBSCAN with a Haversine distance metric and a temporal constraint: points more than T seconds apart are never merged into the same cluster even if they are geographically close. This prevents a depot that a vehicle visits twice daily from appearing as a single 12-hour stop.

Change-Point Detection

Advanced pipelines treat stop detection as a change-point problem over the derivative signal. By computing heading variance, acceleration, and positional spread across a sliding window, the algorithm identifies abrupt transitions in motion state. Statistical tests—CUSUM, PELT, or Bayesian online change-point detection—isolate the exact sample where the vehicle transitions from moving to stationary.

This method excels at high-frequency telemetry (1 Hz+) and at correctly bounding complex manoeuvres: a driver who circles a block before finding parking generates a complex low-speed trajectory that confuses both thresholding and DBSCAN but produces a clean change-point signal. The trade-off is compute cost and parameter sensitivity; the algorithm requires careful calibration of window sizes and sensitivity thresholds per vehicle class.

Python Stack Overview

Library Role When to choose
pandas Vectorized rolling windows, time-series indexing Stop thresholding on medium datasets (<10 M rows)
polars High-throughput columnar operations Batch processing of full-fleet daily history
numpy Haversine distance, velocity derivative arrays Numerical kernels inside clustering and change-point loops
scipy Spatial KD-trees, rolling statistical tests PELT change-point detection, nearest-neighbour enrichment
scikit-learn DBSCAN, preprocessing pipelines Spatial clustering with configurable distance metrics
geopandas Spatial joins against POI and road-network layers Enrichment, geofence intersection, CRS reprojection
shapely Point-in-polygon, buffer geometry Stop centroid containment checks, depot boundary matching

Key Implementation Patterns

Pattern 1: Vectorized Threshold Segmentation

The core of threshold-based detection is a two-pass vectorized operation: first label each point as stopped or moving, then merge consecutive stopped segments into events.

import numpy as np
import pandas as pd


def segment_stops(
    df: pd.DataFrame,
    speed_col: str = "speed_kmh",
    lat_col: str = "lat",
    lon_col: str = "lon",
    ts_col: str = "timestamp_utc",
    speed_threshold: float = 3.0,      # km/h — vehicle-class specific
    min_dwell_seconds: int = 90,        # eliminates traffic-light pauses
    max_gap_seconds: int = 120,         # merges across brief data gaps
    merge_radius_m: float = 50.0,       # merges nearby fragmented stops
) -> pd.DataFrame:
    """
    Return a DataFrame of stop events with arrival, departure, centroid, and duration.
    Input df must be sorted by ts_col and contain a single vehicle_id.
    """
    df = df.sort_values(ts_col).reset_index(drop=True)
    ts = df[ts_col].values.astype("datetime64[s]").astype(np.int64)

    # Label stopped points (vectorized)
    stopped = df[speed_col].values <= speed_threshold

    # Build run-length encoding of stopped/moving segments
    changes = np.where(np.diff(stopped.astype(int)) != 0)[0] + 1
    boundaries = np.concatenate([[0], changes, [len(stopped)]])

    events = []
    for i in range(len(boundaries) - 1):
        start, end = boundaries[i], boundaries[i + 1]
        if not stopped[start]:
            continue
        seg_ts = ts[start:end]
        duration = int(seg_ts[-1] - seg_ts[0])
        if duration < min_dwell_seconds:
            continue
        lats = df[lat_col].values[start:end]
        lons = df[lon_col].values[start:end]
        events.append({
            "arrival_utc": df[ts_col].iloc[start],
            "departure_utc": df[ts_col].iloc[end - 1],
            "duration_s": duration,
            "centroid_lat": float(np.median(lats)),
            "centroid_lon": float(np.median(lons)),
            "point_count": end - start,
        })

    if not events:
        return pd.DataFrame(columns=["arrival_utc", "departure_utc",
                                      "duration_s", "centroid_lat",
                                      "centroid_lon", "point_count"])

    result = pd.DataFrame(events)
    return _merge_fragmented_stops(result, max_gap_seconds, merge_radius_m)


def _haversine_m(lat1, lon1, lat2, lon2):
    R = 6_371_000.0
    phi1, phi2 = np.radians(lat1), np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlam = np.radians(lon2 - lon1)
    a = np.sin(dphi / 2) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlam / 2) ** 2
    return 2 * R * np.arcsin(np.sqrt(a))


def _merge_fragmented_stops(
    events: pd.DataFrame,
    max_gap_seconds: int,
    merge_radius_m: float,
) -> pd.DataFrame:
    """Merge consecutive stop events separated by a short gap at the same location."""
    merged = []
    current = events.iloc[0].to_dict()
    for _, row in events.iloc[1:].iterrows():
        gap = (row["arrival_utc"] - current["departure_utc"]).total_seconds()
        dist = _haversine_m(
            current["centroid_lat"], current["centroid_lon"],
            row["centroid_lat"], row["centroid_lon"],
        )
        if gap <= max_gap_seconds and dist <= merge_radius_m:
            # Extend current segment
            current["departure_utc"] = row["departure_utc"]
            current["duration_s"] = int(
                (current["departure_utc"] - current["arrival_utc"]).total_seconds()
            )
            current["centroid_lat"] = (current["centroid_lat"] + row["centroid_lat"]) / 2
            current["centroid_lon"] = (current["centroid_lon"] + row["centroid_lon"]) / 2
            current["point_count"] += row["point_count"]
        else:
            merged.append(current)
            current = row.to_dict()
    merged.append(current)
    return pd.DataFrame(merged)

Pattern 2: DBSCAN Spatial Clustering with Temporal Guard

When speed data is absent or unreliable, DBSCAN over the raw point cloud produces more robust clusters. The temporal guard prevents cross-trip clustering.

from sklearn.cluster import DBSCAN
import numpy as np
import pandas as pd


def dbscan_stops(
    df: pd.DataFrame,
    lat_col: str = "lat",
    lon_col: str = "lon",
    ts_col: str = "timestamp_utc",
    eps_m: float = 40.0,            # spatial radius — match to typical dock/bay footprint
    min_samples: int = 4,           # minimum points (duration proxy at sampling rate)
    max_gap_seconds: int = 300,     # temporal guard: break clusters across gaps
) -> pd.DataFrame:
    """
    Identify stops via DBSCAN on (lat, lon) with Haversine distance.
    Returns one row per identified stop cluster with centroid and duration.
    """
    df = df.sort_values(ts_col).reset_index(drop=True)
    coords_rad = np.radians(df[[lat_col, lon_col]].values)

    # DBSCAN with ball-tree haversine metric; eps in radians (metres / Earth radius)
    eps_rad = eps_m / 6_371_000.0
    labels = DBSCAN(
        eps=eps_rad,
        min_samples=min_samples,
        algorithm="ball_tree",
        metric="haversine",
    ).fit_predict(coords_rad)

    df = df.copy()
    df["_cluster"] = labels
    df["_ts_s"] = df[ts_col].astype(np.int64) // 10 ** 9

    stops = []
    for cluster_id in set(labels):
        if cluster_id == -1:
            continue  # noise points
        mask = df["_cluster"] == cluster_id
        cluster_pts = df[mask].sort_values(ts_col)

        # Temporal guard: split on large intra-cluster gaps
        ts_vals = cluster_pts["_ts_s"].values
        gap_mask = np.diff(ts_vals) > max_gap_seconds
        split_indices = np.where(gap_mask)[0] + 1
        sub_clusters = np.split(cluster_pts, split_indices)

        for sc in sub_clusters:
            if len(sc) < min_samples:
                continue
            duration = int(sc["_ts_s"].iloc[-1] - sc["_ts_s"].iloc[0])
            stops.append({
                "arrival_utc": sc[ts_col].iloc[0],
                "departure_utc": sc[ts_col].iloc[-1],
                "duration_s": duration,
                "centroid_lat": float(sc[lat_col].median()),
                "centroid_lon": float(sc[lon_col].median()),
                "point_count": len(sc),
            })

    if not stops:
        return pd.DataFrame()
    return pd.DataFrame(stops).sort_values("arrival_utc").reset_index(drop=True)

Pattern 3: Confidence Scoring

Every stop event should carry a composite confidence score before it leaves the segmentation stage. The four primary signal quality indicators are GPS horizontal accuracy, point density relative to the expected rate, velocity variance during the stationary phase, and spatial stability of the centroid.

import numpy as np
import pandas as pd


def score_stop_event(
    stop: dict,
    source_points: pd.DataFrame,
    expected_hz: float = 0.2,       # expected sampling rate (1 ping / 5 s = 0.2 Hz)
    accuracy_col: str = "h_accuracy_m",
    speed_col: str = "speed_kmh",
    lat_col: str = "lat",
    lon_col: str = "lon",
) -> float:
    """
    Return a 0–100 confidence score for a stop event.
    Higher values indicate more reliable stop boundaries and centroid position.
    """
    scores = []

    # 1. GPS horizontal accuracy (lower is better; 5 m = 100, 50 m = 0)
    if accuracy_col in source_points.columns:
        median_acc = source_points[accuracy_col].median()
        acc_score = max(0.0, 100.0 - (median_acc - 5.0) * (100.0 / 45.0))
        scores.append(("accuracy", acc_score, 0.35))

    # 2. Point density relative to expected rate
    expected_points = stop["duration_s"] * expected_hz
    density_ratio = min(stop["point_count"] / max(expected_points, 1), 1.0)
    scores.append(("density", density_ratio * 100.0, 0.25))

    # 3. Velocity variance during stop (lower variance = more stable stop)
    if speed_col in source_points.columns:
        speed_var = source_points[speed_col].var()
        var_score = max(0.0, 100.0 - speed_var * 5.0)
        scores.append(("velocity_variance", var_score, 0.25))

    # 4. Spatial stability of centroid (std dev of position in metres)
    if lat_col in source_points.columns and lon_col in source_points.columns:
        lat_std_m = source_points[lat_col].std() * 111_320.0
        lon_std_m = (
            source_points[lon_col].std()
            * 111_320.0
            * np.cos(np.radians(stop["centroid_lat"]))
        )
        pos_std_m = np.sqrt(lat_std_m ** 2 + lon_std_m ** 2)
        pos_score = max(0.0, 100.0 - pos_std_m * 2.0)
        scores.append(("position_stability", pos_score, 0.15))

    if not scores:
        return 50.0  # neutral score when no quality signals are available

    total_weight = sum(w for _, _, w in scores)
    return round(sum(s * w for _, s, w in scores) / total_weight, 1)

Calculating Dwell Time with Precision

Once a stationary segment is isolated, the raw temporal boundaries are rarely the true arrival and departure moments. GPS drift during stationary periods artificially inflates or compresses measured durations. The “ping-pong” effect—where a vehicle briefly moves to adjust parking position—can falsely split a genuine stop into two separate events if boundary logic is not hardened.

Time-window based dwell calculation addresses this by back-calculating arrival to the first sample where speed remained below threshold for N consecutive samples, and forward-projecting departure until sustained motion resumes. This approach also handles calculating accurate dwell times across timezone shifts by anchoring all boundary logic to UTC before any local-time conversion.

Dwell accuracy directly impacts operational KPIs. A 3-minute systematic error per stop compounds to over 50 hours of misattributed service time per day across a 1 000-stop fleet.

Contextual Enrichment and Location Intelligence

A stop event with coordinates and duration is a geometric fact with limited business value. Location typing and POI matching for stops transforms it into an operational classification: customer delivery, depot return, fuel station, driver rest break, or unauthorized parking.

Reverse geocoding alone is insufficient. Production enrichment pipelines perform spatial joins against curated commercial POI datasets, apply road-network topology checks to confirm that the stop centroid is accessible from the recorded approach path, and use historical visitation patterns to increase classification confidence for locations visited repeatedly.

GPS drift requires probabilistic spatial buffers during matching. A delivery stop might register 15–20 metres from the actual loading dock in a dense urban environment. Matching GPS stops to commercial POI databases in Python covers the full implementation including buffer sizing, spatial index strategies, and confidence-weighted classification.

Coordinate reference system mapping for fleet data is a prerequisite for any spatial join: stop centroids must be reprojected to the same CRS as the POI dataset before distance calculations are performed.

Production Considerations

Mixed Fleets and Per-Class Calibration

A single threshold configuration is incorrect for heterogeneous fleets. Heavy articulated trucks, urban light commercial vehicles, and motorcycles have different GPS positional variance, different speeds during dock manoeuvring, and different ratios of stationary time to moving time. Stop detection pipelines should load per-vehicle-class parameter sets at runtime, keyed on vehicle type from the fleet management system.

Tunnel Gaps and Signal Loss

Network outages or GPS blackouts in tunnels and parking structures create trajectory gaps. Forcing a stop/move boundary at the gap edges produces phantom events. The correct handling is to flag segments with data gaps exceeding a configurable threshold as unverified rather than applying a deterministic stop/move label. These flagged segments route to a separate reconciliation queue where ignition state signals, OBD-II data, or manual review can provide ground truth.

High-Frequency vs. Sparse Sampling

At 1 Hz, velocity derivative methods are reliable and change-point detection is preferred. Between 5-second and 30-second intervals, DBSCAN provides the best spatial coverage. Above 60-second intervals, raw trajectory gaps become significant and interpolation (linear or Kalman-smoothed) is required before any segmentation. The choice of interpolation strategy affects stop boundary accuracy by up to ±30 seconds at low sampling rates.

Ignition State Fusion

When CAN bus ignition status is available alongside GPS telemetry, fusing the two signals dramatically reduces false positives. Ignition-off events confirm true parking; ignition-on with zero speed typically indicates deliberate waiting rather than a data artifact. Ignition-state changes serve as hard stop/move boundaries that override algorithmic segmentation when confidence is low.

Performance and Scaling

Batch Architecture

Historical analysis, compliance reporting, and model training leverage partitioned data lakes (Parquet or Delta Lake). Partition by vehicle_id date for efficient range scans. Polars’ lazy evaluation avoids materializing full-fleet histories in memory: filter to the target date range before applying stop segmentation logic. On a single 32-core node, Polars can process approximately 500 M GPS pings per hour through the threshold segmentation pattern above.

Spatial Indexing

Enrichment pipelines that perform per-stop spatial joins against large POI datasets require spatial indexing. An R-tree (via rtree or geopandas) over the POI dataset enables sub-millisecond bounding-box queries. For nearest-neighbour enrichment, scipy.spatial.KDTree on projected coordinates (UTM or local CRS) provides O(log n) lookup against millions of POI records. A reprojection to a projected CRS—rather than operating in WGS84 degrees—is essential for correct distance calculations; see converting WGS84 to local CRS for fleet routing for the canonical implementation.

Stream Processing

Real-time operations—dispatch alerts, dynamic rerouting, live driver coaching—require streaming architectures. Stream processors maintain a per-vehicle state buffer: current velocity, last known position, active candidate stop window. When the window crosses the minimum dwell threshold, it emits a stop event downstream. Late-arriving pings (common on mobile networks) are handled by watermarking: events arriving more than W seconds after the window close timestamp are sent to a late-data reconciliation topic rather than being silently dropped or forcing a pipeline restart.

Idempotent writes and deduplication keys (typically vehicle_id + arrival_utc) prevent phantom stops from message redelivery. Atomic transaction boundaries ensure that the stop event and its enriched POI classification are always written together, preventing partially enriched records from entering the billing system.

Memory Footprint

For the threshold segmentation pattern, memory footprint scales with the number of active vehicle windows simultaneously in flight, not with total fleet history. A 32-byte per-point working set (lat, lon, timestamp, speed, cluster label) means a 1 000-vehicle fleet generating 10 000 points per vehicle per day consumes approximately 320 MB of working memory during peak processing—well within the bounds of a single processing node.

Validation and Observability

No segmentation algorithm achieves production-level accuracy without continuous measurement. Three metrics form the core validation suite:

F1 score against labelled ground truth. Fleet operators should maintain a labelled dataset of verified stops—collected via driver mobile apps, ELD integrations, or manual audits. F1 scores should be tracked per vehicle class, geographic region (urban dense, suburban, highway), and time of day. A score above 0.92 is achievable with calibrated DBSCAN; below 0.85 indicates systematic misclassification requiring threshold recalibration.

Temporal alignment error. For each labelled stop, measure the difference in seconds between the ground-truth arrival/departure timestamp and the algorithmically detected boundary. Report the median and 95th-percentile alignment error. A p95 above 120 seconds indicates boundary refinement logic requires attention.

Confidence score distribution monitoring. Track the distribution of per-event confidence scores (from Pattern 3 above) over time. A sustained shift toward low-confidence scores indicates degrading GPS signal quality, firmware changes on a device model, or seasonal accuracy degradation—all of which require threshold recalibration before business metrics are affected.

Alert thresholds should be set on the rolling 7-day F1 score (alert below 0.88), mean temporal alignment error (alert above 90 seconds), and the percentage of events with confidence below 40 (alert above 5%).

Confidence scoring for stop detection provides the full implementation and monitoring schema for exposing these metrics in a production observability stack.

Common Pitfalls

Stop boundary splits on parking manoeuvres

Cause: The vehicle briefly exceeds the speed threshold while repositioning within a parking area. Symptom: One genuine 20-minute stop appears as two 9-minute stops with a 2-minute gap. Fix: Apply the _merge_fragmented_stops pattern above with max_gap_seconds=120 and merge_radius_m=50. Tune merge_radius_m to the largest expected parking area footprint for that vehicle class.

Inflated dwell at signalized intersections

Cause: min_dwell_seconds is set below the maximum red-phase duration for that city’s traffic signal network (typically 90–120 seconds on arterials). Symptom: High false-positive rate in urban areas; stop count doubles. Fix: Set min_dwell_seconds to at least 150 seconds in dense urban areas, or add an ignition-state guard—ignition-on + low-speed events below this threshold are classified as traffic delays, not stops.

Cross-trip clustering at depot

Cause: DBSCAN does not have a temporal guard, so a driver’s morning departure ping and afternoon return ping fall within eps_m of each other and merge into a single all-day cluster. Symptom: Depot stops show duration = 8–10 hours; actual delivery stops are absent. Fix: Enable the max_gap_seconds temporal guard in dbscan_stops above. Set it to the maximum expected intra-stop gap for your fleet (typically 300 seconds).

Zero-duration stops from duplicate timestamps

Cause: Duplicate GPS records with identical timestamps and coordinates appear after the deduplication stage is skipped or misconfigured. Symptom: Stop events with duration_s = 0 propagate into billing and produce divide-by-zero errors downstream. Fix: Add a deduplication step keyed on (vehicle_id, timestamp_utc) immediately after ingestion normalisation. Confirm with df.duplicated(subset=["vehicle_id", "timestamp_utc"]).sum() == 0 before passing to segmentation.