How to Split a Track into Trips and Stops in Python
On this page
Problem statement
Fifty-five OpenStreetMap GPS traces over Brighton and Hove, logged every second, arrive as unbroken streams of points — one of them with a seventeen-hour hole inside a single GPX segment. Cutting them into trips is three passes in a fixed order: split wherever the clock jumps by more than five minutes, mark a stop wherever the receiver stays within 30 m for three minutes, and keep the movement between stops as trips, each with its own length, duration and speed.
The parameters do the work, and they are not obvious. A stationary receiver still moves: on 46 real OpenStreetMap traces over Brighton and Hove, logged every second, the 159 minutes spent inside 34 detected stops still added 18 m of apparent travel per minute (Example 2). Ten minutes at a shop adds about 180 m to a trip that never happened — and the same traces give anywhere from 60 to 140 trips depending on two thresholds (Example 3).
Quick answer
Gaps first, then stops by radius and duration, then trips as what is left:
import numpy as np
def segment(df, gap_s=300, radius_m=30.0, stop_s=180):
df = df.sort_values("time").reset_index(drop=True)
df["dt"] = df["time"].diff().dt.total_seconds()
df["leg"] = (df["dt"] > gap_s).cumsum() # 1. split at gaps
stops = []
for _, leg in df.groupby("leg"): # 2. stops within legs
stops += detect_stops(leg.reset_index(drop=True), radius_m, stop_s)
return df, stops # 3. trips are the movement between
Speed thresholds do not work for stop detection, because GPS error makes a stationary receiver report movement: inside the 34 stops found on these traces, derived speed had a median of 0.2 km/h and a 99th percentile of 8.3 km/h — faster than walking pace, from a receiver that never left a 30 m circle.
What you get — from The data used on this page
60,000 points downloaded: 0 duplicates within a track, 2 anywhere
46 tracks, 55,316 points
Split at gaps, stops by radius and time, trips between
1. Split at gaps
The traces measured here had a median interval of 1 s and a longest gap of 61,691 s — seventeen hours inside one GPX segment. Nothing sensible can be said about what happened in the middle of that gap.
df["leg"] = (df["dt"] > 300).groupby(df["track_id"]).cumsum()
Use a gap threshold well above the normal interval — five minutes is a common choice when logging every few seconds.
2. Detect stops as time inside a radius
def detect_stops(leg, radius_m=30.0, min_duration_s=180):
x, y = leg["x"].values, leg["y"].values
t = leg["time"].values.astype("datetime64[s]").astype(np.int64)
stops, i, n = [], 0, len(leg)
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": i, "end": 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
The anchor is the first point of the candidate stop, so the circle does not drift with a slowly moving object. That matters: anchoring on the running mean lets a slow walk stay "stopped" indefinitely.
3. Take trips as the movement between stops
Then filter out the fragments — a "trip" of a few points over tens of metres is jitter between two halves of one stop, usually created by a radius that is slightly too small.
MIN_TRIP_M, MIN_TRIP_S = 100, 60
4. Collapse stops before measuring distance
This is the step that changes the numbers. Each stop becomes one point at its centroid, and the phantom travel disappears.
5. Run a sensitivity sweep and report a range
There is no ground truth for "a stop". If the trip count doubles across a plausible range of radii and durations, report a range rather than a single number.
Trips, stops and parameters returned together
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 — segmentation returning trips, stops and parameters
import numpy as np
import pandas as pd
def segment_tracks(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."""
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)
stops, trips = [], []
for leg_id, leg in df.groupby("leg_id"):
leg = leg.reset_index(drop=True)
found = detect_stops(leg, radius_m, stop_s)
for s in found:
s["leg_id"] = leg_id
s["start_time"] = leg["time"].iloc[s["start"]]
stops.extend(found)
cursor = 0
for boundary in [(s["start"], s["end"]) for s in found] + \
[(len(leg), len(leg))]:
start, end = boundary
if start > cursor:
seg = leg.iloc[cursor:start]
if len(seg) > 1:
length = float(np.hypot(seg["x"].diff(),
seg["y"].diff()).sum())
duration = float((seg["time"].iloc[-1] -
seg["time"].iloc[0]).total_seconds())
if length >= min_trip_m and duration >= min_trip_s:
trips.append({
"leg_id": leg_id, "points": len(seg),
"start_time": seg["time"].iloc[0],
"length_m": length, "duration_s": duration,
"mean_speed_ms": length / max(duration, 1),
})
cursor = end
params = dict(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(stops)} stops, "
f"{len(trips)} trips")
if trips:
t = pd.DataFrame(trips)
print(f" trip length: median {t['length_m'].median():.0f} m, "
f"total {t['length_m'].sum() / 1000:.2f} km")
return pd.DataFrame(trips), pd.DataFrame(stops), params
print(f" median interval {df['dt'].median():.0f} s, longest gap {df['dt'].max():,.0f} s")
trips, stops, params = segment_tracks(df.drop(columns="dt"))
median interval 1 s, longest gap 61,691 s
62 legs, 34 stops, 76 trips
trip length: median 925 m, total 148.78 km
Example 2 — collapsing stops so distance is honest
import numpy as np
import pandas as pd
def collapse_stops(leg, stops):
"""One point per stop, at its centroid, keeping the duration."""
keep = np.ones(len(leg), bool)
replacements = []
for s in stops:
keep[s["start"]:s["end"] + 1] = False
replacements.append({"time": leg["time"].iloc[s["start"]],
"x": s["x"], "y": s["y"],
"stop_duration_s": s["duration_s"]})
out = pd.concat([leg[keep], pd.DataFrame(replacements)]) \
.sort_values("time").reset_index(drop=True)
before = float(np.hypot(leg["x"].diff(), leg["y"].diff()).sum())
after = float(np.hypot(out["x"].diff(), out["y"].diff()).sum())
print(f" {before / 1000:.3f} km -> {after / 1000:.3f} km "
f"({after / before - 1:+.1%}) after collapsing {len(stops)} stops")
return out
Run it on the one leg with the most stops, then measure what every stationary period contributes:
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.reset_index(drop=True)))
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 = 0.0, 0.0, []
for leg, leg_stops in found.values():
for s in leg_stops:
part = leg.iloc[s["start"]:s["end"] + 1]
step = np.hypot(part["x"].diff(), part["y"].diff())
dt = part["time"].diff().dt.total_seconds().replace(0, np.nan)
metres += step.sum()
minutes += s["duration_s"] / 60
speeds.append(step / dt)
speeds = pd.concat(speeds).dropna() * 3.6
print(f" {sum(len(v[1]) for v in found.values())} stops, {minutes:.0f} minutes stationary: "
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")
3.748 km -> 3.459 km (-7.7%) after collapsing 6 stops
34 stops, 159 minutes stationary: 18 m of apparent travel per minute
derived speed while stopped: median 0.2 km/h, p99 8.3 km/h
The printed reduction is the phantom distance the stationary periods were contributing: on the leg with the most stops here, six stops were 7.7% of its length.
Example 3 — the sensitivity sweep that should accompany any result
import itertools
def sensitivity(df, radii=(15, 30, 50), durations=(60, 180, 600)):
"""Does the answer survive plausible parameter changes?"""
print(f" {'radius':>7} {'stop_s':>7} {'trips':>7} {'stops':>7} {'km':>9}")
rows = []
for radius, duration in itertools.product(radii, durations):
trips, stops, _ = segment_tracks(df, radius_m=radius, stop_s=duration)
km = trips["length_m"].sum() / 1000 if len(trips) else 0.0
rows.append({"radius": radius, "stop_s": duration,
"trips": len(trips), "stops": len(stops), "km": km})
print(f" {radius:7.0f} {duration:7d} {len(trips):7d} "
f"{len(stops):7d} {km:9.2f}")
counts = [r["trips"] for r in rows]
print(f" trip count ranges {min(counts)} to {max(counts)} "
f"across these parameters")
return rows
sweep = sensitivity(df.drop(columns="dt"))
radius stop_s trips stops km
62 legs, 124 stops, 125 trips
trip length: median 451 m, total 146.64 km
15 60 125 124 146.64
62 legs, 16 stops, 67 trips
trip length: median 1507 m, total 151.54 km
15 180 67 16 151.54
62 legs, 0 stops, 60 trips
trip length: median 1753 m, total 152.43 km
15 600 60 0 152.43
62 legs, 234 stops, 140 trips
trip length: median 362 m, total 133.80 km
30 60 140 234 133.80
62 legs, 34 stops, 76 trips
trip length: median 925 m, total 148.78 km
30 180 76 34 148.78
62 legs, 1 stops, 60 trips
trip length: median 1753 m, total 152.16 km
30 600 60 1 152.16
62 legs, 345 stops, 119 trips
trip length: median 368 m, total 118.39 km
50 60 119 345 118.39
62 legs, 60 stops, 91 trips
trip length: median 758 m, total 143.59 km
50 180 91 60 143.59
62 legs, 3 stops, 61 trips
trip length: median 1685 m, total 151.76 km
50 600 61 3 151.76
trip count ranges 60 to 140 across these parameters
Here it does: 60 to 140 trips across nine plausible settings, and total trip length from 118.39 to 152.43 km. When the count varies by a factor of two, the honest headline is "between N and M trips", with the parameters stated. Reporting the middle value as if it were measured is the common alternative and it is not defensible.
Radius, duration and the anchor point
Why stops are defined by radius and duration
A stop is "did not go anywhere for a while". Both halves need expressing.
Radius handles the fact that a stationary receiver moves: it jitters inside its error circle, and as long as the circle is bigger than the error, the point has not left it. Duration handles the fact that briefly stationary is not a stop.
An instantaneous speed threshold expresses neither. It flags every noisy fix during genuine movement and misses every slow-moving stretch.
Why anchoring matters
Anchoring the circle on the first candidate point means the stop ends as soon as the object is more than radius from where it started.
Anchoring on a running mean lets the circle drift: a slow walker stays inside a circle that keeps re-centring on them, and the algorithm reports a two-hour "stop" spanning half a mile. Anchoring on the first point cannot do that.
Why gaps must not become stops
If the receiver is off for fifty minutes and resumes nearby, a naive detector sees two fixes close together with a long time between them and calls it a stop.
Sometimes that is right — the phone was in a pocket at a desk. Often it is wrong — the phone was off during a journey and happened to resume near where it stopped. The data contains nothing that distinguishes them, which is exactly why a gap deserves its own label.
Why the parameters are the finding
Two segmentations of one file with different thresholds are different datasets, and nothing in the trip rows records which is which.
This puts segmentation parameters in the same class as a classification threshold or a cloud-mask class list: they must be reported with the results, and the results should come with a sensitivity sweep. A trip count quoted without its parameters is not reproducible.
Anchoring, fragment trips and per-leg detection
- Split at gaps first. A gap is neither a stop nor a trip.
- Anchor the stop circle on the first point, not on a running mean.
- The radius must exceed the position error, or stops fragment.
- Filter out fragment trips on both length and duration.
- Collapse stops before summing distance, or phantom travel inflates everything.
- Detect stops per leg, never across a gap.
- Indoor stop centroids drift by tens of metres; do not over-interpret their position.
- Report the parameters and a sensitivity sweep.
Internal links
- Stops, trips and segmentation explained — the concepts and why speed thresholds fail
- GPS error explained: why your track wanders — the jitter the radius has to absorb
- Sampling rate and gaps explained — choosing the gap threshold
- 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 stops into places
- How to calculate speed, distance and direction along a track — the columns segmentation uses
- How to resample and interpolate a track to a fixed interval — after segmenting, not before
FAQ
How do I split a GPS track into trips?
Split at long data gaps, detect stops as periods spent inside a small radius for a minimum duration, and take trips as the movement between stops — then filter out fragments.
Why not use a speed threshold to find stops?
Because GPS error makes a stationary receiver report movement. Inside the stops detected on 46 real traces, the 99th-percentile derived speed was 8.3 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: a few minutes excludes most traffic-light and junction waits, 15 minutes finds destinations. On the traces here, going from 60 s to 600 s at a 30 m radius cut the stops from 234 to 1.
Why is my trip distance too long?
Stationary periods contribute phantom displacement — 18 m per minute on real traces logged every second. Collapse each stop to one point before summing.
Should a data gap be a stop?
No. Label it separately. Nothing in the data distinguishes "parked" from "receiver off during a journey".
How many trips should I expect?
That depends on the thresholds. On 46 real traces nine plausible settings gave 60 to 140 trips, so run a sensitivity sweep and report a range.
Why do I get lots of very short trips?
The stop radius is slightly too small, so one stop fragments into two with a jitter "trip" between them. Increase the radius, and filter trips on minimum length and duration.