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.

Segmentation in three ordered steps: split at data gaps, detect stops by radius and duration, then take trips as the movement between stops. gaps, then stops, then tripssplit at gaps > 5 minlegsdetect stops within legs30 m for 3 mintrips are what is leftfilter fragmentsDetecting stops across a gap invents a destination spatialworkflow.io
Gaps are neither stops nor trips. Handling them first stops them being misclassified as either.

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.

A stop circle anchored on the first point against one anchored on a running mean, which drifts along with a slow walk. anchoring on the running mean lets the circle walkanchored on the first pointleaves the circlestop ends correctlyanchored on the meana two-hour "stop"circle followsAnchoring on the first point cannot follow a slow walker down the street spatialworkflow.io
Anchoring on the running mean lets the circle walk down the street with the subject.

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.

Trip and stop counts moving with the radius and duration thresholds, so the count is not a robust finding. trips found in 46 real traces, by thresholdstop 60 sstop 180 sstop 600 sradius 15 m125 trips67 trips60 tripsradius 30 m140 trips76 trips60 tripsradius 50 m119 trips91 trips61 tripsNine plausible settings, 60 to 140 trips: report the range spatialworkflow.io
Measured on 55,316 points over Brighton and Hove. There is no ground truth for "a stop" — report the sweep, not the middle cell.

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.

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.