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.

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 columns that make a track useful do not exist in any single row.

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.

Measured track length falling from 37.86 km at native sampling to 27.89 km at two-minute intervals.
Coarser sampling cuts corners. The journey is unchanged; the measurement is not.

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.

Four ways a track identity column goes wrong in real files and what each produces.
None of these raises an error. The second was 78% of a real download.

Edge cases or notes

  • 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 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.

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.