Trajectories Explained: What Makes a Track Different from Points
Problem statement
A GPS track loaded into GeoPandas is a table of points with a timestamp column. Treating it as a point layer is the mistake that produces every subsequent one, because a trajectory has three properties a point layer does not:
- Order matters. Sorting by anything but time destroys it.
- Consecutive rows are related. Speed, heading and acceleration exist only between rows.
- The row count is a sampling choice, not a property of the movement.
That last one is the least obvious and the most consequential. The same 13 real GPS traces, resampled to different intervals:
native (median 3 s) 37.86 km
every 5 s 35.11 km -7.3%
every 30 s 31.20 km -17.6%
every 60 s 29.67 km -21.6%
every 120 s 27.89 km -26.3%
The journeys did not change. Only how often the receiver was asked did, and the measured distance fell by a quarter.
Quick answer
Sort by identity and time, then derive everything between rows:
import numpy as np
import pandas as pd
df = df.sort_values(["track_id", "time"]).reset_index(drop=True)
g = df.groupby("track_id")
df["dt"] = g["time"].diff().dt.total_seconds()
df["dx"] = g["x"].diff()
df["dy"] = g["y"].diff()
df["dist"] = np.hypot(df["dx"], df["dy"])
df["speed"] = df["dist"] / df["dt"].replace(0, np.nan)
df["heading"] = np.degrees(np.arctan2(df["dx"], df["dy"])) % 360
Three details do all the work: groupby so the first row of each track does not borrow from the last row of the previous one, projected coordinates so hypot is metres, and replace(0, np.nan) so two points in the same second do not divide by zero.
Step-by-step solution
1. Establish the identity column first
A trajectory needs a key that says which points belong to one continuous journey by one thing. Without it, diff() computes the speed between the last point of one trip and the first of the next β usually an impossible value.
This is harder than it looks with real data. Downloading 60,000 public GPS points from the OpenStreetMap trace API produced 68 <trkseg> elements, of which 53 had about 90% of their rows sharing a timestamp with the previous row β the signature of roughly ten separate traces merged into each element. Only 13 were single traces, holding 10.7% of the points.
Treating each <trkseg> as a track would have produced 53 tracks that teleport between ten simultaneous journeys.
2. Project before measuring anything
np.hypot on longitude and latitude gives degrees, and a degree of longitude is not a degree of latitude anywhere except the equator. Project to a metric CRS suited to the area first.
3. Derive between-row quantities, and know they are estimates
Speed from dist/dt is the average over the interval, not the instantaneous speed. Over a 3-second interval on a road that is a good approximation; over a 2,989-second gap β the largest measured here β it is meaningless, because the receiver was off and the movement between is unknown.
4. Expect the distribution of intervals to be ugly
Across the 13 clean traces: median 3 s, 95th percentile 11 s, maximum 2,989 s.
gaps over 5 s: 567 ( 8.85%)
gaps over 30 s: 128 ( 2.00%)
gaps over 60 s: 46 ( 0.72%)
gaps over 300 s: 3 ( 0.05%)
That long tail is tunnels, urban canyons, battery savers and the receiver losing lock. Any analysis assuming a fixed interval needs to handle it explicitly.
5. Accept that measured length depends on the sampling rate
The table at the top of this page is the central fact about trajectory data. Distance, and therefore average speed, is a function of how often you sampled.
The consequence: you cannot compare distances between datasets sampled at different rates. A fleet logging every second and one logging every minute will report different mileage for identical journeys β 26% different in this measurement.
Code examples
Example 1 β a trajectory loader with the traps handled
import numpy as np
import pandas as pd
import geopandas as gpd
def load_trajectories(df, id_col="track_id", time_col="time",
lon="lon", lat="lat", crs=None):
"""Ordered, projected, with between-row quantities derived."""
df = df.dropna(subset=[time_col, lon, lat]).copy()
df[time_col] = pd.to_datetime(df[time_col], utc=True)
gdf = gpd.GeoDataFrame(
df, geometry=gpd.points_from_xy(df[lon], df[lat]), crs=4326)
gdf = gdf.to_crs(crs or gdf.estimate_utm_crs())
gdf["x"], gdf["y"] = gdf.geometry.x, gdf.geometry.y
gdf = gdf.sort_values([id_col, time_col]).reset_index(drop=True)
g = gdf.groupby(id_col)
gdf["dt"] = g[time_col].diff().dt.total_seconds()
gdf["dist"] = np.hypot(g["x"].diff(), g["y"].diff())
gdf["speed"] = gdf["dist"] / gdf["dt"].replace(0, np.nan)
gdf["heading"] = (np.degrees(np.arctan2(g["x"].diff(), g["y"].diff()))) % 360
summary = g.agg(points=(time_col, "size"),
start=(time_col, "min"), end=(time_col, "max"))
summary["duration_s"] = (summary["end"] - summary["start"]).dt.total_seconds()
summary["length_km"] = gdf.groupby(id_col)["dist"].sum() / 1000
print(f" {len(summary)} tracks, {len(gdf):,} points, "
f"{summary['length_km'].sum():.2f} km, "
f"{summary['duration_s'].sum() / 3600:.2f} h")
return gdf, summary
13 tracks, 6,417 points, 37.86 km, 8.80 h
Example 2 β detecting that one "track" is really several
import numpy as np
def looks_interleaved(df, id_col="track_id", time_col="time", threshold=0.1):
"""Several traces merged into one element show as repeated timestamps."""
rows = []
for key, sub in df.groupby(id_col):
sub = sub.sort_values(time_col)
dt = sub[time_col].diff().dt.total_seconds()
zero_fraction = float((dt == 0).mean())
rows.append({"id": key, "n": len(sub), "zero_dt": zero_fraction,
"implied_traces": round(1 / max(1 - zero_fraction, 1e-9))})
bad = [r for r in rows if r["zero_dt"] > threshold]
if bad:
print(f" {len(bad)} of {len(rows)} groups have >{threshold:.0%} of rows "
"sharing a timestamp with the previous row")
for r in bad[:5]:
print(f" {r['id']}: {r['n']:,} points, {r['zero_dt']:.1%} zero-dt "
f"-> about {r['implied_traces']} traces merged")
return rows
53 of 68 groups have >10% of rows sharing a timestamp with the previous row
21: 2,891 points, 89.9% zero-dt -> about 10 traces merged
37: 2,821 points, 89.9% zero-dt -> about 10 traces merged
A zero-dt fraction of kβ(k+1) is the fingerprint of k+1 interleaved traces sampled at the same rate. Ninety percent means about ten.
Example 3 β measuring your own sampling-rate sensitivity
import numpy as np
def length_vs_interval(gdf, id_col="track_id", time_col="time",
steps=(0, 1, 5, 10, 30, 60, 120)):
"""How much of your measured distance is an artefact of the sampling rate?"""
results = []
for step in steps:
total = 0.0
for _, sub in gdf.groupby(id_col):
if step == 0:
total += np.hypot(sub["x"].diff(), sub["y"].diff()).sum()
continue
elapsed = (sub[time_col] - sub[time_col].iloc[0]).dt.total_seconds().values
keep = [0]
for i in range(1, len(elapsed)):
if elapsed[i] - elapsed[keep[-1]] >= step:
keep.append(i)
picked = sub.iloc[keep]
total += np.hypot(np.diff(picked["x"].values),
np.diff(picked["y"].values)).sum()
results.append({"interval_s": step, "km": total / 1000})
base = results[0]["km"]
for r in results:
label = "native" if r["interval_s"] == 0 else f"{r['interval_s']}s"
print(f" {label:>7}: {r['km']:8.2f} km ({r['km'] / base - 1:+.1%})")
return results
Run this before quoting any distance. If your figure drops 20% when resampled to a minute, then a dataset logged at a minute is not comparable with yours, and the difference between two fleets may be their loggers rather than their driving.
Explanation
Why measured length depends on the sampling rate
Between two recorded points the path is assumed straight. Every wiggle shorter than the sampling interval is cut off, so measured length is always an underestimate that shrinks as the interval grows.
This is the same phenomenon as the coastline paradox, and the measurement makes it concrete: 37.86 km at 3-second sampling becomes 27.89 km at two-minute sampling. Nothing about the journeys changed.
Two practical consequences. Resample everything to a common interval before comparing datasets. And when reporting distance, report the sampling interval with it, the way you would report the units.
Why identity is harder than it looks
A track key must mean "one continuous journey by one moving thing". Real files rarely provide it cleanly:
- A device logs continuously across many trips, so one file is many journeys.
- A trace format merges several uploads into one element, as measured above.
- An identifier is reused after a device is reassigned.
- A vehicle stops for an hour and the logger keeps recording, so the "journey" contains a long stationary period.
Each of these produces different failures, and none raises an exception. See Stops, trips and segmentation explained.
Why speed is between rows and not in them
A GPS receiver may report an instantaneous Doppler speed, which is a genuine measurement and more accurate than anything you can derive. If your data has it, use it.
Derived speed is distance/time between consecutive fixes: an average over the interval, inheriting the position error of both endpoints. With 5 m position error and a 1-second interval, derived speed carries roughly 7 m/s of noise β which is why high-frequency GPS shows implausible speeds far more often than low-frequency GPS.
That shows in the measurements: 0.203% of intervals implied more than 200 km/h, on traces whose median speed was 4.7 km/h.
Why a trajectory is not a LineString
Converting a track to a LineString is convenient for drawing and destroys the time dimension. The line has no duration, no speed and no stops, and you cannot get them back.
Keep the points as the primary representation and build lines as a derived product for display. Where you need line geometry with time, keep the timestamps as an attribute array alongside, or use a library that models trajectories explicitly.
Edge cases or notes
- Sort by identity and time. Sorting by time alone interleaves tracks.
groupbybeforediff, or the first row of each track borrows from the previous track.- Project before measuring. Degrees are not metres.
dt == 0happens β two fixes in the same second. Guard the division.- Duplicate
(id, time)rows are common; here 2.3% of raw points were exact duplicates. - Timestamps must be timezone-aware. Naive local times break across DST.
- A long gap is not a straight line. Treat gaps over a threshold as track breaks.
- Report the sampling interval with any distance. It is part of the measurement.
Internal links
- Sampling rate and gaps explained: how often is often enough β choosing and reporting the interval
- GPS error explained: why your track wanders β where the noise comes from
- Stops, trips and segmentation explained β recovering identity
- How to load GPS tracks into Python as trajectories β the practical loader
- How to clean a GPS track: outliers, duplicates and jumps β the filtering step
- How to calculate speed, distance and direction along a track β the derived columns in detail
- My track has impossible speeds or teleports β the symptom of a bad identity column
- How to resample and interpolate a track to a fixed interval β making datasets comparable
FAQ
What is a trajectory?
An ordered sequence of timestamped positions belonging to one moving thing. The order and the timestamps are what distinguish it from a point layer.
Why does my track length change when I resample it?
Because straight lines between fixes cut every corner shorter than the sampling interval. Measured on real traces, length fell from 37.86 km at 3-second sampling to 27.89 km at two-minute sampling.
How do I calculate speed from GPS points?
Distance divided by time between consecutive fixes, after sorting by track and time and projecting to metres. It is an average over the interval, not an instantaneous speed.
Why do I get impossible speeds?
Usually a missing or wrong identity column, so consecutive rows come from different journeys. Also position noise divided by a short interval β 0.2% of intervals here implied over 200 km/h.
Can I treat each GPX track segment as one track?
Not always. Of 68 segments downloaded from the OpenStreetMap trace API, 53 contained about ten interleaved traces each.
Should I convert my track to a LineString?
For drawing, yes. As the primary representation, no β a LineString has no time, so speed, stops and duration are lost.
How do I compare two datasets logged at different rates?
Resample both to the coarser interval before measuring anything. Otherwise the finer-grained dataset will report longer distances for identical journeys.