Stops, Trips and Segmentation Explained
Problem statement
A day of GPS logging is not a journey. It is a sequence of trips separated by stops, and almost every useful question β how many journeys, how far, how long, by what mode β needs that structure recovered before it can be answered.
Segmentation is where the analysis actually happens, and it is entirely parameter-driven. Two thresholds decide everything:
- How slow is stopped? A receiver standing still still moves, because error is movement to the algorithm.
- How long is a stop? Thirty seconds at traffic lights is not a destination; twenty minutes at a shop is.
Get either wrong and the same file yields three trips or three hundred.
Quick answer
Segment on a duration inside a radius, not on instantaneous speed:
import numpy as np
def detect_stops(df, radius_m=30.0, min_duration_s=180):
"""A stop is time spent inside a small circle, not a low speed reading."""
df = df.sort_values("time").reset_index(drop=True)
x, y = df["x"].values, df["y"].values
t = df["time"].values.astype("datetime64[s]").astype(np.int64)
stops, i, n = [], 0, len(df)
while i < n:
j = i
while j + 1 < n and np.hypot(x[j + 1] - x[i], y[j + 1] - y[i]) <= radius_m:
j += 1
if t[j] - t[i] >= min_duration_s:
stops.append({"start_idx": i, "end_idx": j,
"duration_s": int(t[j] - t[i]),
"x": float(x[i:j + 1].mean()),
"y": float(y[i:j + 1].mean())})
i = j + 1
else:
i += 1
return stops
Speed thresholds fail because GPS error makes a stationary receiver report several km/h. Duration inside a radius does not care about the jitter β the point simply has not left the circle.
Step-by-step solution
1. Split at data gaps before anything else
A gap is not a stop. The receiver was off, and what happened in between is unknown.
Across 13 real traces the interval distribution had a median of 3 s and a maximum of 2,989 s. That 50-minute gap could be a stop, a drive, or the phone in a bag on a bus.
df["leg"] = (df["dt"] > 300).groupby(df["track_id"]).cumsum()
Split first, then look for stops within each leg. A gap gets its own label β "unobserved" β rather than being classified as either.
2. Choose the radius from the position error, not from the geography
The radius has to be larger than the noise, or a stationary receiver escapes the circle and the stop is missed. With typical consumer error of a few metres, 20β50 m is the usual range.
Too large and separate nearby destinations merge β two shops on one street become one stop. Too small and every stop fragments into several.
3. Choose the duration from the question
- Traffic lights: 30β90 s. Usually noise for a trip analysis, and signal for a congestion analysis.
- A delivery or an errand: 2β10 minutes.
- A destination: 15+ minutes.
- Home or work: hours.
There is no universal answer, which is why the parameter belongs in the output metadata.
4. Build trips as what is left between stops
trips = []
previous_end = 0
for stop in stops:
if stop["start_idx"] > previous_end:
trips.append((previous_end, stop["start_idx"]))
previous_end = stop["end_idx"]
Then filter: a "trip" of three points over 40 m is noise between two halves of one stop, not a journey.
5. Remove the stops before measuring distance
This is the step that changes numbers. A stationary receiver generates a continuous stream of small displacements β with 5 m of error and 3-second sampling, roughly 2 m per fix, or 40 m per minute of standing still.
Ten minutes stationary adds about 400 m of phantom travel to the trip distance. Collapsing each stop to a single point removes it.
Code examples
Example 1 β full segmentation with the parameters recorded
import numpy as np
import pandas as pd
def segment(df, id_col="track_id", gap_s=300, radius_m=30.0,
stop_s=180, min_trip_m=100, min_trip_s=60):
"""Legs, stops and trips, with every threshold returned alongside."""
df = df.sort_values([id_col, "time"]).reset_index(drop=True).copy()
g = df.groupby(id_col)
df["dt"] = g["time"].diff().dt.total_seconds()
df["leg"] = (df["dt"] > gap_s).groupby(df[id_col]).cumsum()
df["leg_id"] = df[id_col].astype(str) + "-" + df["leg"].astype(str)
all_stops, all_trips = [], []
for leg_id, leg in df.groupby("leg_id"):
stops = detect_stops(leg, radius_m=radius_m, min_duration_s=stop_s)
for s in stops:
s["leg_id"] = leg_id
all_stops.extend(stops)
cursor = 0
boundaries = [(s["start_idx"], s["end_idx"]) for s in stops] + \
[(len(leg), len(leg))]
for start, end in boundaries:
if start > cursor:
seg = leg.iloc[cursor:start]
length = np.hypot(seg["x"].diff(), seg["y"].diff()).sum()
duration = (seg["time"].iloc[-1] - seg["time"].iloc[0]) \
.total_seconds() if len(seg) > 1 else 0
if length >= min_trip_m and duration >= min_trip_s:
all_trips.append({"leg_id": leg_id, "points": len(seg),
"length_m": float(length),
"duration_s": float(duration),
"mean_speed_ms": float(length / max(duration, 1))})
cursor = end
params = {"gap_s": gap_s, "radius_m": radius_m, "stop_s": stop_s,
"min_trip_m": min_trip_m, "min_trip_s": min_trip_s}
print(f" {df['leg_id'].nunique()} legs, {len(all_stops)} stops, "
f"{len(all_trips)} trips")
print(f" parameters: {params}")
return pd.DataFrame(all_trips), pd.DataFrame(all_stops), params
Returning params is not tidiness. Two segmentations of one file with different thresholds are different datasets, and nothing in the output rows says which is which.
Example 2 β a sensitivity sweep, which is the real deliverable
import itertools
def segmentation_sensitivity(df, radii=(15, 30, 50), durations=(60, 180, 600)):
"""How much do the thresholds change the answer?"""
print(f" {'radius':>7} {'stop_s':>7} {'trips':>7} {'stops':>7} {'km':>9}")
for radius, duration in itertools.product(radii, durations):
trips, stops, _ = segment(df, radius_m=radius, stop_s=duration)
km = trips["length_m"].sum() / 1000 if len(trips) else 0.0
print(f" {radius:7.0f} {duration:7d} {len(trips):7d} "
f"{len(stops):7d} {km:9.2f}")
If the trip count doubles across a plausible range of thresholds, the trip count is not a robust finding and should not be reported as one. Report a range, or report the metric that is stable.
Example 3 β collapsing stops so distance is honest
import numpy as np
import pandas as pd
def collapse_stops(df, stops):
"""Replace each stop with one point at its centroid."""
keep = np.ones(len(df), bool)
replacements = []
for s in stops:
keep[s["start_idx"]:s["end_idx"] + 1] = False
replacements.append({
"time": df["time"].iloc[s["start_idx"]],
"x": s["x"], "y": s["y"],
"stop_duration_s": s["duration_s"],
})
out = pd.concat([df[keep], pd.DataFrame(replacements)]) \
.sort_values("time").reset_index(drop=True)
before = np.hypot(df["x"].diff(), df["y"].diff()).sum()
after = np.hypot(out["x"].diff(), out["y"].diff()).sum()
print(f" distance {before / 1000:.2f} km -> {after / 1000:.2f} km "
f"({after / before - 1:+.1%}) after collapsing {len(stops)} stops")
return out
The printed reduction is the phantom distance the stops were contributing. On a dataset with long stationary periods it can be a large fraction of the total.
Explanation
Why speed thresholds fail
The intuitive rule is "stopped means speed below 0.5 m/s". It fails in both directions.
A stationary receiver reports speeds of several km/h, because position error divided by a short interval is a large number β the measured 99th-percentile derived speed on a walking dataset was 76 km/h. So a stationary period contains plenty of fixes above any low threshold.
And a slow-moving one β a pedestrian in a crowd, a vehicle in traffic β sits below the threshold for minutes without stopping.
Duration inside a radius sidesteps both. The receiver jitters within its error circle, and the circle is what the algorithm watches.
Why gaps must be handled first
A gap has no observations, so no stop detector can classify it. Treating a gap as a stop invents a destination; treating it as travel invents a straight-line journey at an average speed.
The only honest option is a third category. Any downstream count of trips or stops should say how much of the elapsed time was unobserved β on the traces measured here, gaps over 5 minutes were rare (0.05% of intervals) but one was nearly an hour long.
Why stops inflate distance
Every fix during a stop contributes a small displacement, and they all add up with the same sign, because distance is a sum of absolute values. With a few metres of jitter at a few seconds' interval, that is tens of metres per minute of standing still.
Collapsing each stop to one point removes it exactly. The alternative β filtering out low speeds β does not, because the same error that creates the phantom distance also pushes the speeds above the filter.
Why the parameters are the analysis
There is no ground truth for "a stop". The thresholds encode a definition, and the definition is a modelling choice about the question being asked.
That makes segmentation parameters the same kind of object as a classification threshold or a cloud-mask class list: they must be stated with the results, and results should be reported with a sensitivity sweep rather than a single number.
Edge cases or notes
- Split at gaps before detecting stops. A gap is neither a stop nor a trip.
- The radius must exceed the position error, or stationary jitter escapes the circle.
- Detect stops per leg, not across a gap.
- Traffic lights are stops by most definitions. Set the duration to exclude them if that is what you mean.
- Collapse stops before summing distance, or phantom travel inflates every trip.
- A very long stop may be the device being off while still logging its last position.
- Indoor stops drift badly. A stop centroid inside a building can be tens of metres out.
- Record every threshold with the output. Two segmentations are two datasets.
Internal links
- Trajectories explained: what makes a track different from points β the identity problem underneath
- GPS error explained: why your track wanders β why speed thresholds fail
- How to split a track into trips and stops in Python β the implementation
- Sampling rate and gaps explained β handling the gaps first
- How to clean a GPS track: outliers, duplicates and jumps β cleaning before segmenting
- How to aggregate movement into flows between zones β what trips feed into
- How to cluster points by location with DBSCAN in Python β clustering stop locations into places
- Spatial clustering explained: DBSCAN, K-Means and what they assume β the parameters problem in general
FAQ
How do I detect stops in a GPS track?
Find stretches where the receiver stays within a small radius for at least a minimum duration. Do not use a speed threshold β GPS error makes a stationary receiver report several km/h.
What radius and duration should I use?
20β50 m for the radius, larger than the position error. The duration depends on the question: 30β90 s catches traffic lights, 15 minutes catches destinations.
Why does my trip distance seem too long?
Because stationary periods contribute phantom displacement. Collapse each stop to a single point before summing.
Is a data gap a stop?
No. It is unobserved. Split the track at gaps and label them separately rather than classifying them as stop or travel.
How many trips should a day of logging contain?
That depends entirely on your thresholds. Run a sensitivity sweep; if the count doubles across plausible parameters, report a range rather than a number.
Can I use DBSCAN to find stops?
For clustering stop locations into recurring places, yes. For detecting stops within one track, no β DBSCAN ignores the time order that defines a stop.
Should I remove short trips?
Usually. A "trip" of a few points over tens of metres is jitter between two halves of one stop. Filter on both minimum length and minimum duration.