Stops, Trips and Segmentation Explained

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 receiver standing still 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 defined by not leaving a circle for long enough. Instantaneous speed cannot express that.

Step-by-step solution

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 13 real traces the interval distribution had a median of 3 s and a maximum of 2,989 s. That 50-minute gap 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 β€” with 5 m of error and 3-second sampling, roughly 2 m per fix, or 40 m per minute of standing still.

Ten minutes stationary adds about 400 m of phantom travel to the trip distance. 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 left. The order matters because a gap is not a stop.

Code examples

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

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}")

If the trip count doubles across a plausible range of thresholds, 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

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

Explanation

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 speeds of several km/h, because position error divided by a short interval is a large number β€” the measured 99th-percentile derived speed on a walking dataset was 76 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 rare (0.05% of intervals) but one was nearly an hour long.

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. With a few metres of jitter at a few seconds' interval, that is tens of metres per 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 accumulating about 40 metres of phantom distance per minute from jitter.
Collapsing stops before summing is a correctness fix, not a refinement.

Edge cases or notes

  • 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 several 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: 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.