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.

A stationary GPS cloud staying within a 30 metre radius for several minutes, detected as a stop, against a brief traffic-light pause that does not meet the duration threshold. a stop is time spent inside a circlestationary, 8 minutesa stopinside 30 mtraffic lights, 40 sa pause, by this thresholdinside 30 m, too briefSpeed thresholds fail because a stationary receiver reports several km/h spatialworkflow.io
A stop is defined by not leaving a circle for long enough. Instantaneous speed cannot express that.

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.

A day of GPS logging decomposed into legs split at gaps, stops detected by radius and duration, and trips as the movement in between. gaps first, then stops, then trips as what is leftsplit at data gapsneither stop nor tripdetect stops perlegradius + durationtrips are theremainderfilter fragmentscollapse stopsbefore summing distanceA gap is unobserved; classifying it as either invents something spatialworkflow.io
Gaps first, then stops, then trips as what is left. The order matters because a gap is not a stop.

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.

Stops found in 46 Brighton and Hove GPS traces for stop radii of 15, 30 and 50 m and minimum durations of 60, 180 and 600 s. stops found, by radius and minimum durationstop 60 sstop 180 sstop 600 sradius 15 m124 stops16 stops0 stopsradius 30 m234 stops34 stops1 stopradius 50 m345 stops60 stops3 stopsOne file, nine definitions of a stop: none to 345 spatialworkflow.io
Measured on 55,316 points: the stop count runs from none to 345 across settings that all sound reasonable.

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.

A stationary receiver generating tens of metres of phantom travel per minute from position jitter alone. phantom distance at the measured 18 m a minute1 minute stationary18 m5 minutes90 m10 minutes180 m0100 m200 mDistance is a sum of absolute values, so jitter accumulates with one sign spatialworkflow.io
Collapsing stops before summing is a correctness fix, not a refinement.

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.

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.