How to Split a Track into Trips and Stops in Python

Problem statement

A day of GPS logging is a single stream of points containing several journeys, several stops and at least one period where the receiver lost lock. Almost every question β€” how many trips, how far, how long, at what speed β€” requires that structure first.

The parameters do the work, and they are not obvious. A stationary receiver still moves: with a few metres of error and 3-second sampling, standing still generates roughly 40 m of phantom travel per minute. Ten minutes at a shop adds 400 m to a trip that never happened.

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, radius_m, stop_s)

    return stops, trips_between(df, stops)            # 3. trips are the rest

Speed thresholds do not work for stop detection, because GPS error makes a stationary receiver report several km/h β€” the 99th percentile of derived speed on a real walking dataset was 76 km/h.

Segmentation in three ordered steps: split at data gaps, detect stops by radius and duration, then take trips as the movement between stops.
Gaps are neither stops nor trips. Handling them first stops them being misclassified as either.

Step-by-step solution

1. Split at gaps

The traces measured here had a median interval of 3 s and a maximum of 2,989 s. Nothing sensible can be said about the fifty minutes in the middle of that gap.

df["leg"] = (df["dt"] > gap_s).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 walk down the street with the subject.

Code examples

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

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

The printed reduction is the phantom distance the stationary periods were contributing. On a dataset with long stops it can be a large share of the total.

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

If the trip 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.

Explanation

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 counts varying across a grid of stop radii and durations, showing the count is threshold-dependent.
There is no ground truth for "a stop". Report the sweep, not the middle cell.

Edge cases or notes

  • 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 several km/h. On a real walking dataset the 99th-percentile derived speed was 76 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: about 90 s excludes traffic lights, 15 minutes finds destinations.

Why is my trip distance too long?

Stationary periods contribute phantom displacement β€” roughly 40 m per minute at a few metres of error. 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. Run a sensitivity sweep and report a range if the count moves substantially.

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.