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
- Project to a metric CRS. Transform cleaned
lat/lonto the UTM zone covering your data. Dwell radius, segment length, and speed are all metres from here on — never degrees. - 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.
- Cut moves between stays. Tag every stay run as a
stopsegment; the rows strictly between two consecutive stays become onemovesegment. - Emit the trip/stop sequence. Assign a monotonic
segment_idand asegment_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.
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.
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_mof 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_maskcase explicitly instead of letting a downstreamgroupbyraise 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_idso 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.
Related
- Trajectory Segmentation — the parent topic covering gap, stop, and movement-change criteria in full.
- Stay-Point Detection Algorithms — the dwell-clustering methods this pipeline runs first.
- Implementing DBSCAN for Stay-Point Clustering in Python — a density-based alternative to the time-distance sweep used here.
- Movement Pattern Extraction & Trajectory Analysis — the parent section these stages belong to.
- Speed & Acceleration Profiling — mode inference and the kinematic signals that guard against phantom stays.