Reconciling GPS Drift Against Canonical Field Polygons

Problem statement

A guidance receiver reports a position with a confidence radius, not a certainty. Near the middle of a field that radius is irrelevant, but along a headland — where a sprayer runs its boom right up to the recorded edge — a metre of drift decides whether a fix reads as inside the field or over the fence line. The reconciliation service in Field Boundary Synchronization leans on that verdict: a fix accepted just outside the canonical polygon can nudge acreage and setback math, while a fix wrongly rejected drops coverage from an application record. This guide covers the narrow decision at the boundary — given one GPS fix and the field’s canonical polygon, do we accept it, tolerate it as edge drift, or quarantine it — and one rule that must never be broken: an out-of-bounds point is never snapped onto the edge to make it fit.

The trap is treating all fixes as equally trustworthy. A real-time-kinematic (RTK) fix is corrected to a few centimetres and can be believed almost to the vertex; an uncorrected consumer-grade GNSS fix can wander several metres and needs a wider tolerance before its near-edge position means anything. Applying one fixed buffer to both either quarantines good RTK data or launders metres of uncorrected drift into the field. The remedy is a per-correction-class drift budget, a degraded-fix gate on horizontal dilution of precision (HDOP), and a containment-tolerance buffer that admits a near-edge point without moving it. The canonical polygons themselves are produced upstream by Parsing ISOXML and Shapefile field boundaries; this module only judges fixes against them.

The cost of getting this wrong is asymmetric, which is why the safe default is to quarantine rather than drop or snap. A fix wrongly quarantined is recoverable: it sits in a review queue with its raw coordinates intact and can be admitted later once an operator or a boundary revision resolves the ambiguity. A fix wrongly accepted is not recoverable in the same way — it has already flowed into an application record, an acreage total, and any setback calculation keyed on that pass, and unwinding it means reversing every downstream artefact that trusted it. Because a boundary edge is exactly where an over-fence application would breach a setback, a near-edge fix is also the one most likely to matter for compliance, so the module errs toward holding an uncertain point rather than letting it through and hoping the error averages out across the pass.

GPS fix accept, tolerate, or quarantine decision flow A GPS fix passes through three checks — boundary containment, an HDOP ceiling, and a per-class drift-budget distance test. Containment accepts the fix as inside; an HDOP breach quarantines it; a within-budget near-edge fix is accepted as within tolerance without being moved, and an over-budget fix is quarantined. A legend lists the RTK, DGPS, and uncorrected drift budgets in metres. GPS fix lon · lat · hdop covered by boundary? HDOP ≤ ceiling? distance ≤ drift budget? INSIDE accept · keep coords WITHIN TOLERANCE accept · never snap QUARANTINE keep raw coords · manual review Drift budgets (metres) rtk 0.30 · dgps 1.50 · uncorrected 4.00 HDOP over ceiling ignores the budget yes no yes no yes no

Parameter reference table

Every value below changes whether a near-edge fix enters the record or is held back. Recommended values assume boundaries and fixes both projected to an equal-area metre CRS, so a budget of 0.30 means thirty centimetres on the ground.

Parameter Type Recommended value Effect on behavior
rtk budget Decimal 0.30 Edge tolerance for a real-time-kinematic fix. Tight, because RTK is trusted to a few centimetres; widening it launders drift the correction already removed.
dgps budget Decimal 1.50 Tolerance for a differential fix — looser than RTK, far tighter than uncorrected.
uncorrected budget Decimal 4.00 Tolerance for consumer-grade GNSS. Wide enough to admit honest wander at a headland, not so wide it swallows a neighbouring field.
hdop_ceiling float 5.0 HDOP above which no budget applies and the fix is quarantined outright. A geometrically weak fix cannot be trusted near an edge regardless of correction class.
containment test boundary.covers(point) Treats a point exactly on the edge as inside, avoiding a knife-edge rejection of a legitimately on-line pass.
tolerance buffer boundary.buffer(budget) Dilates the polygon by the budget so a near-edge point is admitted by membership — never by moving the point onto the ring.

Hardcode the budget table in a version-stamped registry rather than scattering literals through controller code, so one parser upgrade re-evaluates every historical fix under the same rule.

Runnable implementation

The module below judges one fix, or a whole pass, against a canonical polygon. It targets Python 3.10+, is fully typed, and depends only on shapely. The decisive design choice is that ContainmentResult echoes the original lon/lat on every branch: accepting a near-edge fix records where the receiver actually said it was, never a coordinate snapped onto the boundary.

python
from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum

from shapely.geometry import Point, Polygon

logger = logging.getLogger("boundary.gps_drift")

# Drift budgets in metres, keyed by correction quality. An RTK-corrected fix is
# trusted to a few centimetres; an uncorrected GNSS fix can wander metres and so
# earns a wider edge tolerance. These are budgets, never licence to move a point.
_DRIFT_BUDGET_M: dict[str, Decimal] = {
    "rtk": Decimal("0.30"),
    "dgps": Decimal("1.50"),
    "uncorrected": Decimal("4.00"),
}


class FixVerdict(str, Enum):
    INSIDE = "inside"
    WITHIN_TOLERANCE = "within_tolerance"
    QUARANTINED = "quarantined"


@dataclass(frozen=True)
class GpsFix:
    field_id: str
    lon: float          # equal-area metres (projected), not raw degrees
    lat: float
    fix_type: str       # "rtk" | "dgps" | "uncorrected"
    hdop: float
    observed_at: str    # ISO 8601 UTC


@dataclass(frozen=True)
class ContainmentResult:
    verdict: FixVerdict
    distance_m: Decimal
    budget_m: Decimal
    # The original coordinates are always echoed back unchanged. Accepting a
    # near-edge fix never rewrites its position onto the boundary.
    lon: float
    lat: float


def evaluate_fix(
    fix: GpsFix, boundary: Polygon, hdop_ceiling: float = 5.0
) -> ContainmentResult:
    """Accept, tolerate, or quarantine one fix against a canonical field polygon.

    A point strictly inside the boundary is accepted outright. A point outside is
    accepted only if a degraded-fix HDOP gate passes and it lies within the drift
    budget for its correction class; otherwise it is quarantined. The point is
    never snapped onto the edge under any branch.
    """
    point = Point(fix.lon, fix.lat)
    budget = _DRIFT_BUDGET_M.get(fix.fix_type, _DRIFT_BUDGET_M["uncorrected"])

    if boundary.covers(point):
        logger.info(json.dumps(
            {"event": "fix_inside", "field_id": fix.field_id,
             "fix_type": fix.fix_type}))
        return ContainmentResult(
            FixVerdict.INSIDE, Decimal("0.00"), budget, fix.lon, fix.lat)

    distance = Decimal(str(point.distance(boundary))).quantize(Decimal("0.01"))

    # A noisy fix cannot claim its nominal budget near an edge: widen nothing,
    # quarantine instead of trusting an imprecise position.
    if fix.hdop > hdop_ceiling:
        logger.warning(json.dumps(
            {"event": "fix_hdop_exceeded", "field_id": fix.field_id,
             "hdop": fix.hdop, "ceiling": hdop_ceiling, "distance_m": str(distance)}))
        return ContainmentResult(
            FixVerdict.QUARANTINED, distance, budget, fix.lon, fix.lat)

    # Containment tolerance buffer: dilate the boundary by the drift budget and
    # test membership. Equivalent to distance <= budget but keeps the geometry
    # explicit for downstream visualization.
    tolerance_zone = boundary.buffer(float(budget))
    if tolerance_zone.covers(point):
        logger.info(json.dumps(
            {"event": "fix_within_tolerance", "field_id": fix.field_id,
             "fix_type": fix.fix_type, "distance_m": str(distance),
             "budget_m": str(budget)}))
        return ContainmentResult(
            FixVerdict.WITHIN_TOLERANCE, distance, budget, fix.lon, fix.lat)

    logger.warning(json.dumps(
        {"event": "fix_quarantined", "field_id": fix.field_id,
         "fix_type": fix.fix_type, "distance_m": str(distance),
         "budget_m": str(budget), "action": "manual_review"}))
    return ContainmentResult(
        FixVerdict.QUARANTINED, distance, budget, fix.lon, fix.lat)


def classify_track(
    fixes: list[GpsFix], boundary: Polygon, hdop_ceiling: float = 5.0
) -> dict[FixVerdict, int]:
    """Summarize a full pass: how many fixes were inside, tolerated, or held."""
    tally: dict[FixVerdict, int] = {v: 0 for v in FixVerdict}
    for fix in fixes:
        result = evaluate_fix(fix, boundary, hdop_ceiling)
        tally[result.verdict] += 1
    return tally

The order of the checks is deliberate. Containment is tested first because a point genuinely inside the field needs no budget at all. The HDOP gate runs before the tolerance buffer so a geometrically weak fix can never buy its way inside on a wide uncorrected budget. Only a fix that is outside, precise enough to trust, and within its class budget earns WITHIN_TOLERANCE — and even then its recorded position is the receiver’s own, unmoved.

covers rather than contains is used for the containment test on purpose: contains returns false for a point sitting exactly on the ring, which would knife-edge-reject a legitimately on-line pass at the field edge, while covers treats a boundary-coincident point as inside. The tolerance buffer is built with boundary.buffer(float(budget)) rather than by comparing point.distance(boundary) to the budget directly; the two are numerically equivalent for a simple polygon, but keeping the dilated zone as an explicit geometry lets a review UI render the tolerance band and lets the same object be reused when a whole track is evaluated, so the buffer is not rebuilt per fix in hot code. classify_track exists for exactly that batch case: it rolls a pass into a tally of inside, tolerated, and quarantined counts, which is the shape the monitoring layer consumes.

Log patterns and observable signals

Every evaluation emits structured JSON so a stored or held fix can be traced back to the exact rule that judged it.

Accepted inside the field:

json
{"event": "fix_inside", "field_id": "CLU-114", "fix_type": "rtk"}

Accepted as edge drift within budget:

json
{"event": "fix_within_tolerance", "field_id": "CLU-114", "fix_type": "uncorrected", "distance_m": "2.71", "budget_m": "4.00"}

Quarantined for a geometrically weak fix:

json
{"event": "fix_hdop_exceeded", "field_id": "CLU-114", "hdop": 7.4, "ceiling": 5.0, "distance_m": "1.02"}

Quarantined for drift beyond the class budget:

json
{"event": "fix_quarantined", "field_id": "CLU-114", "fix_type": "rtk", "distance_m": "0.88", "budget_m": "0.30", "action": "manual_review"}

When triaging, watch the ratio of fix_within_tolerance to fix_inside per field. A headland that is mostly tolerance-band acceptances rather than clean interior fixes is a signal the canonical polygon has drifted from the ground and should be routed to Detecting and Versioning Field Boundary Changes for a revision, not endlessly tolerated fix by fix. A spike in fix_hdop_exceeded usually points at a correction-service outage upstream rather than a boundary problem.

Safe override protocol

Occasionally a batch of legitimately out-of-budget fixes must be admitted — a field whose canonical polygon is known to be stale pending re-survey, or a controlled headland-overspray study. The override must never move a point and never bypass the verdict; it only records an operator’s decision to accept a quarantined fix downstream.

Guard conditions, all mandatory:

  1. Explicit budget, never a silent widening. An override names a one-time widened budget for a specific field_id; the module’s default table is never edited to force the batch through.
  2. Coordinates preserved. The override annotates the fix as operator-admitted but stores the original lon/lat unchanged, so the raw drift stays visible in the record.
  3. Dry-run first. The override runs with persistence disabled and reports how many fixes it would flip from QUARANTINED before any write.
  4. Immutable audit trail. The field_id, the widened budget, the fix count, and the operator identity are written to an append-only ledger and flagged for compliance review.
python
def override_quarantine(
    fixes: list[GpsFix],
    boundary: Polygon,
    field_id: str,
    widened_budget_m: Decimal,
    approver: str,
    dry_run: bool = True,
) -> int:
    admitted = 0
    for fix in fixes:
        if fix.field_id != field_id:
            continue
        point = Point(fix.lon, fix.lat)
        # Explicit, one-time budget; the point is measured, never moved.
        if point.distance(boundary) <= float(widened_budget_m):
            admitted += 1
    logger.warning(json.dumps(
        {"event": "drift_override", "field_id": field_id,
         "widened_budget_m": str(widened_budget_m), "admitted": admitted,
         "approver": approver, "dry_run": dry_run}))
    return admitted  # caller persists only when dry_run is False and review passes

Troubleshooting

  • Good RTK fixes are being quarantined at a headland. Root cause: the RTK budget is too tight for the local antenna offset, or fixes and boundary are in different projections so a real match reads as metres apart. Remediation: confirm both are in the same equal-area CRS before evaluating; do not widen the RTK budget to paper over a projection mismatch.
  • Uncorrected fixes from a neighbouring field are being accepted. Root cause: the uncorrected budget is wide enough to reach across a narrow boundary. Remediation: lower the uncorrected budget, and prefer quarantining ambiguous fixes over admitting them, since a misattributed pass is worse than a missing one.
  • A fix reads as fix_hdop_exceeded even though it looks precise. Root cause: HDOP reflects satellite geometry, not correction quality, so a corrected fix under poor sky view still fails the gate. Remediation: this is intended — a geometrically weak fix near an edge is untrustworthy regardless of correction; route it to manual review.
  • distance_m is always zero for outside points. Root cause: the boundary passed in is a filled Polygon but the point is being measured against its interior, or covers is short-circuiting on an invalid geometry. Remediation: validate the polygon upstream with the boundary decoder and confirm boundary.is_valid before evaluation.
  • The same fix flips between tolerated and quarantined across runs. Root cause: the canonical polygon is being revised between runs. Remediation: pin evaluations to a specific boundary revision from the Field Boundary Synchronization registry rather than always reading the head.

Frequently asked questions

Why not snap an out-of-bounds fix onto the edge? Snapping fabricates a position the receiver never reported and hides the drift. A trustworthy near-edge fix is accepted as within-tolerance with its original coordinates; an untrustworthy one is quarantined. The stored point is always the receiver’s own.

Why different budgets for RTK and uncorrected fixes? RTK is trusted to centimetres, so its tolerance is tight; uncorrected GNSS wanders metres and needs a wider band. A single buffer for both would reject good RTK or admit uncorrected error.

Why does a high HDOP override the budget? HDOP reflects satellite geometry, not correction quality. A geometrically weak fix near an edge is untrustworthy regardless of how close it is, so it is quarantined for review rather than admitted.

Up: Field Boundary Synchronization · Farm Data Ingestion & Field Boundary Synchronization