Time-based vs distance-based trajectory segmentation
A time rule cuts when too long passes between fixes; a distance rule cuts every fixed number of metres travelled. On a motorway at steady speed they produce nearly the same segments. In congestion the time rule produces one long segment and the distance rule produces dozens; in a sparse rural feed the reverse. Which is correct depends on whether the downstream metric is per unit time or per unit distance — and a pipeline that needs both should carry both, not compromise on one.
Why this happens
The two rules measure different things because speed varies. Time-based segmentation produces segments of roughly constant duration and wildly varying length; distance-based produces the opposite. Neither is more accurate; they are different partitions of the same track, suited to different denominators.
The practical consequence is that they fail in opposite conditions. A vehicle idling for twenty minutes covers no distance, so a distance rule produces no cut and the idle joins the segment either side of it. A vehicle on a rural road reporting every ninety seconds covers three kilometres between fixes, so a time rule cuts constantly on data that is perfectly continuous. The general framing of what a segment even means is in trajectory segmentation.
Core pipeline
- Decide the denominator — per-hour metrics want time segments, per-kilometre metrics want distance segments.
- Apply the chosen rule with a threshold derived from the sampling cadence rather than a round number.
- Add a gap rule regardless, because a data outage must break both kinds of segment.
- Name the column after the rule, so a downstream consumer can tell which partition it received.
Production-ready Python implementation
import numpy as np
import pandas as pd
def segment_by_rule(
df: pd.DataFrame,
rule: str = "time",
time_col: str = "t",
x_col: str = "x",
y_col: str = "y",
entity_col: str = "entity_id",
time_threshold_s: float = 300.0,
distance_threshold_m: float = 2000.0,
gap_threshold_s: float = 60.0,
) -> pd.DataFrame:
"""
Cut a track into segments by elapsed time or by travelled distance.
Parameters
----------
rule : {'time', 'distance', 'both'}
'both' emits two independent columns rather than intersecting them —
intersecting produces segments shorter than either rule intended,
which is almost never what a caller means by "use both".
gap_threshold_s : float
A data outage always breaks a segment, under either rule. This is a
different concept from the time rule: a gap means the position is
unknown, whereas the time rule is just a chosen granularity.
Returns
-------
pd.DataFrame
Input plus 'seg_time' and/or 'seg_distance', and always 'seg_gap'.
Raises
------
ValueError
On missing columns, an empty frame, or an unknown rule.
"""
if rule not in {"time", "distance", "both"}:
raise ValueError(f"Unknown rule {rule!r}; expected time, distance or both.")
required = {time_col, x_col, y_col, entity_col}
missing = required - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
if df.empty:
raise ValueError("Input DataFrame is empty.")
d = df.sort_values([entity_col, time_col]).reset_index(drop=True).copy()
g = d.groupby(entity_col, sort=False)
dt = g[time_col].diff().dt.total_seconds().fillna(0.0).to_numpy()
step = np.hypot(g[x_col].diff().fillna(0.0).to_numpy(),
g[y_col].diff().fillna(0.0).to_numpy())
# Gaps always cut. A negative dt is a clock fault and also cuts, because
# elapsed time across it is unknown.
gap_cut = (dt > gap_threshold_s) | (dt < 0)
d["seg_gap"] = gap_cut.cumsum()
if rule in ("time", "both"):
# Accumulate elapsed time, resetting at each cut. A vectorised cumsum
# cannot express "reset on threshold", so this is an explicit loop —
# at fix-level volumes, do it per entity with numpy inside.
d["seg_time"] = _accumulate_cuts(dt, time_threshold_s, gap_cut)
if rule in ("distance", "both"):
d["seg_distance"] = _accumulate_cuts(step, distance_threshold_m, gap_cut)
return d
def _accumulate_cuts(delta: np.ndarray, threshold: float, force_cut: np.ndarray) -> np.ndarray:
"""Running accumulator that emits a new segment id whenever it exceeds threshold."""
seg = np.empty(delta.size, dtype=np.int64)
acc, sid = 0.0, 0
for i in range(delta.size):
if force_cut[i]:
sid += 1
acc = 0.0
elif acc + delta[i] > threshold:
sid += 1
acc = 0.0
acc += max(delta[i], 0.0)
seg[i] = sid
return seg
Validation block
def validate_segments(d: pd.DataFrame, rule: str,
time_threshold_s: float = 300.0,
distance_threshold_m: float = 2000.0) -> dict:
"""Check the segments obey the rule that produced them."""
out = {}
if rule in ("time", "both"):
dur = d.groupby("seg_time")["t"].agg(lambda s: (s.max() - s.min()).total_seconds())
# Allow one sampling interval of overshoot; more means the accumulator
# is not resetting.
assert dur.max() <= time_threshold_s * 1.2, (
f"a time segment ran to {dur.max():.0f}s against a "
f"{time_threshold_s:.0f}s threshold"
)
out["time_segments"] = int(d["seg_time"].nunique())
if rule in ("distance", "both"):
length = d.groupby("seg_distance").apply(
lambda g: float(np.hypot(g["x"].diff(), g["y"].diff()).sum()),
include_groups=False,
)
assert length.max() <= distance_threshold_m * 1.2, (
f"a distance segment ran to {length.max():.0f}m"
)
out["distance_segments"] = int(d["seg_distance"].nunique())
# Every gap must break every segmentation, or a segment spans an outage.
for col in [c for c in ("seg_time", "seg_distance") if c in d]:
spans = d.groupby(col)["seg_gap"].nunique()
assert (spans == 1).all(), f"{col} has segments spanning a data gap"
return out
Common mistakes and gotchas
-
Intersecting the two rules. Cutting whenever either fires produces segments shorter than either intended and a partition nobody chose. Emit both columns and let consumers pick.
-
Conflating the time rule with the gap rule. A five-minute granularity and a sixty-second outage are different concepts. Keep
seg_gapseparate, because only it means “the position is unknown”. -
Computing distance in degrees. The distance rule then cuts more often near the equator. Project first.
-
A threshold below the sampling interval. A 30-second time rule on a 60-second feed makes every fix its own segment. Derive the threshold from the observed cadence.
-
Not resetting the accumulator at a cut. The classic off-by-one: segments then grow without bound after the first cut.
-
Unnamed segment columns. A column called
segment_idtells a consumer nothing about which partition it is. Name it after the rule.
FAQ
Which rule should I default to?
Time, for most fleet analytics, because the metrics people ask for — utilisation, idle time, hours driven — have time in the denominator. Switch to distance when the product is per-kilometre: fuel, emissions, wear, or corridor exposure.
What thresholds work in practice?
For time, 5–15 minutes at typical fleet cadences; below about ten times the sampling interval the segments are too short to carry stable statistics. For distance, 1–5 km in urban work and 10–20 km for long-haul. In both cases derive from the cadence and check the segment-count curve is not on a cliff.
Do I still need dwell-based segmentation?
Yes — these rules are about granularity, not semantics. Neither knows what a trip is. Trip boundaries come from stops and gaps, which is segmenting trips by dwell time and gap thresholds; the time and distance rules subdivide within a trip.
Related
- Trajectory Segmentation — the parent reference and the four-criteria comparison.
- Segmenting Trips by Dwell Time and Gap Thresholds — the semantic rule these subdivide within.
- Combining Trajectory Segmentation with Stay-Point Detection — the ordering constraint between the two stages.
- Sampling Rate Optimization — the cadence these thresholds have to be derived from.
- Detecting and Classifying Gaps in Trajectory Streams — the gap rule that overrides both.