Detecting and Versioning Field Boundary Changes

Problem statement

Every time a field boundary is re-observed, the platform faces the same question: is this a genuine change worth recording, sensor noise to ignore, or an edit so large it needs a human before it becomes truth? Answering it by area alone is a trap. Two polygons can enclose nearly identical acreage while one has a headland shifted ten metres — a real operational change that a pure area check waves through. Answering it by exact-match is the opposite trap: sub-centimetre RTK jitter makes every pass look like a change and floods the history with meaningless revisions. The Field Boundary Synchronization registry depends on this decision being made on more than one axis, so a change is classified honestly and versioned immutably.

This guide implements the differ that registry injects: given the active revision’s geometry and an incoming candidate, it measures three things — relative area delta, intersection-over-union (IoU) overlap, and Hausdorff distance (the worst-case edge excursion) — and combines them to classify the change as unchanged, auto-accept, or human-review. When the verdict is anything but unchanged, it mints a new append-only BoundaryRevision whose parent_hash chains it to the prior shape, emitting an audit entry that carries the decision inputs so the call can be replayed during an inspection. Individual near-edge GPS fixes are judged separately in Reconciling GPS Drift Against Canonical Field Polygons; this module works on whole polygons.

The three metrics are complementary because each is blind to something the others catch. Area delta sees a field that grew or shrank but not one that merely moved. IoU sees loss of overlap — a shape that slid sideways drops its intersection sharply even when its area is unchanged — but a high IoU can still hide a single spike of boundary that juts far out while most of the ring stays put. Hausdorff distance is precisely the metric for that last case: it reports the largest gap between the two rings anywhere along their length, so a ten-metre headland excursion surfaces as a large Hausdorff even when both area and IoU look benign. Using all three means the classifier is hard to fool from any one direction, and the audit entry records the full triplet so a later reviewer can see not just the verdict but the geometry that drove it.

Boundary change detection and versioning flow An active revision and an incoming candidate feed a diff-metrics stage computing relative area delta, IoU overlap, and Hausdorff excursion. A classifier maps the metrics to unchanged, auto-accept, or human-review, and an accepted change appends a new immutable revision with an audit entry whose parent_hash links back to the prior revision. Active revision N canonical polygon geom_hash · parent Incoming geometry from trace or export validated · WGS84 Diff metrics area Δ (relative) IoU (overlap ratio) Hausdorff (max excursion) on active-area CRS Classify vs thresholds unchanged → no revision auto-accept → mint human-review → queue Append-only chain rev N (active) rev N+1 + audit entry parent_hash → rev N

Parameter reference table

Every threshold below moves the line between silent acceptance, an automatic revision, and an escalation to a person. Recommended values assume geometry stored in an equal-area metre CRS so area and Hausdorff distances are in real units.

Parameter Type Recommended value Effect on behavior
unchanged_iou Decimal 0.9999 Overlap at or above this is treated as the same geometry and mints no revision, absorbing RTK jitter.
area_rel_auto Decimal 0.02 Relative area change (as a fraction) at or below which a change may auto-accept. Above it, the change escalates.
iou_auto Decimal 0.98 Overlap floor for auto-accept. Guards against a shape that keeps its area while shifting a headland.
hausdorff_review_m Decimal 8.0 Maximum single edge excursion, in metres, allowed for auto-accept. A larger local move forces review even if area barely changes.
source str Provenance tag carried into the audit entry; drives conflict precedence back in the registry.

The three geometric thresholds are ANDed, not averaged: a change auto-accepts only when the area delta, the overlap, and the worst-case excursion are all within bounds. That is what stops a large local edit from hiding behind a small net area change.

Runnable implementation

The module below classifies a change and mints a revision for it. It targets Python 3.10+, is fully typed, and depends on shapely>=2.0 for the vectorized hausdorff_distance. The classifier returns a report; minting is a separate call so the registry can gate a human_review verdict behind an operator before the revision is written.

python
from __future__ import annotations

import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum

from shapely import hausdorff_distance
from shapely.geometry import mapping
from shapely.geometry.base import BaseGeometry

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


class ChangeVerdict(str, Enum):
    UNCHANGED = "unchanged"
    AUTO_ACCEPT = "auto_accept"
    HUMAN_REVIEW = "human_review"


@dataclass(frozen=True)
class ChangeThresholds:
    unchanged_iou: Decimal = Decimal("0.9999")   # near-identical overlap -> no revision
    area_rel_auto: Decimal = Decimal("0.02")     # <= 2% relative area change auto-accepts
    iou_auto: Decimal = Decimal("0.98")          # >= 0.98 overlap auto-accepts
    hausdorff_review_m: Decimal = Decimal("8.0")  # max edge excursion before review


@dataclass(frozen=True)
class ChangeReport:
    field_id: str
    verdict: ChangeVerdict
    area_rel: Decimal
    iou: Decimal
    hausdorff_m: Decimal


@dataclass(frozen=True)
class BoundaryRevision:
    field_id: str
    revision: int
    geojson: dict
    geom_hash: str
    parent_hash: str | None
    verdict: str
    recorded_at: datetime


def _iou(a: BaseGeometry, b: BaseGeometry) -> Decimal:
    """Intersection-over-union: 1.0 is identical, 0.0 is disjoint."""
    union_area = a.union(b).area
    if union_area == 0:
        return Decimal("0.0000")
    inter_area = a.intersection(b).area
    return (Decimal(str(inter_area)) / Decimal(str(union_area))).quantize(Decimal("0.0001"))


def _geom_hash(geojson: dict) -> str:
    blob = json.dumps(geojson, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def classify_change(
    field_id: str,
    current: BaseGeometry,
    candidate: BaseGeometry,
    thresholds: ChangeThresholds | None = None,
) -> ChangeReport:
    """Diff a candidate geometry against the active revision on three axes.

    Metrics run in the active revision's equal-area CRS, so area and Hausdorff
    distances are in real metres rather than degrees. A near-perfect overlap is
    reported UNCHANGED; a small, low-excursion change auto-accepts; anything
    larger is routed to human review rather than committed blindly.
    """
    thr = thresholds or ChangeThresholds()

    cur_area = Decimal(str(current.area))
    cand_area = Decimal(str(candidate.area))
    area_rel = (
        (abs(cand_area - cur_area) / cur_area).quantize(Decimal("0.0001"))
        if cur_area > 0 else Decimal("1.0000")
    )
    iou = _iou(current, candidate)
    hausdorff_m = Decimal(str(hausdorff_distance(current, candidate))).quantize(Decimal("0.01"))

    if iou >= thr.unchanged_iou:
        verdict = ChangeVerdict.UNCHANGED
    elif (
        area_rel <= thr.area_rel_auto
        and iou >= thr.iou_auto
        and hausdorff_m <= thr.hausdorff_review_m
    ):
        verdict = ChangeVerdict.AUTO_ACCEPT
    else:
        # Any single threshold breach forces review; the axes are ANDed, not
        # averaged, so a large local excursion cannot be masked by a small
        # net area change.
        verdict = ChangeVerdict.HUMAN_REVIEW

    logger.info(json.dumps(
        {"event": "change_classified", "field_id": field_id, "verdict": verdict.value,
         "area_rel": str(area_rel), "iou": str(iou), "hausdorff_m": str(hausdorff_m)}))
    return ChangeReport(field_id, verdict, area_rel, iou, hausdorff_m)


def mint_revision(
    active: BoundaryRevision | None,
    candidate: BaseGeometry,
    report: ChangeReport,
    source: str,
) -> BoundaryRevision:
    """Append a new immutable revision for an accepted or review-approved change.

    Raises for an UNCHANGED verdict: an unchanged geometry never mints a revision,
    which keeps the chain free of no-op churn.
    """
    if report.verdict is ChangeVerdict.UNCHANGED:
        raise ValueError(f"{report.field_id}: unchanged geometry mints no revision")

    geojson = mapping(candidate)
    revision = BoundaryRevision(
        field_id=report.field_id,
        revision=(active.revision + 1) if active else 1,
        geojson=geojson,
        geom_hash=_geom_hash(geojson),
        parent_hash=active.geom_hash if active else None,
        verdict=report.verdict.value,
        recorded_at=datetime.now(timezone.utc),
    )
    # The audit entry is emitted at mint time so the decision inputs travel with
    # the revision and can be replayed during an inspection.
    logger.info(json.dumps(
        {"event": "revision_minted", "field_id": revision.field_id,
         "revision": revision.revision, "parent_hash": revision.parent_hash,
         "geom_hash": revision.geom_hash[:12], "verdict": revision.verdict,
         "source": source, "area_rel": str(report.area_rel), "iou": str(report.iou),
         "hausdorff_m": str(report.hausdorff_m)}))
    return revision

Keeping classify_change and mint_revision separate is what lets the registry auto-mint an auto_accept immediately while parking a human_review verdict in an operator queue and only calling mint_revision once the change is approved — the same conservative escalation the parent subsystem applies to any contested change.

Two details in the code are load-bearing. The metrics run in the active revision’s equal-area CRS, established by the comment on classify_change, because area and Hausdorff distance are meaningless if one geometry is in degrees and the other in metres — a degree of longitude is tens of kilometres, so a raw comparison would classify every field as wildly changed. The _iou helper guards its denominator: two disjoint geometries have zero union area, and returning 0.0000 in that case rather than dividing by zero lets the classifier treat a geometry that has moved entirely off the field as a maximal change routed to review, not a crash. Finally, mint_revision refuses an unchanged verdict outright; making that an error rather than a silent no-op means a caller can never accidentally append a revision that encodes nothing, and the chain stays a faithful log of real changes.

Log patterns and observable signals

Every classification and mint emits structured JSON, so a stored revision can be traced back to the exact geometry comparison that produced it.

Noise absorbed, no revision minted:

json
{"event": "change_classified", "field_id": "CLU-114", "verdict": "unchanged", "area_rel": "0.0001", "iou": "0.9999", "hausdorff_m": "0.04"}

A small change auto-accepted and versioned:

json
{"event": "change_classified", "field_id": "CLU-114", "verdict": "auto_accept", "area_rel": "0.0110", "iou": "0.9860", "hausdorff_m": "3.20"}
{"event": "revision_minted", "field_id": "CLU-114", "revision": 5, "parent_hash": "9f2c41ab...", "geom_hash": "b7d0e91c44a2", "verdict": "auto_accept", "source": "rtk_trace", "area_rel": "0.0110", "iou": "0.9860", "hausdorff_m": "3.20"}

A large local shift escalated despite small net area change:

json
{"event": "change_classified", "field_id": "CLU-114", "verdict": "human_review", "area_rel": "0.0050", "iou": "0.9410", "hausdorff_m": "11.80"}

When triaging, the most informative signal is a human_review verdict with a small area_rel and a large hausdorff_m: that is exactly the headland-shift case a pure area check would have missed, and a rising rate of it points at a real operational change on the ground rather than a data problem. A flood of unchanged verdicts is healthy — it means the registry is absorbing RTK jitter instead of versioning it.

Safe override protocol

Occasionally a change that classified as human_review must be admitted in bulk — a co-op re-surveys a block of fields and the new boundaries are all legitimately different. The override must never rewrite the thresholds and never edit an existing revision; it only records an operator’s approval so mint_revision may run on a reviewed batch.

Guard conditions, all mandatory:

  1. Per-field approval, never a blanket threshold change. The override names each field_id it approves; the module defaults stay intact so the next unattended change is judged normally.
  2. Report before write. The override first re-runs classify_change and surfaces the three metrics for each field, so the approver sees why each was escalated before signing off.
  3. Dry-run first. No revision is minted until the batch runs with persistence enabled and every field has an explicit approval on file.
  4. Immutable audit trail. The approver identity and the metric snapshot are written into the minted revision’s audit entry, so the human decision is as replayable as the automatic one.

Because mint_revision refuses an unchanged verdict and always chains parent_hash to the current head, even an operator-approved batch cannot fork a chain or overwrite a prior shape — it can only append.

Troubleshooting

  • Every pass mints a revision. Root cause: unchanged_iou is set too low, so sub-centimetre jitter clears the bar. Remediation: raise it toward 0.9999; the interior of a stable field should overlap almost perfectly between passes.
  • A real headland shift auto-accepted silently. Root cause: hausdorff_review_m is too high, so a large local excursion slipped under it while area barely moved. Remediation: lower the Hausdorff cap; it is the axis that catches shape changes area and IoU can both miss.
  • area_rel reads as 1.0000 on a valid field. Root cause: the active geometry has zero area — usually a degenerate or unclosed ring reached the differ. Remediation: validate and repair upstream in Parsing ISOXML and Shapefile field boundaries before comparison.
  • Hausdorff distance looks implausibly large. Root cause: the two geometries are in different projections, or one is still in degrees, so the metric is not in metres. Remediation: reproject both to the active revision’s equal-area CRS before calling classify_change.
  • mint_revision raises on a change you expected to store. Root cause: the verdict was unchanged, which mints nothing by design. Remediation: confirm the change actually exceeds unchanged_iou; if it should, the thresholds need tuning, not a bypass of the guard.

Frequently asked questions

Why IoU and Hausdorff instead of just area? Area is blind to shape: two polygons can match in acreage while one has a headland moved ten metres. IoU catches lost overlap and Hausdorff catches the worst-case edge excursion, so a shape change with stable area is escalated rather than accepted.

Why AND the thresholds rather than average them? Averaging would let a large local move hide behind a small net area change. ANDing means all three axes must be within bounds to auto-accept; a breach on any one forces review.

Why separate classification from minting? So the registry can auto-mint an accepted change immediately but hold a review verdict for an operator. The mint call refuses an unchanged verdict and always chains to the head, so no path forks the chain or overwrites a prior shape.

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