Resampling GPS Traces to a Fixed Interval in pandas

This page answers a narrow question: given one vehicle’s cleaned GPS fixes at an irregular interval, how do you produce a fixed-interval trace in pandas without inventing movement? It extends the trace resampling and densification topic with a single self-contained implementation you can drop into a preprocessing job.

The short version: do not use resample(). Use reindex() with a nearest-neighbour tolerance to move real fixes onto grid slots, then interpolate the remaining holes under an explicit limit, then label every row with where its coordinates came from. resample().mean() averages positions and resample().interpolate() extrapolates past the ends of the trace; both produce coordinates that look like measurements and are not.


Compatibility and Configuration Requirements

Requirement Value Why it matters
pandas ≥ 2.1 limit_area on Series.interpolate behaves consistently from 2.1 onward
Timestamp dtype datetime64[ns, UTC] A naive index silently drops the DST question rather than answering it
Index Monotonic, deduplicated reindex(method="nearest") raises on a non-monotonic index
Input state Outliers already removed Interpolation smears any surviving spike across its neighbours
Coordinate units Degrees, WGS84 Fine for gaps under a minute; project first for anything longer

The grid interval and the interpolation limit are the two parameters that matter, and both should be derived from the device’s observed rate rather than from its configured one. A device advertising 30-second reporting whose 90th-percentile interval is four minutes is not a 30-second device.


The Resampler

from __future__ import annotations

import numpy as np
import pandas as pd


def resample_trace(
    fixes: pd.DataFrame,
    interval: str = "30s",
    tolerance: str = "10s",
    gap_limit: int = 2,
    coord_cols: tuple[str, str] = ("lat", "lon"),
) -> pd.DataFrame:
    """Resample one vehicle's fixes onto a fixed grid.

    Parameters
    ----------
    fixes
        Cleaned fixes for a single vehicle. Must contain a tz-aware ``ts_utc``
        column plus the two coordinate columns.
    interval
        Grid spacing. Never set this finer than the device's median interval —
        upsampling adds rows, not information.
    tolerance
        How far a real fix may be moved to land on a slot. Keep it at or below
        one third of ``interval`` so no two slots compete for the same fix.
    gap_limit
        Maximum number of consecutive empty slots that may be interpolated.
        Anything longer stays null and is reported as missing.

    Returns
    -------
    DataFrame indexed by grid slot with the coordinate columns plus a
    ``provenance`` column taking the values ``observed``, ``interpolated``
    or ``missing``.
    """
    lat_col, lon_col = coord_cols

    src = (
        fixes.loc[:, ["ts_utc", lat_col, lon_col]]
        .dropna(subset=["ts_utc"])
        .drop_duplicates(subset="ts_utc", keep="last")
        .sort_values("ts_utc")
        .set_index("ts_utc")
    )
    if src.empty:
        return src.assign(provenance=pd.Series(dtype="string"))

    grid = pd.date_range(
        src.index.min().floor(interval),
        src.index.max().ceil(interval),
        freq=interval,
        tz="UTC",
    )

    # 1. Alignment only: every value here is a real measurement, possibly
    #    moved by up to `tolerance`. No coordinates are created.
    aligned = src.reindex(grid, method="nearest", tolerance=pd.Timedelta(tolerance))
    observed = aligned[lat_col].notna()

    # 2. Gap-limited interpolation. `limit_area="inside"` refuses to
    #    extrapolate beyond the first and last real fix.
    filled = aligned.copy()
    for col in (lat_col, lon_col):
        filled[col] = aligned[col].interpolate(
            method="time", limit=gap_limit, limit_area="inside"
        )

    # 3. Provenance for every emitted row.
    provenance = np.where(
        observed,
        "observed",
        np.where(filled[lat_col].notna(), "interpolated", "missing"),
    )
    filled["provenance"] = pd.Series(provenance, index=grid, dtype="string")
    filled.index.name = "slot_utc"
    return filled

Three details carry most of the correctness.

method="nearest" with a tolerance, not resample(). Every coordinate that comes out of step one was measured. A slot with two candidate fixes takes the nearer one and discards the other rather than averaging them into a position the vehicle never occupied.

limit=gap_limit counts slots, not seconds. Two slots on a 30-second grid is a minute; the same two slots on a 5-second grid is ten seconds. Expressing the limit in slots means the parameter travels correctly when the grid changes, which it will.

limit_area="inside". Without it, pandas extrapolates before the first fix and after the last one. That silently extends a trace beyond the period anything was observed, which is how a vehicle ends up apparently parked at a customer site for the twenty minutes before it arrived.

Raw fixes, aligned slots, filled grid Irregular raw fixes on a continuous time axis are moved onto the nearest grid slot within the tolerance. Slots with no fix in range stay empty. A final pass fills the one-slot and two-slot holes and leaves the four-slot hole null. 30 s grid, 10 s tolerance, 2-slot limit raw fixes aligned filled 1 slot — filled 2 slots — filled? no: 3 apart The fix near 296 s lands on the 300 s slot because it is inside the tolerance; the one near 640 s is exact. Nothing is created in the middle row. Every filled cell there is a measurement that moved by at most ten seconds.

Execution and Tuning Guidelines

Run it per vehicle-day and concatenate; the function is deliberately single-vehicle so that the grouping stays visible in the caller.

out = (
    fixes.groupby(["vehicle_id", "operating_date"], group_keys=True)
    .apply(lambda g: resample_trace(g, interval="30s", gap_limit=2))
    .reset_index()
)

interval is the parameter people get wrong first. Setting it finer than the device’s median interval produces a table that is mostly interpolation, and every downstream consumer that counts rows — sample counts, variance windows, point-in-polygon tests — will be reading a number driven by the grid rather than by the vehicle. Start at the median observed interval, rounded up to something tidy.

tolerance trades reproducibility against coverage. At one third of the interval, a fix belongs unambiguously to one slot. At half the interval, two slots can both claim it and the winner depends on iteration order, which changes between pandas releases. Raising the tolerance also silently smears the timing of turns, because a fix moved by fourteen seconds at 50 km/h has been moved by nearly two hundred metres along the path.

gap_limit is the safety-critical one. Raising it from 2 to 6 on a 30-second grid means the resampler will now draw a straight line across three minutes of unobserved driving. The effect on totals is not symmetric: filling across a coverage hole tends to under-report distance, while filling across a stop adds distance that never happened.

What raising the gap limit buys and costs As the gap limit rises from one to eight slots, the share of rows with a non-null coordinate rises quickly and then flattens, while the absolute error in reported trip distance grows steadily. Past three slots the coverage gain is under two percent per slot and the distance error is already over four percent. Coverage and distance error against gap_limit, 30 s grid rows with a coordinate distance error usable range 1 2 3 5 8 gap_limit (slots) Coverage past three slots is bought almost entirely with invented positions, and the distance error says so. If coverage genuinely matters more than fidelity, raise the limit and exclude interpolated rows from distance instead.

A useful habit is to emit the provenance distribution as a metric on every run:

share = out.groupby("vehicle_id")["provenance"].value_counts(normalize=True).unstack(fill_value=0)
assert (share.get("interpolated", 0) < 0.25).all(), "device family needs a coarser grid"

An assertion like this one has to be able to fail to be worth anything. Point it at a device family that reports every four minutes and it will fire immediately, which is the correct response — that family should not be on a 30-second grid at all.


Common Pitfalls Specific to This Technique

Interpolating latitude and longitude independently across a curve. Linear interpolation of the two coordinates cuts the corner, so a filled point on a bend sits inside the turn rather than on it. For a single slot at urban speeds the error is a few metres and tolerable; across three slots on a motorway slip road it can be sixty. This is one of the reasons the gap limit is small.

Forgetting that method="time" needs a DatetimeIndex. With a plain integer index, interpolate silently falls back to treating rows as equally spaced, which is exactly wrong on a grid that contains nulls. The resampler sets the index before interpolating for this reason.

Reindexing a non-monotonic index. A trace that has been concatenated from two files, or that contains a device clock stepping backwards, will raise ValueError: index must be monotonic increasing. Sorting is not enough if duplicate timestamps remain — deduplicate first, keeping the last observation, which is what the implementation above does.


Validating the Output Before It Leaves the Job

A resampler is easy to get subtly wrong and hard to notice, because every failure mode produces a well-formed table. Four assertions catch essentially all of them, and all four are cheap enough to run on every partition rather than in a nightly audit.

Row count against elapsed time. The number of emitted slots must equal the span of the trace divided by the interval, plus one. A mismatch means the grid was built from the wrong bounds — usually a timezone-aware and timezone-naive comparison silently succeeding.

Observed count against input count. Every input fix should land on exactly one slot or be dropped for being outside the tolerance. If the observed count exceeds the input count, two slots have claimed the same fix, which means the tolerance is too generous relative to the interval.

Monotonic index. The output index must be strictly increasing with a constant step. A duplicated slot is the signature of a grid built across a daylight-saving fold in local time rather than in UTC.

Provenance distribution within bounds. Assert an upper bound on the interpolated share and alert rather than fail when it is exceeded, because the correct response is usually a configuration change rather than a rejected batch.

def validate(out: pd.DataFrame, src: pd.DataFrame, interval: str) -> None:
    step = pd.Timedelta(interval)
    expected = int((out.index.max() - out.index.min()) / step) + 1
    assert len(out) == expected, f"{len(out)} rows for {expected} slots — check the grid bounds"
    assert out.index.is_monotonic_increasing and out.index.is_unique
    n_obs = int((out["provenance"] == "observed").sum())
    assert n_obs <= len(src), f"{n_obs} observed rows from {len(src)} input fixes — tolerance too wide"

The third assertion is the one that has to be able to fail. Feed the resampler a trace whose grid is built in Europe/Amsterdam rather than UTC and run it across the October clock change: the hour between 02:00 and 03:00 occurs twice, the index gains duplicates, and the assertion fires. A check that has never rejected anything is not evidence that the code is correct.

Four assertions, four distinct failures Row count catches a grid built from the wrong bounds. Observed count catches an over-wide tolerance. Index monotonicity catches a grid built in local time across a clock change. The provenance bound catches a device family placed on a grid far finer than its reporting rate. Each assertion exists because the others miss its failure row count wrong grid bounds tz-naive comparison observed count tolerance too wide two slots, one fix monotonic index grid built in local time across a clock change provenance bound grid far finer than the device rate All four run in under a millisecond on a vehicle-day, so there is no reason to defer them to a nightly audit. Fail the batch on the first three; alert on the fourth, because it is a configuration signal rather than corruption. Test the assertions themselves against a deliberately broken input — an assertion nobody has seen fire proves nothing. Record the assertion version with the output so a later audit knows which checks the data actually passed.