How to Resample and Interpolate a Track to a Fixed Interval

On this page

Problem statement

Two datasets logged at different rates cannot be compared. The finer one reports longer journeys for identical driving, because straight lines between fixes cut fewer corners.

Measured on 46 real tracks from Brighton, downsampled from their native rate of about one fix a second (Example 4):

native   163.17 km
   1 s   163.17 km    -0.0%
   2 s   162.32 km    -0.5%
   5 s   160.65 km    -1.5%
  10 s   158.27 km    -3.0%
  30 s   151.38 km    -7.2%
  60 s   142.92 km   -12.4%
 120 s   133.11 km   -18.4%

Resampling is how you make them comparable β€” and the direction matters. Downsampling to a common interval works; upsampling to recover detail does not.

Quick answer

Downsample by time, not by row count:

import numpy as np


def downsample(sub, step_s):
    """Keep the first fix, then the next fix at least step_s later."""
    elapsed = (sub["time"] - sub["time"].iloc[0]).dt.total_seconds().values
    keep = [0]
    for i in range(1, len(elapsed)):
        if elapsed[i] - elapsed[keep[-1]] >= step_s:
            keep.append(i)
    return sub.iloc[keep]

Taking every nth row instead gives a variable interval whenever the source rate varies, which it always does β€” the tracks measured here had a median interval of 1 s and a maximum of 61,691 s.

Downsampling discarding fixes to a common interval against upsampling inserting interpolated points that add no path length. two directions, two different operationsdownsampleβœ“discards real fixesβœ“length falls, measurablyβœ“makes datasets comparableβœ“the only safe directionupsampleβœ•adds points on the assumed lineβœ•length does not changeβœ•aligns two datasets in timeβœ•recovers nothingInterpolation adds rows along a line already assumed between fixes spatialworkflow.io
Downsampling loses detail honestly. Upsampling adds rows without adding information.

What you get β€” from The data used on this page

46 tracks, 55,316 points

Pick a direction, resample by time, never across a gap

1. Decide which direction you need

  • Downsample to compare datasets, to reduce volume, or to match a model's time step. It loses detail, and you can quantify exactly how much.
  • Upsample to align two datasets in time, or to feed a routine that requires a regular series. It does not recover detail.

Both are legitimate. Confusing them is not.

2. Downsample by elapsed time

The greedy rule above β€” keep a fix if it is at least step after the last kept one β€” produces intervals of at least step and never resamples across a gap in a misleading way.

The alternative, df.resample("30s").first(), produces a regular index with NaN in every empty bin. That is useful when you want a strictly regular series and want the gaps visible, and unhelpful when you want a smaller set of real fixes.

3. Never resample across a gap

The longest gap in these tracks, 61,691 seconds, resampled to 10 seconds would produce 6,169 interpolated points along a straight line that nobody travelled.

df["leg"] = (df["dt"] > gap_s).groupby(df["track_id"]).cumsum()

Split into legs first, resample within each, and leave the gaps as gaps.

4. If upsampling, interpolate positions β€” and mark them

resampled = (sub.set_index("time")[["x", "y"]]
                .resample(f"{step}s").mean()
                .interpolate(method="time"))
resampled["interpolated"] = sub.set_index("time")["x"] \
    .resample(f"{step}s").count().eq(0).values

The interpolated flag is the important line. An interpolated position is a model output, and any statistic computed over the series should be able to exclude it or weight it down.

5. Report the interval with every derived number

Distance, mean speed and time-in-motion are all interval-dependent. State the interval the way you state the units.

A long gap resampled into many interpolated points along a straight line, against splitting into legs and leaving the gap empty. resampling across a gap manufactures a journeyresampled acrossa 17-hour straight line6,169 invented pointssplit firstunobservedthe gap stays emptySplit into legs, resample within each, and leave the gaps as gaps spatialworkflow.io
Resampling across a 50-minute gap manufactures a journey. Split first.

Downsampling, flagged upsampling and matching two datasets

The data used on this page

Public GPS traces from the OpenStreetMap trackpoints API 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 β€” downsampling with the cost reported

import numpy as np
import pandas as pd


def downsample_tracks(df, step_s, id_col="track_id", gap_s=None):
    """Downsample every track to a minimum interval, respecting gaps."""
    if gap_s:
        g = df.groupby(id_col)
        dt = g["time"].diff().dt.total_seconds()
        df = df.copy()
        df["leg"] = (dt > gap_s).groupby(df[id_col]).cumsum()
        group_cols = [id_col, "leg"]
    else:
        group_cols = [id_col]

    kept = []
    for _, sub in df.sort_values(group_cols + ["time"]).groupby(group_cols):
        elapsed = (sub["time"] - sub["time"].iloc[0]).dt.total_seconds().values
        keep = [0]
        for i in range(1, len(elapsed)):
            if elapsed[i] - elapsed[keep[-1]] >= step_s:
                keep.append(i)
        kept.append(sub.iloc[keep])

    out = pd.concat(kept, ignore_index=True)

    def length(frame):
        gg = frame.groupby(group_cols)
        return float(np.hypot(gg["x"].diff(), gg["y"].diff()).sum())

    before, after = length(df), length(out)
    print(f"  {len(df):,} -> {len(out):,} points ({len(out) / len(df):.1%})")
    print(f"  {before / 1000:.2f} km -> {after / 1000:.2f} km "
          f"({after / before - 1:+.1%})")
    return out


every_10s = downsample_tracks(df, 10)
  55,316 -> 7,055 points (12.8%)
  163.17 km -> 158.27 km (-3.0%)

Printing both the point reduction and the distance change makes the trade-off explicit at the moment you make it.

Example 2 β€” upsampling to a regular series, with flags

import numpy as np
import pandas as pd


def upsample_tracks(df, step_s, id_col="track_id", gap_s=300,
                    method="time"):
    """Regular time series per leg, with interpolated rows marked."""
    g = df.groupby(id_col)
    df = df.copy()
    df["leg"] = (g["time"].diff().dt.total_seconds() > gap_s) \
        .groupby(df[id_col]).cumsum()

    out = []
    for keys, sub in df.groupby([id_col, "leg"]):
        sub = sub.sort_values("time").set_index("time")
        regular = sub[["x", "y"]].resample(f"{int(step_s)}s").mean()
        observed = sub[["x"]].resample(f"{int(step_s)}s").count()["x"] > 0
        regular = regular.interpolate(method=method, limit_area="inside")
        regular["interpolated"] = ~observed.reindex(regular.index,
                                                    fill_value=False).values
        regular[id_col], regular["leg"] = keys
        out.append(regular.reset_index())

    result = pd.concat(out, ignore_index=True)
    print(f"  {len(df):,} observed -> {len(result):,} rows, "
          f"{result['interpolated'].mean():.1%} interpolated")
    return result


regular = upsample_tracks(every_10s, 1)
g = regular.dropna(subset=["x"]).groupby(["track_id", "leg"])
print(f"  path length: {np.hypot(g['x'].diff(), g['y'].diff()).sum() / 1000:.2f} km")
  7,055 observed -> 82,511 rows, 91.4% interpolated
  path length: 147.57 km

limit_area="inside" stops the interpolation extending past the last real fix in a leg, which would extrapolate the journey beyond where it was observed.

Example 3 β€” matching two datasets to a common interval

import numpy as np


def harmonise(datasets, id_col="track_id"):
    """Downsample every dataset to the coarsest median interval present."""
    intervals = {}
    for name, df in datasets.items():
        dt = df.groupby(id_col)["time"].diff().dt.total_seconds().dropna()
        intervals[name] = float(dt.median())
        print(f"  {name:16} median interval {intervals[name]:6.1f}s, "
              f"{len(df):,} points")

    target = max(intervals.values())
    print(f"  harmonising everything to {target:.0f}s")

    return {name: downsample_tracks(df, target, id_col=id_col)
            for name, df in datasets.items()}


# the same journeys as if one fleet logged every second and another every 10 s
same = harmonise({"1 s logger": df, "10 s logger": every_10s})
  1 s logger       median interval    1.0s, 55,316 points
  10 s logger      median interval   10.0s, 7,055 points
  harmonising everything to 10s
  55,316 -> 7,055 points (12.8%)
  163.17 km -> 158.27 km (-3.0%)
  7,055 -> 7,055 points (100.0%)
  158.27 km -> 158.27 km (+0.0%)

Choosing the coarsest is the only safe direction. Harmonising upward would upsample the coarse dataset, which changes its row count without changing its measured distance and produces two series that look comparable and are not.

Example 4 β€” the distance table at the top of this page

def path_km(frame, cols=("track_id",)):
    g = frame.groupby(list(cols))
    return float(np.hypot(g["x"].diff(), g["y"].diff()).sum()) / 1000


native = path_km(df)
print(f"native  {native:7.2f} km")
for step in (1, 2, 5, 10, 30, 60, 120):
    thinned = pd.concat([downsample(sub, step) for _, sub in df.groupby("track_id")])
    km = path_km(thinned)
    print(f"{step:4d} s  {km:7.2f} km  {km / native - 1:+7.1%}")

dt = df["dt"].dropna()
print(f"\nintervals: median {dt.median():.0f} s, max {dt.max():,.0f} s; "
      f"the longest gap resampled to 10 s would be {int(dt.max() // 10):,} invented points")
native   163.17 km
   1 s   163.17 km    -0.0%
   2 s   162.32 km    -0.5%
   5 s   160.65 km    -1.5%
  10 s   158.27 km    -3.0%
  30 s   151.38 km    -7.2%
  60 s   142.92 km   -12.4%
 120 s   133.11 km   -18.4%

intervals: median 1 s, max 61,691 s; the longest gap resampled to 10 s would be 6,169 invented points

Downsampling shortens, upsampling adds rows

Why downsampling always shortens the track

Between two kept fixes, the path is assumed straight, so every deviation shorter than the new interval is discarded. Length therefore falls monotonically and never rises.

The measured curve steepens as the interval grows: 0.5% lost at 2 seconds, 3.0% at 10, 18.4% at 120. How fast depends on how sinuous the movement is β€” a motorway drive loses very little, a walk through a park loses a lot.

Why upsampling adds rows and not length

Interpolating between two fixes places new points on the straight line already assumed between them. The path length along a straight line does not change when you add points to it.

So the 10-second log upsampled back to one row a second (Example 2) grows from 7,055 rows to 82,511 β€” 91.4% of them interpolated β€” and measures 147.57 km: less than the 158.27 km it had, because splitting at gaps over five minutes removes the straight lines across them, and nowhere near the native 163.17. The rows increased nearly twelvefold and no lost path came back.

The legitimate uses of upsampling are alignment β€” putting two datasets on the same time base for joining or comparison β€” and satisfying a downstream tool that requires regular sampling. Neither is about recovering detail.

Why linear interpolation is usually enough

More sophisticated interpolators exist β€” cubic splines, Kalman smoothers, map-matched paths β€” and each imports assumptions.

A spline through GPS points overshoots at sharp turns, producing positions outside the corridor the vehicle occupied. A Kalman smoother assumes a motion model that may not fit. Linear interpolation assumes only that the object went roughly from A to B, which is the weakest and safest assumption.

If you need better than linear, the honest improvement is map matching, which brings in external information about where movement is possible.

Why the interval belongs in the metadata

A resampled dataset looks exactly like the original: same columns, same types, same CRS. Nothing in a minute-resampled copy records that its distances run 12.4% short.

Write the interval into the file's metadata or a sidecar, and into the column name if you can β€” length_km_at_10s is ugly and unambiguous.

Two datasets logged at different rates harmonised to the coarser interval before comparison. harmonise to the coarsest interval presentfleet A at 1 sreports more distancefleet B at 60 sreports lessdownsample A to 60sthe only safe directionnow comparablestate the intervalHarmonising upward changes the row count without changing the measured length spatialworkflow.io
Upsampling the coarse dataset would make the two look comparable while leaving the distances different.

limit_area, harmonising downward and splines

  • Downsample by elapsed time, not by taking every nth row.
  • Split at gaps before resampling. Resampling across a 50-minute gap manufactures a journey.
  • Mark interpolated rows so downstream statistics can exclude them.
  • limit_area="inside" stops interpolation extrapolating past the last fix.
  • Harmonise downward, to the coarsest interval present.
  • Upsampling does not change measured length. It cannot.
  • Avoid splines for positions; they overshoot at turns.
  • Record the interval with every distance and speed you report.

FAQ

How do I resample a GPS track to a fixed interval?

Group by track, then keep the first fix and each subsequent fix at least the target interval after the last kept one. Split at long gaps first.

Does resampling change my track distance?

Downsampling does, always downward: measured length fell from 163.17 km to 142.92 km between 1-second and 60-second sampling on real tracks. Upsampling does not change it at all.

Can I upsample to recover detail?

No. Interpolation adds points along the straight line already assumed between fixes, so it adds rows without adding path.

Should I use df.resample() or a greedy filter?

resample for a strictly regular index with visible gaps; the greedy filter when you want a smaller set of real observations at a minimum interval.

How do I compare two datasets logged at different rates?

Downsample both to the coarser median interval before computing anything. Never harmonise upward.

What interpolation method should I use?

Linear. Splines overshoot at turns and place positions outside the corridor the object occupied. If you need better, use map matching.

Should I resample before or after cleaning?

After. Cleaning uses the full-rate data to find spikes, and resampling first can drop the very fix that identifies an error.