Trajectories Explained: What Makes a Track Different from Points

On this page

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 46 real GPS tracks from Brighton, resampled to different intervals (Example 3):

native (median 1 s)   163.17 km
every  5 s            160.65 km   -1.5%
every 30 s            151.38 km   -7.2%
every 60 s            142.92 km  -12.4%
every 120 s           133.11 km  -18.4%

The journeys did not change. Only how often the receiver was asked did, and the measured distance fell by a fifth.

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.

The same coordinates as an unordered point layer and as an ordered trajectory, with speed, heading and duration existing only in the ordered version. the same rows, two objectsa point layerβœ“order is arbitraryβœ“each row is completeβœ“no speed, no headingβœ“row count is the dataa trajectoryβœ“order carries meaningβœ“rows relate to their neighboursβœ“speed, heading, durationβœ“row count is a sampling choiceThe columns that make a track useful do not exist in any single row spatialworkflow.io
The columns that make a track useful do not exist in any single row.

Identity first, project, then between-row quantities

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 for central Brighton from the OpenStreetMap trace API produced 54 <trkseg> elements. In four, about 90% of rows shared a timestamp with the previous row β€” which is what ten traces merged into one element look like, and also what one receiver logging ten fixes a second with whole-second timestamps looks like. Here it was the second: the points sharing each second were 1–3 m apart.

Had they been merged traces, treating each <trkseg> as a track would have produced tracks that teleport between simultaneous journeys; treating fast loggers as merged traces throws good data away. Only the distance between the same-second points tells you which you have.

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 1-second interval on a road that is a good approximation; over a 61,691-second gap β€” the largest in the tracks below β€” 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 46 tracks: median 1 s, 95th percentile 3 s, maximum 61,691 s β€” 16 gaps over five minutes, 0.03% of the intervals and 51% of the elapsed time (measured here).

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 two minutes will report different mileage for identical journeys β€” 18.4% different in this measurement.

Measured track length falling from 163.17 km at native sampling to 133.11 km at two-minute intervals. the same 46 tracks, resamplednative (median 1 s)163.17 kmevery 5 s160.65 km βˆ’1.5%every 30 s151.38 km βˆ’7.2%every 120 s133.11 km βˆ’18.4%0200The journeys did not change. Only how often the receiver was asked spatialworkflow.io
Coarser sampling cuts corners. The journey is unchanged; the measurement is not.

Loading Brighton traces and finding tracks that are several

The data used on this page

Public GPS traces from the OpenStreetMap trackpoints API for a box over central Brighton and Hove, βˆ’0.20 to βˆ’0.10Β° E, 50.81 to 50.86Β° N: the first 12 pages of 5,000 points. The setup loads them the way How to load GPS tracks in Python does β€” one track per <trkseg>, exact duplicates dropped, elements with more than 10% repeated timestamps set aside, tracks under ten points dropped β€” and projects them to UTM zone 30N, adding x, y and the interval dt in seconds. The API serves the newest uploads first, so the setup reads the pages as saved on 9 October 2026. Nothing on this page prints a location or a username.

import glob
import io
import os
import xml.etree.ElementTree as ET
import zipfile

import geopandas as gpd
import numpy as np
import pandas as pd
import requests

# The OSM trackpoints API for this box, pages 0-11, as saved on 9 October 2026. The API serves the
# newest uploads first, so a live download changes whenever someone uploads a trace here. Live:
#   requests.get("https://api.openstreetmap.org/api/0.6/trackpoints",
#                params={"bbox": "-0.20,50.81,-0.10,50.86", "page": page})
if not os.path.exists("traces/page_11.gpx"):
    reply = requests.get("https://data.spatialworkflow.io/osm-gps-traces-brighton-2026-10-09-v2.zip",
                         timeout=120)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("traces")

NS = {"g": "http://www.topografix.com/GPX/1/0"}
rows = []
for n, path in enumerate(sorted(glob.glob("traces/*.gpx"))):
    for t, trk in enumerate(ET.parse(path).getroot().findall("g:trk", NS)):
        for s, seg in enumerate(trk.findall("g:trkseg", NS)):
            rows += [(f"p{n}:{t}-{s}", float(p.get("lat")), float(p.get("lon")),
                      p.findtext("g:time", namespaces=NS)) for p in seg.findall("g:trkpt", NS)]
raw = pd.DataFrame(rows, columns=["track_id", "lat", "lon", "time"]).dropna()
raw["time"] = pd.to_datetime(raw["time"], utc=True, format="ISO8601")
raw = raw.drop_duplicates(["track_id", "time", "lat", "lon"]).sort_values(["track_id", "time"], kind="stable")
zero = raw.groupby("track_id")["time"].apply(lambda s: (s.diff().dt.total_seconds() == 0).mean())
size = raw.groupby("track_id").size()
keep = zero.index[(zero <= 0.1) & (size >= 10)]

df = gpd.GeoDataFrame(raw[raw.track_id.isin(keep)], crs=4326,
                      geometry=gpd.points_from_xy(raw.lon, raw.lat)[raw.track_id.isin(keep).to_numpy()])
df = df.to_crs(32630).reset_index(drop=True)
df["x"], df["y"] = df.geometry.x, df.geometry.y
df["dt"] = df.groupby("track_id")["time"].diff().dt.total_seconds()
print(f"{df.track_id.nunique()} tracks, {len(df):,} points")
46 tracks, 55,316 points

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


trajectories, summary = load_trajectories(df)
  46 tracks, 55,316 points, 163.17 km, 47.26 h
Four GPS segments with about 90% repeated timestamps, and the distance between the points that share a second. 90% zero intervals, and what they turned out to besegmentpointszero intervalssame-second spacingp6:1-01,86490.0%1 mp6:2-02,09089.8%2 mp6:3-066890.0%3 mp7:0-05487.0%3 mmerged traces would put those points hundreds of metres apartSpacing measured in the GPS loading guide on the same Brighton download spatialworkflow.io
Ten fixes a second from one receiver: the zero-interval test flags them, the spacing clears them.

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']} points a second: merged traces or a fast logger")
    return rows


rows = looks_interleaved(raw)          # every <trkseg>, before the setup set any aside
  4 of 54 groups have >10% of rows sharing a timestamp with the previous row
    p6:5-0: 1,268 points, 90.0% zero-dt -> about 10 points a second: merged traces or a fast logger
    p7:0-0: 596 points, 89.8% zero-dt -> about 10 points a second: merged traces or a fast logger
    p7:1-0: 2,090 points, 89.8% zero-dt -> about 10 points a second: merged traces or a fast logger
    p7:2-0: 722 points, 89.9% zero-dt -> about 10 points a second: merged traces or a fast logger

A zero-dt fraction of k⁄(k+1) means k+1 points a second, on average. That is what k+1 interleaved traces sampled at the same rate produce, and also what one receiver logging k+1 fixes a second with whole-second timestamps produces. In this download all four 90% elements were the second kind; measure the distance between same-second points before deciding.

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


results = length_vs_interval(df)
   native:   163.17 km  (+0.0%)
       1s:   163.17 km  (-0.0%)
       5s:   160.65 km  (-1.5%)
      10s:   158.27 km  (-3.0%)
      30s:   151.38 km  (-7.2%)
      60s:   142.92 km  (-12.4%)
     120s:   133.11 km  (-18.4%)

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.

Why length depends on sampling and speed sits between rows

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: 163.17 km at 1-second sampling becomes 133.11 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.

The Brighton tracks are a counter-example worth knowing: at a median interval of one second, none of 55,269 intervals implied more than 200 km/h, and the fastest was 172 km/h (measured here). Noise at 1 Hz is real, but on clean data it stays well below the impossible.

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.

Ways a track identity column is wrong in real files, and what each produces. four ways identity goes wrongwhat the file doeswhat it producesone device logs many tripsone "track" containing severaljourneysseveral traces merged into oneelementzero intervals, points far apartan identifier reused afterreassignmenta teleport mid-tracka long stationary period inside atripphantom distanceA fast logger also gives 90% zero intervals, with its points metres apart spatialworkflow.io
None of these raises an error, and the second has a look-alike that is not an error at all.

Sorting, groupby, zero intervals and long gaps

  • Sort by identity and time. Sorting by time alone interleaves tracks.
  • groupby before diff, or the first row of each track borrows from the previous track.
  • Project before measuring. Degrees are not metres.
  • dt == 0 happens β€” two fixes in the same second. Guard the division.
  • Duplicate (id, time) rows turn up even in clean downloads; here 2 of 60,000 raw points were exact duplicates, each under a different track id.
  • 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.

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 tracks, length fell from 163.17 km at 1-second sampling to 133.11 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. On 46 clean Brighton tracks logged at one fix a second there were none over 200 km/h, so a large number of them points at the data, not the physics.

Can I treat each GPX track segment as one track?

Not always, and the usual test can mislead. Of 54 segments downloaded from the OpenStreetMap trace API, four had about 90% of their timestamps repeated β€” the signature of merged traces β€” yet each was one receiver logging ten fixes a second. Check the distance between points that share a timestamp.

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.