Sampling Rate and Gaps Explained: How Often Is Often Enough

On this page

Problem statement

The sampling interval of a GPS log is usually chosen by whatever the device defaulted to, and it silently determines what your analysis can and cannot say.

Measured on 46 real GPS tracks from Brighton, logged at a median of one fix a second and thinned to coarser intervals (Example 3):

native   163.2 km
   5 s   160.6 km    -1.5%
  10 s   158.3 km    -3.0%
  30 s   151.4 km    -7.2%
  60 s   142.9 km   -12.4%
 120 s   133.1 km   -18.4%

Same journeys, same receivers, same code. Nearly a fifth of the distance disappears between 1-second and 2-minute logging.

The interval also is not one number. Across those tracks the median gap was 1 s, the 95th percentile 3 s and the maximum 61,691 s β€” over seventeen hours with no fix at all.

Quick answer

Report the interval distribution, not the nominal rate:

import numpy as np

dt = df.groupby("track_id")["time"].diff().dt.total_seconds().dropna()

print(f"median {dt.median():.0f}s   p95 {dt.quantile(0.95):.0f}s   "
      f"p99 {dt.quantile(0.99):.0f}s   max {dt.max():,.0f}s")
for threshold in (5, 30, 60, 300):
    print(f"  gaps over {threshold:4d}s: {int((dt > threshold).sum()):5,} "
          f"({(dt > threshold).mean():6.2%})")
median 1s   p95 3s   p99 11s   max 61,691s
  gaps over    5s: 1,389 ( 2.51%)
  gaps over   30s:   113 ( 0.20%)
  gaps over   60s:    64 ( 0.12%)
  gaps over  300s:    16 ( 0.03%)
A GPS interval distribution with a median of 1 second, a 95th percentile of 3 seconds and a long tail out to 61,691 seconds. intervals across 46 real tracks (log scale)median1 s95th percentile3 s99th percentile11 smaximum61,691 s β€” 17 hoursBar length is log(1 + seconds): the median is the rate; the tail breaks the analysis spatialworkflow.io
The nominal rate describes the median. The tail is what breaks the analysis.

The interval your question needs against the one you have

1. Work out what interval your question needs

Different questions have different requirements, and they differ by orders of magnitude:

question interval needed why
where did this vehicle go 30–60 s route topology survives coarse sampling
how far did it travel 1–5 s distance is sampling-dependent
where did it stop 5–30 s a 2-minute stop needs several fixes inside it
how fast at a point 1 s, or the receiver's own speed derived speed needs a short baseline
did it exceed a speed limit 1 s, plus Doppler speed averages hide brief excesses
turn-by-turn behaviour 1 s turns take a few seconds

"Where did it go" is remarkably robust; "how far did it go" is not.

2. Measure the interval you actually have

Nominal rates are aspirations. A device set to 1 Hz loses lock in tunnels, throttles on low battery, and may drop to event-based logging when stationary.

The distribution above has a median of 1 s and a maximum of 61,691 s. Both matter: the median sets the resolution, the tail sets where the track is fiction. Here the 16 gaps over five minutes are 0.03% of the intervals and 51% of the elapsed time (Example 1).

3. Treat long gaps as breaks, not as long straight lines

A 61,691-second gap interpolated as a straight line at constant speed is a fabrication. Where the receiver was off, the honest representation is a break.

GAP_SECONDS = 120
df["new_leg"] = (df["dt"] > GAP_SECONDS) | df["dt"].isna()
df["leg"] = df.groupby("track_id")["new_leg"].cumsum()

Splitting at gaps turns one impossible line into two honest ones with a hole between them.

4. Resample to a common interval before comparing datasets

Two fleets, two loggers, two mileage figures for the same driving. The only fix is to downsample both to the coarser interval and compare like with like. See How to resample and interpolate a track to a fixed interval.

5. Do not upsample to fix a coarse log

Interpolating a 60-second log to 1 Hz produces sixty points a minute, of which one is a measurement. On these tracks it turned 1,341 fixes into 168,895 points, and the distance stayed at 142.92 km rather than rising back to 163.17 km, because interpolation on a straight line adds points without adding path.

Upsampling is useful for aligning two datasets in time. It does not recover information.

A long GPS gap drawn as a straight line against the same gap represented as a break between two track legs. a 17-hour gap is not a straight lineinterpolatednobody recorded thisasserts a journeysplit into legsunobserveda visible holeSplitting at gaps turns one impossible line into two honest ones spatialworkflow.io
A 50-minute gap drawn as a straight line asserts a journey nobody recorded.

Interval reports, gap splits and an upsampling test

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 β€” an interval report with the tail made visible

import numpy as np
import pandas as pd


def interval_report(df, id_col="track_id", time_col="time",
                    thresholds=(2, 5, 10, 30, 60, 300, 900)):
    """The nominal rate, the real distribution, and where the holes are."""
    dt = df.sort_values([id_col, time_col]).groupby(id_col)[time_col] \
           .diff().dt.total_seconds().dropna()

    print(f"  {len(dt):,} intervals across {df[id_col].nunique()} tracks")
    for q in (5, 25, 50, 75, 95, 99):
        print(f"    p{q:<3} {np.percentile(dt, q):9.1f} s")
    print(f"    max  {dt.max():9.1f} s")

    total = dt.sum()
    for t in thresholds:
        over = dt[dt > t]
        print(f"    over {t:5d}s: {len(over):5,} gaps "
              f"({len(over) / len(dt):6.2%} of intervals, "
              f"{over.sum() / total:6.2%} of elapsed time)")
    return dt


dt = interval_report(df)
  55,270 intervals across 46 tracks
    p5         1.0 s
    p25        1.0 s
    p50        1.0 s
    p75        1.0 s
    p95        3.0 s
    p99       11.0 s
    max    61691.0 s
    over     2s: 3,408 gaps ( 6.17% of intervals, 68.00% of elapsed time)
    over     5s: 1,389 gaps ( 2.51% of intervals, 63.38% of elapsed time)
    over    10s:   559 gaps ( 1.01% of intervals, 60.12% of elapsed time)
    over    30s:   113 gaps ( 0.20% of intervals, 55.46% of elapsed time)
    over    60s:    64 gaps ( 0.12% of intervals, 54.23% of elapsed time)
    over   300s:    16 gaps ( 0.03% of intervals, 51.39% of elapsed time)
    over   900s:     6 gaps ( 0.01% of intervals, 48.00% of elapsed time)

The second percentage is the one people miss. A handful of long gaps can be a negligible fraction of the intervals and most of the elapsed time β€” meaning the track covers far less of the journey than its point count suggests.

Path length of 46 Brighton GPS tracks at their native rate and thinned to 30, 60 and 120 seconds. km of path, same journeys, coarser loggingnative, about 1 s163.2βˆ’7.2% of nativeevery 30 s151.4βˆ’12.4% of nativeevery 60 s142.9βˆ’18.4% of nativeevery 120 s133.1Every straight line between fixes cuts off the corners it skipped spatialworkflow.io
The journeys did not get shorter; the ruler got longer.

Example 2 β€” splitting a track at its gaps

import numpy as np


def split_on_gaps(df, id_col="track_id", time_col="time",
                  max_gap_s=120, max_jump_m=None):
    """One leg per continuously observed stretch."""
    df = df.sort_values([id_col, time_col]).copy()
    g = df.groupby(id_col)
    dt = g[time_col].diff().dt.total_seconds()

    breaks = dt > max_gap_s
    if max_jump_m is not None:
        jump = np.hypot(g["x"].diff(), g["y"].diff())
        breaks |= jump > max_jump_m

    breaks |= dt.isna()                        # first row of each track
    df["leg"] = breaks.groupby(df[id_col]).cumsum()
    df["leg_id"] = df[id_col].astype(str) + "-" + df["leg"].astype(str)

    legs = df.groupby("leg_id").size()
    print(f"  {df[id_col].nunique()} tracks -> {len(legs)} legs")
    print(f"  legs with fewer than 3 points: {int((legs < 3).sum())}")
    return df


legs = split_on_gaps(df, max_gap_s=120, max_jump_m=500)
  46 tracks -> 75 legs
  legs with fewer than 3 points: 3

Splitting on a distance jump as well as a time gap catches the other failure: a short interval with an impossible displacement, which is a position error rather than a pause.

Example 3 β€” what interval does your question tolerate?

import numpy as np


def question_sensitivity(gdf, metric, id_col="track_id", time_col="time",
                         steps=(1, 5, 10, 30, 60, 120)):
    """Recompute your actual metric at several intervals and see if it moves."""
    base = metric(gdf)
    print(f"  native: {base:.4g}")
    for step in steps:
        keep = []
        for _, sub in gdf.groupby(id_col):
            elapsed = (sub[time_col] - sub[time_col].iloc[0]) \
                .dt.total_seconds().values
            picked = [0]
            for i in range(1, len(elapsed)):
                if elapsed[i] - elapsed[picked[-1]] >= step:
                    picked.append(i)
            keep.append(sub.iloc[picked])
        value = metric(pd.concat(keep))
        print(f"  {step:4d}s: {value:.4g}  ({value / base - 1:+.1%})")


def path_km(g):
    return sum(np.hypot(np.diff(t.x), np.diff(t.y)).sum() for _, t in g.groupby("track_id")) / 1000


def displacement_km(g):
    return sum(np.hypot(t.x.iloc[-1] - t.x.iloc[0], t.y.iloc[-1] - t.y.iloc[0])
               for _, t in g.groupby("track_id")) / 1000


print("path length, km")
question_sensitivity(df, path_km, steps=(5, 10, 30, 60, 120))
print("start-to-end displacement, km")
question_sensitivity(df, displacement_km, steps=(5, 10, 30, 60, 120))
path length, km
  native: 163.2
     5s: 160.6  (-1.5%)
    10s: 158.3  (-3.0%)
    30s: 151.4  (-7.2%)
    60s: 142.9  (-12.4%)
   120s: 133.1  (-18.4%)
start-to-end displacement, km
  native: 56.52
     5s: 56.31  (-0.4%)
    10s: 56.03  (-0.9%)
    30s: 55.52  (-1.8%)
    60s: 54.47  (-3.6%)
   120s: 52.82  (-6.5%)

Do this with your metric rather than with distance. Some are far more robust than others: start-to-end displacement moved by 1.8% or less up to 30-second sampling, while path length lost 7.2%. At 120 s even displacement fell 6.5%, partly because keeping one fix every two minutes drops up to two minutes from the end of every track.

Example 4 β€” upsampling does not bring the distance back

def thin(g, step):
    out = []
    for _, sub in g.groupby("track_id"):
        elapsed = (sub.time - sub.time.iloc[0]).dt.total_seconds().to_numpy()
        picked = [0]
        for i in range(1, len(elapsed)):
            if elapsed[i] - elapsed[picked[-1]] >= step:
                picked.append(i)
        out.append(sub.iloc[picked])
    return pd.concat(out)


def upsample(g, step=1):
    out = []
    for key, sub in g.groupby("track_id"):
        t = (sub.time - sub.time.iloc[0]).dt.total_seconds().to_numpy()
        grid = np.arange(0, t[-1] + step, step)
        out.append(pd.DataFrame({"track_id": key, "x": np.interp(grid, t, sub.x),
                                 "y": np.interp(grid, t, sub.y)}))
    return pd.concat(out)


coarse = thin(df, 60)
print(f"native {path_km(df):.2f} km; every 60 s {path_km(coarse):.2f} km; "
      f"that log interpolated to 1 s {path_km(upsample(coarse)):.2f} km "
      f"({len(upsample(coarse)):,} points from {len(coarse):,})")
native 163.17 km; every 60 s 142.92 km; that log interpolated to 1 s 142.92 km (168,895 points from 1,341)

Why coarser sampling shortens the path

Why coarser sampling always shortens the path

Between two fixes the software assumes a straight line. Any deviation from straight between them is lost, and deviations exist at every scale β€” lane changes, gentle curves, the wobble of a walking gait.

So measured length falls monotonically as the interval grows, and never rises. It is the coastline paradox with time as the ruler: 163.2 km measured at 1 s becomes 133.1 km at 120 s.

There is no "true" length independent of the ruler. There is only length-at-an-interval, which is why the interval belongs beside the number.

Why the gap distribution is more useful than the rate

A device advertised as "1 Hz" produces intervals of 1 s most of the time and much longer occasionally, and the occasional ones concentrate exactly where the environment is difficult β€” urban canyons, tunnels, dense canopy.

That correlation matters. The gaps are not random missing data; they are systematically located in the places where routing, mode detection and stop detection are hardest. An analysis that ignores them is biased, not merely noisy.

Why the receiver's own speed beats derived speed

Many receivers compute speed from Doppler shift on the carrier signal, which is an independent measurement rather than a difference of two noisy positions.

Derived speed inherits the error of both endpoints, divided by the interval. With 5 m of position error and a 1-second interval, that is roughly 7 m/s of noise β€” which is why the highest-frequency logs contain the most implausible speeds.

If your data has a speed column from the device, prefer it, and use derived speed only as a cross-check.

Why upsampling does not help

Interpolating between fixes adds rows along the straight line already assumed. Distance along a straight line does not change when you add points to it, so the measured length is unchanged.

Upsampling is genuinely useful for one thing: putting two datasets on a common time base so they can be compared or joined. It is not a way to recover a path that was never sampled.

Some trajectory metrics being robust to the sampling interval and others not. which of your metrics survive coarser sampling?robust to interval?needsroute takenβœ“yes30–60 sstart-to-end displacementβœ“yesanypath lengthβœ•no β€” βˆ’21.5% at 120 s1–5 sinstantaneous speedβœ•no1 s or DopplerChoose the metric that survives your sampling, or resample everything to match spatialworkflow.io
Route topology survives coarse sampling. Distance does not, and speed least of all.

Medians, percentiles and devices that change rate

  • Report the interval with every distance. It is part of the measurement.
  • Use the median and the 95th percentile, not the mean β€” one long gap dominates the mean.
  • Split at long gaps rather than interpolating across them.
  • A gap of zero is also a problem: two fixes in the same second give infinite derived speed.
  • Gaps cluster in hard environments, so missing data is not missing at random.
  • Downsample to compare, never upsample.
  • Devices change rate mid-log on low battery or when stationary; check for regime changes.
  • Displacement is robust, path length is not. Choose the metric that survives your sampling.

FAQ

What GPS sampling rate should I use?

It depends on the question. Route topology survives 30–60 s; distance needs 1–5 s; stop detection needs several fixes inside the shortest stop you care about.

Why does my track distance change with the sampling rate?

Because straight lines between fixes cut every deviation shorter than the interval. Measured length fell 21.5% between 1-second and 2-minute sampling on real traces.

Can I recover detail by interpolating to a higher rate?

No. Interpolation adds points along the straight line already assumed, so the measured length does not change. It is useful only for aligning datasets in time.

What should I do about long gaps?

Split the track into legs at gaps above a threshold. Drawing a straight line across a 50-minute gap asserts a journey that was never recorded.

Is a mean interval useful?

Not really. One long gap dominates it. Report the median, the 95th percentile and the maximum.

Why does high-frequency GPS show more impossible speeds?

Because derived speed is a position difference divided by a short interval, so position noise is amplified. Use the receiver's Doppler speed if it is available.

How do I compare two datasets logged at different rates?

Downsample both to the coarser interval before computing anything, and state the interval alongside every distance.