Segmenting trips by dwell time and gap thresholds
A trip ends when the vehicle stops for long enough to count, or when the data stops for long enough that nobody knows. Those are different events and they need different handling: a dwell boundary means “a visit happened here”, a gap boundary means “we have no idea what happened”. Conflating them is why trip counts disagree between systems that are looking at the same day.
Why this happens
The naive rule — cut whenever speed drops below a threshold — makes every traffic light a trip boundary. Adding a duration requirement fixes that and introduces a new problem: the duration cannot be evaluated until it has elapsed, so a streaming implementation that emits on the first slow fix has to retract. That is why a candidate state exists, and why the same state machine appears in combining trajectory segmentation with stay-point detection.
The gap case is different in kind. When a device stops reporting for forty minutes, the vehicle may have been parked, may have completed two deliveries, or may have driven across the county. The honest response is to end the trip and start a new one without claiming a stop occurred — and to label the boundary as gap so that a later analysis of dwell times does not count it.
Core pipeline
- Detect candidate stops where speed stays below a threshold and displacement stays inside a radius.
- Confirm a stop only once the dwell exceeds the minimum duration, emitting nothing before that.
- Cut on gaps independently, labelling those boundaries as unknown rather than as stops.
- Calibrate against labelled trip counts, because the threshold is the single largest driver of the total.
Production-ready Python implementation
import numpy as np
import pandas as pd
def segment_trips(
df: pd.DataFrame,
time_col: str = "t",
x_col: str = "x",
y_col: str = "y",
speed_col: str = "speed_ms",
entity_col: str = "entity_id",
stop_speed_ms: float = 0.8,
stop_radius_m: float = 30.0,
min_dwell_s: float = 180.0,
gap_s: float = 600.0,
) -> pd.DataFrame:
"""
Cut each entity's track into trips at confirmed dwells and at data gaps.
Parameters
----------
min_dwell_s : float
How long the vehicle must remain within stop_radius_m below
stop_speed_ms before the pause counts as a trip boundary. Below about
120 s, traffic signals start creating trips.
gap_s : float
Reporting gap that ends a trip regardless of what the vehicle did.
Returns
-------
pd.DataFrame
Input plus 'trip_id' and 'boundary_kind' in {start, dwell, gap}.
Raises
------
ValueError
On missing columns, an empty frame, or a non-positive dwell.
"""
required = {time_col, x_col, y_col, speed_col, entity_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
if min_dwell_s <= 0:
raise ValueError("min_dwell_s must be positive.")
d = df.sort_values([entity_col, time_col]).reset_index(drop=True).copy()
trip_id = np.zeros(len(d), dtype=np.int64)
kind = np.full(len(d), "", dtype=object)
tid = 0
for _, idx in d.groupby(entity_col, sort=False).groups.items():
pos = d.index.get_indexer(idx)
t = d[time_col].to_numpy()[pos]
x = d[x_col].to_numpy()[pos]
y = d[y_col].to_numpy()[pos]
v = d[speed_col].to_numpy()[pos]
tid += 1
trip_id[pos[0]] = tid
kind[pos[0]] = "start"
anchor = 0 # first fix of the current candidate stop
in_candidate = False
for i in range(1, len(pos)):
dt = (t[i] - t[i - 1]) / np.timedelta64(1, "s")
# ── gap: ends the trip, claims nothing about what happened ──
if dt > gap_s or dt < 0:
tid += 1
kind[pos[i]] = "gap"
in_candidate = False
trip_id[pos[i]] = tid
continue
slow = v[i] < stop_speed_ms
if slow and not in_candidate:
in_candidate, anchor = True, i
elif slow and in_candidate:
moved = np.hypot(x[i] - x[anchor], y[i] - y[anchor])
dwell = (t[i] - t[anchor]) / np.timedelta64(1, "s")
if moved > stop_radius_m:
# Drifted out of the radius: it was crawling, not stopping.
in_candidate, anchor = True, i
elif dwell >= min_dwell_s:
# Confirmed. The boundary is placed at the START of the
# dwell, so the arrival belongs to the trip that arrived.
tid += 1
kind[pos[anchor]] = "dwell"
trip_id[pos[anchor]:pos[i] + 1] = tid
in_candidate = False
else:
in_candidate = False
if trip_id[pos[i]] == 0:
trip_id[pos[i]] = tid
d["trip_id"] = trip_id
d["boundary_kind"] = kind
return d
Validation block
def validate_trips(d: pd.DataFrame, labelled_trip_count: int | None = None) -> dict:
"""Checks that catch over- and under-segmentation before they reach a report."""
trips = d.groupby("trip_id")
durations = trips["t"].agg(lambda s: (s.max() - s.min()).total_seconds())
n = len(durations)
# 1. No zero-length trips. These come from a boundary placed on a single fix.
assert (durations > 0).all(), f"{(durations == 0).sum()} zero-duration trips"
# 2. Very short trips are a symptom of over-segmentation, not of driving.
tiny = (durations < 60).mean()
assert tiny < 0.1, (
f"{tiny:.0%} of trips are under a minute — min_dwell_s is too low and "
"traffic signals are creating trips"
)
# 3. Gap boundaries must not be counted as dwells anywhere downstream.
gaps = (d["boundary_kind"] == "gap").sum()
dwells = (d["boundary_kind"] == "dwell").sum()
# 4. Against ground truth, if available. This is the only real calibration.
out = {"trips": n, "gap_boundaries": int(gaps), "dwell_boundaries": int(dwells),
"median_duration_s": float(durations.median())}
if labelled_trip_count:
err = abs(n - labelled_trip_count) / labelled_trip_count
assert err < 0.15, (
f"{n} trips against {labelled_trip_count} labelled ({err:.0%} off) "
"— tune min_dwell_s before shipping this"
)
out["count_error"] = err
return out
Common mistakes and gotchas
-
Emitting on the first slow fix. Without a candidate state, every red light becomes a trip and the streaming implementation has to retract them.
-
Treating a gap as a dwell. It adds dwell time that never happened, at a location the vehicle may have left immediately.
-
Placing the boundary at the end of the dwell. The arrival belongs to the trip that arrived; putting the cut at the dwell’s end attributes the whole stop to the next trip.
-
Using speed alone without a radius. A vehicle crawling in a queue is slow for ten minutes and moves 400 m. The radius test is what distinguishes that from a stop.
-
Shipping an uncalibrated threshold. The curve above is steep exactly where the default values sit. One labelled day is enough to place it.
-
Not carrying
boundary_kindforward. Once trips are aggregated, the distinction between a dwell and a gap is unrecoverable, and every dwell-time metric silently includes outages.
FAQ
What dwell threshold should I use?
Calibrate it, but expect 2–5 minutes for delivery and service fleets and 10–15 minutes for commuting analysis where only substantive activities count. The lower bound is set by traffic-signal duration: below about two minutes, junction queues start producing trips.
Should a gap always end a trip?
Above the threshold, yes, and the threshold should be well above the normal reporting interval — ten minutes on a 30-second feed. Below it, interpolate and keep the trip. The point at which “we do not know” becomes true is the point at which the vehicle could have completed a journey inside the gap.
How does this interact with stay-point detection?
They are the same physical event seen from two directions: stay-point detection finds where the vehicle dwelled, trip segmentation uses that to cut the track. Run them in that order, with the same thresholds, or the trip boundaries and the stop list will disagree — the ordering argument is in combining trajectory segmentation with stay-point detection.
Related
- Trajectory Segmentation — the parent reference and the criteria comparison.
- Time-Based vs Distance-Based Trajectory Segmentation — the granularity rules that subdivide within a trip.
- Combining Trajectory Segmentation with Stay-Point Detection — why the stage order matters.
- Stay-Point Detection Algorithms — the dwell detection this rule depends on.
- Building Origin-Destination Matrices from Trajectory Data — the consumer whose totals move directly with this threshold.