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.
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.
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.
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:
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.
-
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.
Related
- Estimating Positional Error in Field Area Calculations — what boundary uncertainty does to a hectare figure
- Yield Monitor Data Cleaning & Telemetry QA — the cleaning pipeline these flags feed
- Validating Coordinate Systems for Variable-Rate Maps — ruling out a projection error before blaming the receiver