RTK GPS Accuracy & Positional Error Budgets
Every spatial decision on a farm has a tolerance, and every position has an error. This topic is about making both explicit, so that a prescription boundary drawn to the metre is not executed by a machine that knows where it is to within three, and so that a yield map’s fine structure is not an artefact of a correction dropout under a tree line. The output is a per-dataset error budget — a single number in metres, derived from named contributions — plus the code that asserts it before the data is used. It sits under ag-GIS data fundamentals and spatial reference systems, alongside the projection discipline in understanding CRS in precision agriculture.
The distinction that matters throughout is between precision and accuracy. An RTK receiver holding a fixed solution is precise to a couple of centimetres relative to its base station; whether those centimetres land in the right place on the earth depends on the base station’s own coordinates, the datum, and the epoch. Machines care about the first. Maps that will be compared across years care about the second.
Prerequisites
- Python 3.11+,
pyproj3.6.,geopandas1.0.,numpy1.26.,pandas2.2. - Position data carrying its fix quality — NMEA GGA quality flag, or the equivalent column in a monitor export
- Receiver and correction details: base station or network provider, baseline length, antenna height and offset from the implement centre
- The declared CRS of each dataset, and ideally its datum realisation epoch — see resolving pyproj datum shift warnings
- A decision tolerance to compare against — implement width, zone size, or the row spacing being controlled
1. Concept: Five Terms, Added in Quadrature
A positional error budget is the combination of independent contributions. Because they are independent, they combine as the square root of the sum of squares rather than by simple addition — which is why the largest single term usually dominates and why halving a small term is wasted effort.
Correction quality. The dominant term, and the one that changes minute to minute. An RTK-fixed solution sits at 1–3 cm horizontally; RTK float, where the integer ambiguity has not resolved, at 20–80 cm; network differential at 0.3–1 m; autonomous at 2–5 m. Under a shelter belt or at the end of a long baseline, a receiver moves between these silently.
Antenna offset and attitude. The antenna is on the cab roof; the working point is the implement, several metres behind and below. On level ground the offset is a fixed translation; on a side slope the machine rolls, and a 3 m antenna height with 8° of roll displaces the projected ground position by about 42 cm. Machines with terrain compensation correct this internally; those without contribute it to every point.
Datum and epoch. A global reference frame and a plate-fixed national datum diverge at plate velocity — a few centimetres a year, which over a decade is comparable to the implement’s steering accuracy. Recording EPSG:4326 without an epoch is recording the shape of the earth and not the year.
Time tagging and latency. A position stamped when the message arrived rather than when it was measured, on a machine travelling at 10 km/h, is displaced by 28 cm for every 100 ms of latency. This term is small, systematic and directional, which makes it the one that produces neat parallel offsets between passes.
Terrain and projection. Reprojection itself contributes millimetres if done properly, but a slope introduces a difference between the ground distance a machine travels and the horizontal distance a map records: at 10° slope the discrepancy is 1.5%, which on a 800 m pass is 12 m of accumulated along-track difference.
The chart makes the practical point: under a fixed solution, antenna geometry dominates and improving the receiver buys nothing; under float, the correction term swamps everything and no amount of careful offset measurement helps. That is why the fix quality flag has to travel with the data.
2. Step-by-Step Implementation
Step 1 — Keep the fix quality and summarise it per pass
import pandas as pd
# NMEA GGA quality indicator → nominal horizontal 1σ, metres.
FIX_SIGMA_M = {0: float("nan"), # invalid
1: 3.0, # autonomous
2: 0.6, # differential
4: 0.02, # RTK fixed
5: 0.40, # RTK float
9: 1.5} # SBAS
def fix_quality_summary(df: pd.DataFrame, quality_col: str = "gps_quality") -> pd.DataFrame:
"""Share of samples and nominal sigma per fix class, per operation."""
assert quality_col in df, f"no {quality_col} column — fix quality was dropped on import"
out = (df.groupby(["operation_id", quality_col])
.size().rename("samples").reset_index())
total = out.groupby("operation_id")["samples"].transform("sum")
out["share"] = out["samples"] / total
out["sigma_m"] = out[quality_col].map(FIX_SIGMA_M)
return out
The values in FIX_SIGMA_M are nominal and should be replaced with figures measured on your own equipment where possible. What matters more than the exact numbers is that they exist as data rather than as an assumption in someone’s head.
Step 2 — Project the antenna offset onto the ground
import numpy as np
def attitude_offset_m(mast_height_m: float, roll_deg: float, pitch_deg: float = 0.0) -> float:
"""Horizontal displacement of the projected ground point due to machine attitude."""
roll = np.deg2rad(roll_deg)
pitch = np.deg2rad(pitch_deg)
dx = mast_height_m * np.tan(roll)
dy = mast_height_m * np.tan(pitch)
return float(np.hypot(dx, dy))
assert round(attitude_offset_m(3.0, 8.0), 2) == 0.42, "8° of roll on a 3 m mast is ~42 cm"
Where a machine records roll and pitch, use them per sample. Where it does not, use a representative value for the field’s terrain — a flat field justifies 1–2°, rolling ground 5–8° — and treat the result as a term in the budget rather than a correction.
Step 3 — Record the datum epoch, not just the code
from pyproj import CRS
def describe_datum(epsg: int) -> dict:
crs = CRS.from_epsg(epsg)
datum = crs.datum
return {
"epsg": epsg,
"datum": datum.name if datum else None,
"is_dynamic": "dynamic" in (datum.type_name.lower() if datum else ""),
"note": "record the observation epoch alongside this code for any dynamic datum",
}
A dynamic reference frame requires an epoch to be a complete coordinate specification. Storing the epoch as a column beside the geometry costs nothing and is the only thing that lets a boundary surveyed in 2019 be compared honestly with one surveyed in 2026.
Step 4 — Combine into a budget
def error_budget_m(sigma_fix: float, mast_height_m: float, roll_deg: float,
epoch_years: float, speed_m_s: float, latency_s: float,
plate_velocity_m_yr: float = 0.025, terrain_m: float = 0.05) -> dict:
"""Combine independent horizontal error terms in quadrature."""
terms = {
"fix": sigma_fix,
"attitude": attitude_offset_m(mast_height_m, roll_deg),
"datum_epoch": plate_velocity_m_yr * epoch_years,
"latency": speed_m_s * latency_s,
"terrain": terrain_m,
}
total = float(np.sqrt(sum(v ** 2 for v in terms.values())))
dominant = max(terms, key=terms.get)
return {"terms": terms, "total_m": total, "dominant": dominant}
Reporting the dominant term alongside the total is what makes the budget actionable. A total of 0.55 m dominated by fix says “exclude the float sections”; the same total dominated by attitude says “the terrain compensation is off”.
Step 5 — Assert against the decision’s tolerance
TOLERANCE_M = {
"guidance_pass_to_pass": 0.05,
"planter_row_control": 0.15,
"section_control_24m": 0.60,
"zone_prescription_30m": 1.50,
"field_area_reporting": 2.00,
}
def assert_fit_for(purpose: str, budget: dict) -> None:
tol = TOLERANCE_M[purpose]
assert budget["total_m"] <= tol, (
f"positional budget {budget['total_m']:.2f} m exceeds the {tol:.2f} m tolerance for "
f"{purpose}; dominant term is {budget['dominant']}")
3. Key Parameters and Tuning
| Parameter | Type | Default | Agronomic effect |
|---|---|---|---|
| Minimum acceptable fix class | int |
4 (RTK fixed) for row control | Admitting float positions into row-level work misplaces seed by up to a metre; excluding them from zone work needlessly discards usable data |
| Baseline length | km |
< 20 | RTK error grows roughly 1 mm per km of baseline plus atmospheric effects; beyond ~30 km fixed solutions become intermittent |
| Mast height | m |
3.0 | Multiplies every degree of roll; a lower antenna is the cheapest accuracy improvement on rolling ground |
| Terrain compensation | on/off | on | Without it, roll error is systematic across a slope and biases every zone boundary downhill |
| Assumed latency | s |
0.035 | Produces an along-track offset proportional to speed; a systematic per-pass shift that looks like a boundary error |
| Datum epoch tolerance | yr |
1 | Comparing datasets more than a few years apart without epochs introduces centimetres per year of apparent drift |
| Fix-loss gap threshold | s |
5 | Longer gaps should split a pass rather than be interpolated across; interpolating over a dropout invents a straight line the machine did not drive |
4. Edge Cases and Failure Modes
Silent degradation under tree lines. A headland beside a shelter belt loses fix on every pass, at the same place, every year. Because it is spatially consistent, the resulting error looks like a real field feature — a persistently poor strip — and has been known to trigger drainage investigations on ground that is perfectly fine.
A base station moved. A local base whose coordinates were re-entered, or a network provider changing realisation, shifts everything measured afterwards by the difference. The symptom is an abrupt offset between seasons with no error anywhere. Record base station identity and coordinates with every operation.
Mixed correction sources in one field. Two machines on the same field with different correction services can disagree by tens of centimetres. For prescriptions this is usually tolerable; for anything comparing passes — a strip trial, a controlled-traffic system — it is not.
Interpolating across a dropout. Filling a 30-second gap with a straight line places yield on ground the combine never crossed. Split the pass instead, and record the gap; this is the same discipline as the segment handling in yield monitor data cleaning and telemetry QA.
Vertical error mistaken for horizontal. Vertical error in GNSS is typically 1.5–2.5 times the horizontal figure. Elevation-derived products — drainage models, terrain-based zones — need their own budget and should never inherit the horizontal one.
Averaging away the evidence. Aggregating positions to a grid before checking fix quality destroys the only record of which cells were measured well. Filter first, then aggregate.
5. Verification and Output Validation
The strongest verification is a repeated pass over known ground.
import geopandas as gpd
def repeatability_check(pass_a: gpd.GeoDataFrame, pass_b: gpd.GeoDataFrame,
expected_m: float) -> float:
"""Median separation between two passes down the same line, in a metre-based CRS."""
assert pass_a.crs == pass_b.crs and pass_a.crs.is_projected, (
"compare passes in a projected CRS, not in degrees")
joined = gpd.sjoin_nearest(pass_a, pass_b, how="inner", distance_col="sep_m")
median = float(joined["sep_m"].median())
assert median < 3 * expected_m, (
f"median pass-to-pass separation {median:.2f} m against an expected {expected_m:.2f} m — "
"the receiver is not performing to its stated class")
return median
Run it once per season on a fixed reference line — a farm track, a fence line — and store the result. A number that creeps upward across seasons is an antenna, a mount or a correction subscription degrading, and it is far cheaper to find that way than by discovering a season of misplaced yield data at harvest.
Prove the gate works by feeding it two passes offset by a metre and asserting it raises. As everywhere on this site, a check that has never rejected anything is not evidence.
6. Integration with the Broader Pipeline
The error budget is a precondition for nearly every other page in this section. It sets the buffer distance when clipping rasters to field boundaries — a boundary known to a metre should not be clipped to the pixel — and it sets the minimum sensible zone size in management zone classification algorithms, since a zone narrower than the positional error cannot be executed. It determines whether kriging fine structure in interpolating sparse yield monitor data with kriging is signal or positional noise, and it is the first thing to check when validating coordinate systems for variable-rate maps reports an unexpected offset.
Frequently Asked Questions
Is a correction subscription worth it for zone-level work? For 30 m management zones and 24 m section control, network differential at sub-metre is usually adequate and RTK is a convenience. The economics change entirely for planting, strip tillage and controlled traffic, where pass-to-pass repeatability is the product.
How do I know my antenna offset is right? Drive a straight line in both directions over the same physical mark and compare where the data puts it. A consistent offset perpendicular to travel is a lateral antenna error; a consistent offset along travel is latency or a longitudinal offset. Both are measurable in twenty minutes and both are commonly wrong.
Should I filter out non-RTK points or keep them flagged? Keep and flag. Removing them silently creates gaps that interpolation then fills with invention. Carrying the flag lets each consumer decide: a zone map can use differential positions happily, and a row-level analysis can exclude them explicitly.
This topic is part of Ag-GIS Data Fundamentals & Spatial Reference Systems — see there for the projection and format discipline the budget sits inside.
Related
- Correcting GPS Drift in Yield Monitor Tracks — detecting and handling fix-quality changes within a single pass
- Estimating Positional Error in Field Area Calculations — turning a metre of boundary uncertainty into an area tolerance in hectares
- Understanding CRS in Precision Agriculture — the projection side of positional correctness
- Validating Coordinate Systems for Variable-Rate Maps — checks that catch an offset before it reaches a controller
- Yield Monitor Data Cleaning & Telemetry QA — where fix quality becomes a filtering decision