Stops, Trips and Segmentation Explained
On this page
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 stationary receiver 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.
Gaps first, a radius from the error, a duration from the question
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 46 real OpenStreetMap traces over Brighton and Hove the median interval was 1 s and the longest 17.1 hours. Gaps over five minutes were only 0.03% of the intervals, and 51% of the elapsed time (Example 1). What happened inside them 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 β on the real traces here, 0.50 m per fix and 18 m of apparent travel per minute, across 159 minutes spent inside 34 stops (Example 3).
Ten minutes stationary adds about 180 m of phantom travel to the trip distance; on the leg with the most stops, collapsing its six stops removed 7.7% of its length. Collapsing each stop to a single point removes it.
Segmentation with a sensitivity sweep
The data used on this page
Public GPS traces from the OpenStreetMap trackpoints API for a box over central Brighton and Hove, β0.20 to β0.10Β° E, 50.81 to 50.86Β° N: the first 12 pages of 5,000 points. The setup loads them the way How to load GPS tracks in Python does β one track per <trkseg>, exact duplicates dropped, elements with more than 10% repeated timestamps set aside, tracks under ten points dropped β and projects them to UTM zone 30N, adding x, y and the interval dt in seconds. The API serves the newest uploads first, so the setup reads the pages as saved on 9 October 2026. Nothing on this page prints a location or a username.
import glob
import io
import os
import xml.etree.ElementTree as ET
import zipfile
import geopandas as gpd
import numpy as np
import pandas as pd
import requests
# The OSM trackpoints API for this box, pages 0-11, as saved on 9 October 2026. The API serves the
# newest uploads first, so a live download changes whenever someone uploads a trace here. Live:
# requests.get("https://api.openstreetmap.org/api/0.6/trackpoints",
# params={"bbox": "-0.20,50.81,-0.10,50.86", "page": page})
if not os.path.exists("traces/page_11.gpx"):
reply = requests.get("https://data.spatialworkflow.io/osm-gps-traces-brighton-2026-10-09-v2.zip",
timeout=120)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("traces")
NS = {"g": "http://www.topografix.com/GPX/1/0"}
rows = []
for n, path in enumerate(sorted(glob.glob("traces/*.gpx"))):
for t, trk in enumerate(ET.parse(path).getroot().findall("g:trk", NS)):
for s, seg in enumerate(trk.findall("g:trkseg", NS)):
rows += [(f"p{n}:{t}-{s}", float(p.get("lat")), float(p.get("lon")),
p.findtext("g:time", namespaces=NS)) for p in seg.findall("g:trkpt", NS)]
raw = pd.DataFrame(rows, columns=["track_id", "lat", "lon", "time"]).dropna()
raw["time"] = pd.to_datetime(raw["time"], utc=True, format="ISO8601")
within = raw.duplicated(["track_id", "time", "lat", "lon"]).sum()
anywhere = raw.duplicated(["time", "lat", "lon"]).sum()
print(f"{len(raw):,} points downloaded: {within:,} duplicates within a track, {anywhere:,} anywhere")
raw = raw.drop_duplicates(["time", "lat", "lon"]).sort_values(["track_id", "time"], kind="stable")
zero = raw.groupby("track_id")["time"].apply(lambda s: (s.diff().dt.total_seconds() == 0).mean())
size = raw.groupby("track_id").size()
keep = zero.index[(zero <= 0.1) & (size >= 10)]
df = gpd.GeoDataFrame(raw[raw.track_id.isin(keep)], crs=4326,
geometry=gpd.points_from_xy(raw.lon, raw.lat)[raw.track_id.isin(keep).to_numpy()])
df = df.to_crs(32630).reset_index(drop=True)
df["x"], df["y"] = df.geometry.x, df.geometry.y
df["dt"] = df.groupby("track_id")["time"].diff().dt.total_seconds()
print(f"{df.track_id.nunique()} tracks, {len(df):,} points")
60,000 points downloaded: 0 duplicates within a track, 2 anywhere
46 tracks, 55,316 points
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
trips, stops, params = segment(df.drop(columns="dt"))
gaps = df["dt"] > params["gap_s"]
elapsed = df.groupby("track_id")["time"].agg(lambda s: (s.max() - s.min()).total_seconds()).sum()
print(f" gaps over 5 min: {gaps.mean():.2%} of intervals, {df.loc[gaps, 'dt'].sum() / elapsed:.0%} "
f"of the elapsed time, the longest {df['dt'].max() / 3600:.1f} h")
62 legs, 34 stops, 76 trips
parameters: {'gap_s': 300, 'radius_m': 30.0, 'stop_s': 180, 'min_trip_m': 100, 'min_trip_s': 60}
gaps over 5 min: 0.03% of intervals, 51% of the elapsed time, the longest 17.1 h
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}")
segmentation_sensitivity(df.drop(columns="dt"))
radius stop_s trips stops km
62 legs, 124 stops, 125 trips
parameters: {'gap_s': 300, 'radius_m': 15, 'stop_s': 60, 'min_trip_m': 100, 'min_trip_s': 60}
15 60 125 124 146.64
62 legs, 16 stops, 67 trips
parameters: {'gap_s': 300, 'radius_m': 15, 'stop_s': 180, 'min_trip_m': 100, 'min_trip_s': 60}
15 180 67 16 151.54
62 legs, 0 stops, 60 trips
parameters: {'gap_s': 300, 'radius_m': 15, 'stop_s': 600, 'min_trip_m': 100, 'min_trip_s': 60}
15 600 60 0 152.43
62 legs, 234 stops, 140 trips
parameters: {'gap_s': 300, 'radius_m': 30, 'stop_s': 60, 'min_trip_m': 100, 'min_trip_s': 60}
30 60 140 234 133.80
62 legs, 34 stops, 76 trips
parameters: {'gap_s': 300, 'radius_m': 30, 'stop_s': 180, 'min_trip_m': 100, 'min_trip_s': 60}
30 180 76 34 148.78
62 legs, 1 stops, 60 trips
parameters: {'gap_s': 300, 'radius_m': 30, 'stop_s': 600, 'min_trip_m': 100, 'min_trip_s': 60}
30 600 60 1 152.16
62 legs, 345 stops, 119 trips
parameters: {'gap_s': 300, 'radius_m': 50, 'stop_s': 60, 'min_trip_m': 100, 'min_trip_s': 60}
50 60 119 345 118.39
62 legs, 60 stops, 91 trips
parameters: {'gap_s': 300, 'radius_m': 50, 'stop_s': 180, 'min_trip_m': 100, 'min_trip_s': 60}
50 180 91 60 143.59
62 legs, 3 stops, 61 trips
parameters: {'gap_s': 300, 'radius_m': 50, 'stop_s': 600, 'min_trip_m': 100, 'min_trip_s': 60}
50 600 61 3 151.76
On these traces it more than doubles β 60 to 140 trips across the nine settings β so 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
Run it on the leg with the most stops, then measure what standing still contributes across every stop:
legs = df.drop(columns="dt").sort_values(["track_id", "time"]).reset_index(drop=True)
gap = legs.groupby("track_id")["time"].diff().dt.total_seconds() > 300
legs["leg"] = gap.groupby(legs["track_id"]).cumsum()
found = {key: (leg.reset_index(drop=True), detect_stops(leg))
for key, leg in legs.groupby(["track_id", "leg"])}
busiest = max(found, key=lambda k: len(found[k][1]))
collapse_stops(*found[busiest])
metres, minutes, speeds, fixes = 0.0, 0.0, [], 0
for leg, leg_stops in found.values():
for s in leg_stops:
part = leg.iloc[s["start_idx"]:s["end_idx"] + 1]
step = np.hypot(part["x"].diff(), part["y"].diff())
metres += step.sum()
minutes += s["duration_s"] / 60
fixes += len(part) - 1
speeds.append(step / part["time"].diff().dt.total_seconds().replace(0, np.nan))
speeds = pd.concat(speeds).dropna() * 3.6
print(f" stationary: {minutes:.0f} minutes, {metres / fixes:.2f} m per fix, "
f"{metres / minutes:.0f} m of apparent travel per minute")
print(f" derived speed while stopped: median {speeds.median():.1f} km/h, "
f"p99 {speeds.quantile(0.99):.1f} km/h, "
f"{(speeds > 1.8).mean():.0%} of fixes above 0.5 m/s")
distance 3.75 km -> 3.46 km (-7.7%) after collapsing 6 stops
stationary: 159 minutes, 0.50 m per fix, 18 m of apparent travel per minute
derived speed while stopped: median 0.2 km/h, p99 8.3 km/h, 26% of fixes above 0.5 m/s
The printed reduction is the phantom distance the stops were contributing.
Why speed thresholds fail to find stops
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 movement, because position error divided by a short interval is not zero. Inside the 34 stops detected here, a quarter of the fixes were above 0.5 m/s and the 99th-percentile derived speed was 8.3 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 0.03% of the intervals but 51% of the elapsed time, and the longest ran 17.1 hours.
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. On the real traces here that came to 18 m for every 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.
Traffic lights, long stops and indoor drift
- 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 movement: a quarter of the fixes inside real stops were above 0.5 m/s.
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.