Combining trajectory segmentation with stay-point detection

Running stay-point detection and trajectory segmentation as one pipeline turns a raw stream of GPS fixes into a clean, ordered sequence of trips and stops. The trick is ordering: detect the dwell clusters first, then cut the moving spans between them into segments. Because the stays act as fixed anchors, every trip is guaranteed to begin and end at a known location and no stop-edge point leaks into an adjacent trip’s distance.

Why this happens

A trajectory has no intrinsic structure — it is just timestamps and coordinates. Structure comes from two complementary questions: where did the device dwell and how do the moves between dwells break into trips. Answering them independently and then reconciling the two boundary sets is where most pipelines go wrong; you get off-by-one edges, double-counted points, and trips that start mid-stop.

The fix is to make one question depend on the other. Stay-point detection, covered in depth under stay-point detection algorithms and its DBSCAN implementation guide, gives you the exact index range of every dwell. Once those ranges are known, segmentation is almost free: the move-segments are simply the spans strictly between consecutive stays. This ordering is the core idea of the parent trajectory segmentation topic and of the broader movement pattern extraction discipline. It assumes the input has already been cleaned upstream — segmenting un-smoothed data invents stops that are not there.

Core pipeline

Stay-Point-First Segmentation Pipeline Four sequential stages connected by arrows: Project to a metric CRS, Detect Stay-Points, Cut Moves Between Stays, and Emit Trip and Stop Sequence. 1. Project to metric CRS 2. Detect stay-points 3. Cut moves between stays 4. Emit trip / stop seq
  1. Project to a metric CRS. Transform cleaned lat/lon to the UTM zone covering your data. Dwell radius, segment length, and speed are all metres from here on — never degrees.
  2. Detect stay-points. Run time-distance stay-point detection to obtain, for each dwell, the start and end row indices. Any method that returns index ranges works; the classic Li time-distance algorithm is used below.
  3. Cut moves between stays. Tag every stay run as a stop segment; the rows strictly between two consecutive stays become one move segment.
  4. Emit the trip/stop sequence. Assign a monotonic segment_id and a segment_kind, then validate that the segments tile the track with no gaps or overlaps.

Production-ready Python implementation

The function below runs the whole pipeline on a single cleaned device track. It detects stays with a vectorized time-distance sweep, then labels moves as the spans between them. All geometry is computed in a metric projected CRS (a UTM zone derived from the data centroid) — never on raw EPSG:4326 degrees. Every edge case flagged in the docstring is handled explicitly: an empty frame, a single point, and a track with no qualifying stays.

PYTHON
from __future__ import annotations

import numpy as np
import pandas as pd
from pyproj import Transformer


def segment_by_stay_points(
    df: pd.DataFrame,
    lat_col: str = "lat",
    lon_col: str = "lon",
    ts_col: str = "ts",
    stay_radius_m: float = 30.0,
    stay_min_s: float = 60.0,
) -> pd.DataFrame:
    """
    Detect stay-points, then cut a trajectory into ordered move/stop segments.

    The trajectory is split so that every 'stop' segment is a dwell cluster
    and every 'move' segment is the span strictly between two consecutive
    stops (or the track ends). Stays are the anchors; moves fall out between.

    Parameters
    ----------
    df : Single-device, cleaned trajectory with lat, lon (WGS84 degrees) and
         ts (UTC datetime64). Assumed already smoothed upstream.
    stay_radius_m : Dwell radius (metres). Points staying within this radius
                    for at least stay_min_s form a stay.
    stay_min_s : Minimum dwell duration (seconds) for a stay to qualify.

    Returns
    -------
    DataFrame sorted by ts with added columns:
      x_m, y_m      — projected coordinates (metres)
      segment_id    — monotonic integer id, one per sub-trajectory
      segment_kind  — 'move' or 'stop'

    Edge cases
    ----------
    empty frame  -> returned with the output columns but no rows
    single point -> one 'stop' segment
    no stays     -> the whole track is one 'move' segment
    """
    required = {lat_col, lon_col, ts_col}
    missing = required - set(df.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")

    out = df.copy()

    # ── Edge case: empty frame ────────────────────────────────────────────
    if out.empty:
        out["x_m"] = pd.Series(dtype="float64")
        out["y_m"] = pd.Series(dtype="float64")
        out["segment_id"] = pd.Series(dtype="int64")
        out["segment_kind"] = pd.Series(dtype="object")
        return out

    out[ts_col] = pd.to_datetime(out[ts_col], utc=True)
    out = out.sort_values(ts_col).reset_index(drop=True)

    # ── Step 1: project to a metric CRS (UTM from centroid) ───────────────
    # NEVER measure dwell radius in degrees — a degree is not a fixed length.
    c_lat = float(out[lat_col].mean())
    c_lon = float(out[lon_col].mean())
    utm_zone = int((c_lon + 180) // 6) + 1
    utm_epsg = (32600 if c_lat >= 0 else 32700) + utm_zone
    to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{utm_epsg}", always_xy=True)
    x, y = to_metric.transform(out[lon_col].to_numpy(), out[lat_col].to_numpy())
    out["x_m"], out["y_m"] = x, y

    # ── Edge case: single point ───────────────────────────────────────────
    if len(out) == 1:
        out["segment_id"] = 0
        out["segment_kind"] = "stop"
        return out

    ts_s = out[ts_col].astype("int64").to_numpy() / 1e9  # seconds

    # ── Step 2: time-distance stay-point detection ────────────────────────
    # Sweep an anchor point forward while later points stay within the radius;
    # if the dwell spans stay_min_s, record the [start, end] index range.
    n = len(out)
    stay_mask = np.zeros(n, dtype=bool)
    i = 0
    while i < n:
        j = i + 1
        while j < n:
            dist = np.hypot(x[j] - x[i], y[j] - y[i])
            if dist > stay_radius_m:
                break
            j += 1
        # points i .. j-1 are within the radius of anchor i
        dwell = ts_s[j - 1] - ts_s[i]
        if j - 1 > i and dwell >= stay_min_s:
            stay_mask[i:j] = True
            i = j          # jump past the confirmed stay
        else:
            i += 1         # anchor did not start a stay; advance one point

    # ── Step 3: cut moves between stays ───────────────────────────────────
    kind = np.where(stay_mask, "stop", "move")

    # ── Edge case: no stays -> whole track is a single move-segment ───────
    if not stay_mask.any():
        out["segment_id"] = 0
        out["segment_kind"] = "move"
        return out

    # ── Step 4: assign monotonic segment ids on kind transitions ──────────
    boundary = np.zeros(n, dtype=bool)
    boundary[1:] = kind[1:] != kind[:-1]
    out["segment_id"] = np.cumsum(boundary).astype(int)
    out["segment_kind"] = kind
    return out

Because stay membership is decided by an explicit index sweep, the move-segments are exactly the runs where stay_mask is False. There is no separate reconciliation step and therefore no opportunity for a stop’s first or last point to bleed into the neighbouring trip — the boundary is the transition in segment_kind, computed once.

Validation block

Run these checks immediately after segment_by_stay_points. They target the three ways a stay-first pipeline typically fails: dropped or duplicated points, a broken move/stop alternation, and stops that violate their own dwell definition.

PYTHON
def validate_segments(
    result: pd.DataFrame,
    n_input: int,
    stay_radius_m: float = 30.0,
) -> dict:
    """
    Sanity-check the output of segment_by_stay_points.
    Raises AssertionError on a structural violation; returns a summary dict.
    """
    assert len(result) == n_input, (
        f"Row count changed: {n_input} -> {len(result)} (segmentation must not drop rows)"
    )
    if result.empty:
        return {"n_segments": 0, "n_stops": 0, "n_moves": 0}

    # 1. segment_id must be contiguous from 0 and monotonic non-decreasing
    ids = result["segment_id"].to_numpy()
    assert (np.diff(ids) >= 0).all(), "segment_id is not monotonic"
    assert set(np.unique(ids)) == set(range(ids.max() + 1)), "segment_id has gaps"

    # 2. kind must be constant within a segment and alternate across segments
    kinds = result.groupby("segment_id")["segment_kind"].nunique()
    assert (kinds == 1).all(), "a segment mixes move and stop points"
    seg_kind = result.groupby("segment_id")["segment_kind"].first().to_numpy()
    assert (seg_kind[1:] != seg_kind[:-1]).all(), "adjacent segments share a kind"

    # 3. every stop must actually satisfy the dwell radius it was built with
    for sid, grp in result[result["segment_kind"] == "stop"].groupby("segment_id"):
        r = np.hypot(grp["x_m"] - grp["x_m"].mean(), grp["y_m"] - grp["y_m"].mean()).max()
        assert r <= stay_radius_m * 1.5, f"stop {sid} radius {r:.1f} m exceeds tolerance"

    n_stops = int((seg_kind == "stop").sum())
    return {
        "n_segments": len(seg_kind),
        "n_stops": n_stops,
        "n_moves": len(seg_kind) - n_stops,
        "n_points": len(result),
    }

A passing run guarantees the segments tile the whole track (no dropped rows), that move and stop alternate cleanly, and that every stop honours its dwell radius. Feed the resulting n_moves count against your labelled trip counts to calibrate stay_radius_m and stay_min_s.

Common mistakes and gotchas

  • Detecting stays in degrees. A stay_radius_m of 30 only means 30 metres in a metric CRS. Applied to raw longitude/latitude it becomes a latitude-dependent ellipse, so the same track yields different stays at different latitudes. Project once, up front, and keep everything metric — the same discipline enforced across movement pattern extraction.
  • Segmenting first, matching stays later. Cutting on speed alone and then trying to attach stays produces off-by-one edges where a dwell point lands in the adjacent trip. Detect stays first so the move-segments are defined as the spans between them.
  • Treating a no-stay track as an error. A highway leg with no pause is one valid move-segment. Handle the empty-stay_mask case explicitly instead of letting a downstream groupby raise on an unexpected shape.
  • Over-counting trip distance across stop edges. If you sum step distances without respecting segment boundaries, the crawl into and out of a stop inflates the trip length. Compute distance per segment_id so stop-edge steps stay inside the stop.
  • Skipping the cleaning stage. Stay-point detection on un-smoothed GPS invents phantom stays from multipath jitter. Always run the speed and acceleration profiling and upstream cleaning stages before this pipeline so the dwell test sees real dwells, not noise.

Back to Trajectory Segmentation