Correcting GPS Drift in Yield Monitor Tracks

One-sentence answer: segment each pass by fix quality, detect jumps against a speed-derived plausible distance, measure lateral offset against the neighbouring pass, and split at dropouts — flagging degraded segments rather than smoothing them into something that looks correct.

Context

The most persuasive artefact in a yield map is a strip of poor yield along a shelter belt that appears every year. It looks like a real agronomic feature — competition for water, shading, compaction on the turn row — and sometimes it is. Just as often it is the receiver losing its fixed solution under the trees on every pass, at the same place, for the same twenty seconds.

Because the artefact is spatially consistent, no statistical outlier filter will find it. The only evidence that distinguishes the two explanations is the fix quality flag recorded alongside the position, which is why the error budget insists it travels with the data. This guide is what to do with it once it has.

The same dropout on every pass looks like a field feature Four parallel harvest passes running left to right beside a tree line at the top. Each pass has a segment near the same along-track position where the fix degrades from RTK fixed to float, drawn offset laterally from the intended line. A note records that the artefact repeats every season and reads as a persistent poor strip. tree line — sky obstructed, corrections intermittent RTK float on every pass, same place, every season RTK fixed — 2 cm RTK float — 20–80 cm, drifting Flag the segment; do not smooth it, and do not conclude anything agronomic about that strip.

Prerequisites

Beyond the parent topic’s stack: telemetry with a fix quality column and ground speed, already projected to a metre-based CRS, and passes reconstructed as in yield monitor data cleaning and telemetry QA.

Step-by-step

1. Segment by fix quality within each pass.

Five steps that keep the evidence rather than tidying it away Five steps for handling positional degradation within a pass: segment the pass into runs of constant fix quality, flag position jumps that exceed the distance the recorded speed allows, split at dropouts instead of interpolating across them, measure lateral offset against neighbouring passes, and flag rather than delete. Segment by fix runs of one quality Flag jumps against speed × time Split at gaps never interpolate Measure offset against neighbouring passes Flag, keep never delete Interpolating across a dropout draws a straight line the machine may never have driven, and attributes real grain to ground it never crossed.

2. Detect implausible jumps using speed and elapsed time.

3. Measure lateral offset against the adjacent pass.

4. Split at dropouts.

5. Flag, do not delete.

PYTHON
import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.geometry import LineString

FIX_SIGMA_M = {0: np.nan, 1: 3.0, 2: 0.6, 4: 0.02, 5: 0.40, 9: 1.5}
TRUSTED_FIXES = (4,)                       # RTK fixed only, for row-level work


def segment_by_fix(g: gpd.GeoDataFrame, quality_col: str = "gps_quality") -> gpd.GeoDataFrame:
    """Give each run of constant fix quality within a pass its own segment id."""
    assert quality_col in g, f"no {quality_col} column — fix quality was dropped on import"
    g = g.sort_values(["pass_id", "ts"]).copy()
    changed = (g[quality_col] != g[quality_col].shift()) | (g["pass_id"] != g["pass_id"].shift())
    g["segment_id"] = changed.cumsum().astype("int32")
    g["sigma_m"] = g[quality_col].map(FIX_SIGMA_M)
    g["degraded"] = ~g[quality_col].isin(TRUSTED_FIXES)
    share = float(g["degraded"].mean())
    print(f"{share:.1%} of samples are not RTK-fixed")
    return g


def flag_jumps(g: gpd.GeoDataFrame, speed_col: str = "speed_m_s",
               tolerance: float = 3.0) -> gpd.GeoDataFrame:
    """Flag samples whose step exceeds what the recorded speed could produce."""
    g = g.sort_values(["pass_id", "ts"]).copy()
    dt = g.groupby("pass_id")["ts"].diff().dt.total_seconds()
    step = np.hypot(g.geometry.x.diff(), g.geometry.y.diff())
    plausible = g[speed_col].shift().fillna(0) * dt * tolerance + 1.0
    g["jump"] = (step > plausible) & dt.notna()
    print(f"{int(g['jump'].sum())} position jump(s) beyond {tolerance}× the plausible step")
    return g


def split_on_gaps(g: gpd.GeoDataFrame, gap_s: float = 5.0) -> gpd.GeoDataFrame:
    """Break a pass at dropouts instead of interpolating a line the machine may not have driven."""
    g = g.sort_values(["pass_id", "ts"]).copy()
    dt = g.groupby("pass_id")["ts"].diff().dt.total_seconds()
    brk = (dt > gap_s) | g["jump"]
    g["pass_id"] = (g["pass_id"].astype(str) + "_" +
                    brk.groupby(g["pass_id"]).cumsum().astype(int).astype(str))
    return g


def lateral_offset(g: gpd.GeoDataFrame) -> pd.DataFrame:
    """Median distance from each pass's degraded samples to the nearest neighbouring pass line."""
    lines = {}
    for pid, chunk in g[~g["degraded"]].groupby("pass_id"):
        if len(chunk) > 1:
            lines[pid] = LineString(list(zip(chunk.geometry.x, chunk.geometry.y)))

    rows = []
    for pid, chunk in g[g["degraded"]].groupby("pass_id"):
        others = [ln for other, ln in lines.items() if other != pid]
        if not others or chunk.empty:
            continue
        dists = [min(ln.distance(pt) for ln in others) for pt in chunk.geometry]
        rows.append({"pass_id": pid, "n": len(dists), "median_offset_m": float(np.median(dists))})
    return pd.DataFrame(rows)

Inline verification — check that degraded segments really are displaced, and by how much:

PYTHON
g = split_on_gaps(flag_jumps(segment_by_fix(points)))
offsets = lateral_offset(g)
print(offsets.sort_values("median_offset_m", ascending=False).head())

worst = offsets["median_offset_m"].max()
assert worst < 5.0, (
    f"a degraded segment sits {worst:.1f} m from its neighbours — that is not drift, "
    "it is a wrong field match or a base-station change")
degraded_share = float(g["degraded"].mean())
assert degraded_share < 0.25, (
    f"{degraded_share:.0%} of the field was harvested without a fixed solution — "
    "the map is not suitable for zone work at this resolution")

Settings worth being deliberate about

Setting Default here Why it matters
Trusted fix classes RTK fixed only Right for row-level work. For 30 m zone mapping, differential positions are perfectly usable and excluding them discards good data
Jump tolerance 3× the speed-derived step Scales with the machine’s own speed, so a slow pass in heavy crop is judged against its own plausible distance rather than a global limit
Gap threshold 5 s Longer gaps split the pass. Interpolating across one invents a straight line and attributes grain to ground never crossed
Minimum segment length ~20 samples Below this a segment’s statistics are meaningless; keep the points flagged, exclude them from per-pass measures
Degraded-share limit 25% of a field Above it the field’s map is not suitable for zone work at the resolution being attempted, whatever the filters do afterwards
Offset alarm 5 m from neighbours Beyond this it is no longer drift — it is a wrong field match or a base-station change

Store the fix class alongside every point rather than a boolean. The classes carry different error magnitudes, and a consumer deciding whether it can trust a 15 m neighbourhood needs the magnitude, not just “good” or “bad”.

Gotchas and edge cases

  • A repeating spatial artefact is the signature of obstruction, not agronomy. Before concluding anything about a persistent poor strip, group the fix quality by location: if the degraded samples cluster in the same place across passes and seasons, the sky is the explanation.
Four patterns, and which of them is a real field feature A table of four spatial patterns seen in yield telemetry: a recurring poor strip beside a tree line, a constant whole-pass offset, an offset that grows along a pass, and a single large jump. Each row gives the usual cause and the test that distinguishes it from agronomy. Pattern in the data What it usually is How to tell Poor strip beside a tree line every season Correction dropout, not agronomy Group fix quality by location Whole pass offset, constant one direction Antenna offset or base change Drive a line in both directions Offset that grows along a pass drifting Solution degraded mid-pass Segment by fix class Sudden 20 m jump single sample Receiver glitch Speed-derived plausible step
  • Do not shift a pass by its average offset. A constant offset with a documented cause — a measured antenna position, a base-station move — can be corrected. A drifting offset means the solution degraded within the pass, and correcting by the mean makes the good half wrong.

  • A jump detector needs the recorded speed, not a global limit. A combine at 6 km/h and one at 1 km/h in heavy crop have very different plausible steps. Using a fixed metres-per-sample threshold flags the fast machine constantly and misses the slow one entirely.

  • Splitting creates short segments, and short segments distort per-pass statistics. After splitting, discard segments below roughly twenty samples for statistical purposes while keeping their points flagged in the data — the same distinction between flagging and deleting made throughout.

  • Vertical error is larger than horizontal. If elevation is being used for anything — drainage, terrain-derived zones — it needs its own quality assessment rather than inheriting the horizontal fix class.

  • Fix quality is often lost at import, not at capture. Many export and conversion tools keep position, time and yield and quietly drop the quality column, because it is not needed to draw a map. By the time the data reaches a cleaning pipeline the evidence is gone and every sample looks equally trustworthy. Check for the column at ingest and treat its absence as a defect in the export configuration rather than as a property of the machine — most monitors record it, and recovering it later means re-exporting the original file.


This guide is part of RTK GPS Accuracy & Positional Error Budgets — see there for the full error budget these flags feed into.