Tuning DBSCAN eps and min_samples for Delivery Truck Stops

Selecting eps and min_samples for DBSCAN-based fleet stop detection is where most implementations diverge between production-quality and prototype-quality results. The parameters encode two domain facts simultaneously: the spatial resolution of your GPS hardware and the operational definition of a “stop” for your fleet type. For delivery trucks, a reasonable starting point is eps between 7.85e-06 and 2.36e-05 radians (roughly 50–150 metres; obtained by dividing metres by Earth’s mean radius of 6,371,000 m) combined with min_samples between 3 and 6 pings. These ranges anchor to real-world commercial telematics accuracy and typical 5–30 second sampling intervals — not to theoretical optima. This page explains how to arrive at values that hold up against ground truth, edge cases specific to last-mile delivery, and high-ping-rate fleet data.

DBSCAN parameter decision grid A grid showing recommended eps and min_samples combinations for delivery truck stop detection, with regions colour-coded by outcome: too fragmented, good zone, and merged stops. Merges Merges Merges Merges Merges Merges Good Good Good Caution Sparse Good Good Good Misses Fragments Fragments Fragments Fragments Fragments 2 3 4 5 6 min_samples >2.4e-05 1.6–2.4e-05 0.8–1.6e-05 <8e-06 eps (radians) Recommended range Stops merge across locations Stops fragment / missed
Outcome grid for DBSCAN parameter combinations. Green cells indicate parameter pairs that reliably separate delivery stops in typical telematics data. Rows represent eps bands in radians; columns represent min_samples values.

Compatibility and Configuration Requirements

Requirement Minimum version / value Notes
Python 3.10 f-string assignment expressions used in examples
scikit-learn 1.3 DBSCAN and NearestNeighbors haversine support stable since 1.0; 1.3 adds BallTree performance fixes
numpy 1.24 np.radians vectorised over DataFrame columns
pandas 2.0 .copy() behaviour on filtered slices; avoids chained-assignment warnings
Coordinate format [lat, lon] in radians metric='haversine' in scikit-learn expects radians; lon-first order raises silent distance errors
algorithm parameter 'ball_tree' or 'auto' 'kd_tree' is incompatible with haversine; 'auto' selects BallTree correctly
Speed column km/h float Pre-filter threshold is typically 3–7 km/h; adjust for low-speed urban couriers on cargo bikes

Tables in this section are horizontally scrollable on narrow viewports.

Production-Ready Tuning Script

The class below encapsulates the full workflow: velocity filtering, radian conversion, K-distance plotting for eps selection, DBSCAN fitting, and post-hoc dwell-time computation. Each parameter choice is explained inline.

Reading eps off the k-distance curve Sorted distance to the fourth nearest neighbour for every fix in a delivery day, plotted from smallest to largest. The curve is flat for the ninety percent of fixes that sit inside a stop, then bends sharply upward. The distance at the knee, about twenty-two metres, is the natural eps for this data. Sorted 4th-nearest-neighbour distance, one vehicle-day 200 m 100 m 0 knee ≈ 22 m — start eps here fixes inside stops moving fixes Plot k = min_samples − 1 so the curve answers the question DBSCAN will actually ask when it decides what a core point is. The knee is a starting point, not an answer — validate it against labelled stops before it reaches a billing report. Recompute per depot region: a rural round and a city centre round do not share a knee.
import numpy as np
import pandas as pd
from sklearn.cluster import DBSCAN
from sklearn.neighbors import NearestNeighbors
from typing import Optional


class DeliveryStopTuner:
    """
    End-to-end DBSCAN stop-detection tuner for delivery truck telematics.

    Parameters
    ----------
    eps_rad : float
        Neighbourhood radius in radians.  Convert metres with: metres / 6_371_000.
        Typical range: 7.85e-06 (~50 m) to 2.36e-05 (~150 m).
        Start at 1.57e-05 (~100 m) and adjust from the K-distance elbow.
    min_samples : int
        Minimum pings to declare a core point.  At 1 ping/10 s, value=4 ≈ 40 s
        stationary — suitable for curbside courier drops.  Raise to 6–8 for
        mandatory break detection (>5 min dwell required).
    speed_threshold_kmh : float
        Pings above this speed are excluded before clustering.  5.0 km/h is a
        safe default; reduce to 3.0 for dense urban environments where trucks
        creep below 5 km/h in traffic without stopping.
    min_dwell_seconds : int
        Post-clustering filter.  Clusters whose timestamp span is shorter than
        this value are discarded as GPS jitter artefacts.  Set to 120 (2 min)
        for delivery confirmations; 900 (15 min) for mandated driver breaks.
    temporal_gap_minutes : float
        If two consecutive pings within a spatial cluster are separated by more
        than this interval, the cluster is split into separate stop events.
        Prevents a morning depot visit merging with an evening return.
    """

    EARTH_RADIUS_M = 6_371_000

    def __init__(
        self,
        eps_rad: float = 1.57e-05,
        min_samples: int = 4,
        speed_threshold_kmh: float = 5.0,
        min_dwell_seconds: int = 120,
        temporal_gap_minutes: float = 15.0,
    ):
        self.eps_rad = eps_rad
        self.min_samples = min_samples
        self.speed_threshold_kmh = speed_threshold_kmh
        self.min_dwell_seconds = min_dwell_seconds
        self.temporal_gap_minutes = temporal_gap_minutes

    @staticmethod
    def metres_to_radians(metres: float) -> float:
        """Helper: convert a distance in metres to radians on the Earth sphere."""
        return metres / DeliveryStopTuner.EARTH_RADIUS_M

    def _prefilter(self, df: pd.DataFrame, lat: str, lon: str, speed: str) -> np.ndarray:
        """Return radian coordinate array after velocity filtering."""
        mask = df[speed] <= self.speed_threshold_kmh
        if mask.sum() == 0:
            raise ValueError(
                f"No pings at or below {self.speed_threshold_kmh} km/h. "
                "Check speed column units (expected km/h)."
            )
        coords_deg = df.loc[mask, [lat, lon]].values
        return np.radians(coords_deg), df[mask].copy()

    def k_distance_plot_data(
        self,
        df: pd.DataFrame,
        lat_col: str = "lat",
        lon_col: str = "lon",
        speed_col: str = "speed_kmh",
    ) -> np.ndarray:
        """
        Compute sorted k-th nearest-neighbour distances for eps selection.

        Returns an array of distances (radians) sorted descending.  Plot this
        array on the y-axis against point index on the x-axis.  The 'elbow' —
        the steepest inflection point — is a reliable eps candidate.

        k is set to self.min_samples so the K-distance graph is consistent with
        the density definition used by DBSCAN.
        """
        coords_rad, _ = self._prefilter(df, lat_col, lon_col, speed_col)
        nbrs = NearestNeighbors(
            n_neighbors=self.min_samples,
            algorithm="ball_tree",   # BallTree required for haversine
            metric="haversine",
        )
        nbrs.fit(coords_rad)
        distances, _ = nbrs.kneighbors(coords_rad)
        # Column -1 is the k-th neighbour (0-indexed from the point itself)
        k_distances = np.sort(distances[:, -1])[::-1]
        return k_distances

    def fit(
        self,
        df: pd.DataFrame,
        lat_col: str = "lat",
        lon_col: str = "lon",
        speed_col: str = "speed_kmh",
        timestamp_col: str = "timestamp_utc",
        vehicle_id_col: Optional[str] = "vehicle_id",
    ) -> pd.DataFrame:
        """
        Run DBSCAN on velocity-filtered pings and return a stop-events DataFrame.

        Output columns
        --------------
        vehicle_id      : from input (if provided)
        cluster_id      : DBSCAN label; -1 = noise (not a stop)
        stop_event_id   : unique id after temporal-gap splitting
        centroid_lat    : mean latitude of cluster pings
        centroid_lon    : mean longitude of cluster pings
        arrival_time    : min(timestamp) within stop event
        departure_time  : max(timestamp) within stop event
        dwell_seconds   : (departure_time - arrival_time).total_seconds()
        ping_count      : number of GPS pings in the stop event
        """
        coords_rad, df_slow = self._prefilter(df, lat_col, lon_col, speed_col)

        db = DBSCAN(
            eps=self.eps_rad,
            min_samples=self.min_samples,
            metric="haversine",
            algorithm="ball_tree",  # explicit; 'auto' also works
            n_jobs=-1,              # parallelise distance computation
        )
        df_slow = df_slow.copy()
        df_slow["_cluster"] = db.fit_predict(coords_rad)

        # Discard noise points (label == -1)
        df_clustered = df_slow[df_slow["_cluster"] != -1].copy()

        # Sort within each cluster by time for temporal-gap splitting
        sort_cols = (
            [vehicle_id_col, "_cluster", timestamp_col]
            if vehicle_id_col and vehicle_id_col in df_clustered.columns
            else ["_cluster", timestamp_col]
        )
        df_clustered = df_clustered.sort_values(sort_cols).reset_index(drop=True)

        # Temporal gap splitting: if two consecutive pings in the same spatial
        # cluster are separated by > temporal_gap_minutes, assign a new event id
        gap_threshold = pd.Timedelta(minutes=self.temporal_gap_minutes)
        ts = pd.to_datetime(df_clustered[timestamp_col])
        gap_flag = ts.diff().fillna(pd.Timedelta(0)) > gap_threshold
        cluster_change = df_clustered["_cluster"].ne(df_clustered["_cluster"].shift())
        df_clustered["stop_event_id"] = (gap_flag | cluster_change).cumsum()

        # Aggregate to one row per stop event
        agg = {
            lat_col: "mean",
            lon_col: "mean",
            timestamp_col: ["min", "max", "count"],
        }
        if vehicle_id_col and vehicle_id_col in df_clustered.columns:
            grp_cols = [vehicle_id_col, "stop_event_id"]
        else:
            grp_cols = ["stop_event_id"]

        summary = df_clustered.groupby(grp_cols).agg(agg)
        summary.columns = [
            "centroid_lat", "centroid_lon",
            "arrival_time", "departure_time", "ping_count",
        ]
        summary = summary.reset_index()

        # Dwell duration filter: remove sub-threshold artefacts
        summary["arrival_time"] = pd.to_datetime(summary["arrival_time"])
        summary["departure_time"] = pd.to_datetime(summary["departure_time"])
        summary["dwell_seconds"] = (
            summary["departure_time"] - summary["arrival_time"]
        ).dt.total_seconds()
        summary = summary[summary["dwell_seconds"] >= self.min_dwell_seconds]

        return summary.reset_index(drop=True)


# ---------------------------------------------------------------------------
# Usage example
# ---------------------------------------------------------------------------
# tuner = DeliveryStopTuner(
#     eps_rad=DeliveryStopTuner.metres_to_radians(100),  # 100 m
#     min_samples=4,
#     speed_threshold_kmh=5.0,
#     min_dwell_seconds=120,
#     temporal_gap_minutes=15.0,
# )
#
# # Step 1 — inspect K-distance plot to validate / adjust eps_rad
# k_dist = tuner.k_distance_plot_data(raw_df)
# # plot k_dist to identify elbow; update tuner.eps_rad accordingly
#
# # Step 2 — fit and extract stop events
# stops = tuner.fit(raw_df, timestamp_col="timestamp_utc", vehicle_id_col="vehicle_id")
# print(stops.head())

Execution and Tuning Guidelines

1. Start with the K-distance plot

Call tuner.k_distance_plot_data(df) to get the sorted k-nearest-neighbour distance array. Plot it on a line graph. The steepest bend (elbow) marks the natural density transition between clustered stops and sparse travel points. If no clear elbow exists, your data may lack a clean speed pre-filter — verify that the speed_threshold_kmh column contains km/h values, not m/s.

2. Convert metres to radians explicitly

Use DeliveryStopTuner.metres_to_radians(metres) rather than hard-coding radian values. The conversion metres / 6_371_000 is straightforward but easy to transpose: at 50 m the radian value is 7.85e-06, not 7.85e-05. A one-order-of-magnitude error silently inflates eps to a ~1 km radius, merging an entire city block into one stop.

3. Interpret eps and min_samples together

  • Raise eps (e.g., from 100 m to 130 m) when GPS drift causes a single physical stop to split across two clusters. Typical symptom: a loading dock appears as two adjacent events 20–40 m apart.
  • Lower eps (e.g., from 100 m to 70 m) when a busy intersection or traffic light produces false positives. Confirm by checking the ping_count of flagged clusters — signal-stop false positives rarely exceed 4–5 pings at 10-second sampling.
  • Raise min_samples when short traffic halts (< 30 s) contaminate results. Each additional sample adds one sampling interval of required dwell.
  • Lower min_samples (minimum 2) for fleets with sparse 30-second or 60-second sampling intervals, where even a two-minute stop produces only 2–4 pings within the eps radius.

4. Validate against ground truth

Cross-reference the output stops DataFrame against at least one of: driver delivery confirmation logs, geofenced depot entry/exit records, or customer POI coordinates from a location typing and POI matching pipeline. Compute precision and recall across labelled stops. Aim for recall > 0.90 before tightening precision — missed stops are operationally more costly than spurious ones for most last-mile use cases.

5. Apply GPS noise reduction upstream

Raw telematics pings frequently jump 10–20 m even when the vehicle is parked. Applying Kalman filtering for GPS noise reduction or a rolling median filter for GPS drift removal before clustering allows you to use a tighter eps without fragmenting genuine stops. Smoothed inputs also reduce the minimum required min_samples because consecutive pings are more spatially coherent.

Common Pitfalls

Depot merges: morning arrival and evening return collapse into one stop event The same parameters, two depots, two outcomes A suburban yard with bays forty metres apart clusters correctly at eps twenty-five metres. A city kerbside round where the same vehicle stops at doorways twelve metres apart merges four deliveries into one cluster at the same eps. One parameter set cannot serve both rounds. eps = 25 m, min_samples = 4 — applied to two rounds suburban yard — correct bay 1 bay 2 40 m apart — 2 clusters city kerbside — wrong 4 doorways, 12 m apart merged into 1 cluster Key the parameters off the round type, which the schedule already knows, rather than searching for one value that suits both. Where no round type exists, derive eps per vehicle-day from that day's own k-distance knee. Merged deliveries are invisible downstream: the stop count is simply lower, and nothing flags it.

DBSCAN operates on spatial proximity alone. If a truck visits the same depot at 06:00 and returns at 20:00, the pings from both visits overlap spatially and form a single cluster. The temporal_gap_minutes parameter in the tuner above splits these by detecting gaps larger than the threshold inside an otherwise-spatial cluster. Set temporal_gap_minutes to a value shorter than your minimum planned route duration (typically 15–60 minutes for last-mile delivery).

Silent distance corruption from wrong coordinate order or missing radian conversion

scikit-learn’s haversine metric expects input in [latitude, longitude] order and in radians, not degrees. Passing degrees produces distances roughly 57× too large (since 1 radian ≈ 57.3 degrees), making eps=1.57e-05 radians effectively ~5,700 km — every ping on Earth lands in one cluster. Passing [longitude, latitude] introduces a sign error that scrambles hemispheres. Neither mistake raises an exception; the output silently fails. The _prefilter method in the tuner above enforces radian conversion via np.radians, but always verify with a sanity check: the haversine distance between two points 100 m apart should return approximately 1.57e-05 radians.

BallTree / KDTree incompatibility drops the algorithm silently to brute-force

Specifying algorithm='kd_tree' with metric='haversine' causes scikit-learn to fall back to brute-force O(n²) computation without warning in some versions. For datasets exceeding 100,000 filtered pings this can increase fitting time from seconds to minutes. Always specify algorithm='ball_tree' or algorithm='auto' (which selects BallTree for haversine). For datasets exceeding 10 million filtered pings, replace NearestNeighbors with a pre-built BallTree and pass the distance matrix directly to DBSCAN(metric='precomputed') using chunked computation.


Up: DBSCAN for Fleet Stop Clustering | Stop Detection & Dwell Time Analytics