Measuring Positional Accuracy Against Control Points

Positional accuracy is the one quality indicator a consumer cannot infer from the data itself, and the one they most need before deciding whether a product is fit for their use. This guide computes it: sample a published layer against a named control set, compute RMSE and CE90 in metres, and publish both alongside the control set identifier so the figure is interpretable. It implements the advisory half of Spatial Data Quality and Validation Standards within Geospatial Data Mesh Fundamentals, and it pairs with Defining SLAs for Spatial Data Products, which decides whether a measured accuracy belongs in the product’s published commitments.

Prerequisites

Requirement Value / Assumption Notes
Tools psql ≥ 14 (PostGIS 3.3), ogrinfo (GDAL ≥ 3.6), python3 ≥ 3.11 PostGIS supplies the geodesic distance
Control set A registered set of surveyed points with published accuracy Identifier and CRS both required
CRS convention Control and product both reprojected to EPSG:4326 for storage; distances computed on geography Never compute distance in degrees
Sample size ≥ 30 matched pairs, ≥ 60 for a defensible CE90 Below 20 the statistic is noise
Access roles gis-data-steward (register a control set), platform-engineer (run the measurement) Control sets are governed artifacts
Environment CATALOG_API, CONTROL_SET_ID, PRODUCT_ID Exported before running

A control set is a governed artifact, not a file someone has locally. It carries an identifier, a CRS, a survey date, its own stated accuracy, and the datum transformation used to bring it into EPSG:4326. Measuring against an unregistered set produces a number nobody else can reproduce or compare against.

Step-by-Step Implementation

1. Register the control set and confirm its provenance

The control set’s own accuracy bounds what you can claim about the product. Measuring a 0.3 m product against a 2 m control set tells you about the control set.

bash
# Register once; the identifier travels with every measurement made against it.
curl -sS -X PUT "$CATALOG_API/control-sets/national-geodetic-2024" \
  -H 'content-type: application/json' \
  -d '{
        "survey_date": "2024-06-01",
        "crs": "EPSG:4326",
        "source_crs": "EPSG:32633",
        "transformation": "NTv2:national-grid-2024",
        "stated_accuracy_m": 0.05,
        "point_count": 412
      }'

Verify the registered accuracy is at least an order of magnitude better than what you intend to claim:

bash
curl -sS "$CATALOG_API/control-sets/$CONTROL_SET_ID" | jq '.stated_accuracy_m'
# For a product you expect near 1 m, anything above 0.2 here makes the claim unsupportable.

2. Match product features to control points

Matching is where most measurement error enters. A control point must be matched to the feature it actually surveys, not to the nearest feature, or the measurement reports matching error rather than positional error.

sql
-- match_control.sql — join control points to product features by a stable reference,
-- falling back to nearest-within-tolerance only where a reference is unavailable.
-- Parameters: :control_set (text), :product (text), :max_match_m (float)
WITH control AS (
    SELECT point_id, ref_code, geom
    FROM control_points
    WHERE control_set = :'control_set'
),
matched AS (
    -- Preferred: an explicit shared reference code. Unambiguous and reproducible.
    SELECT c.point_id, c.geom AS control_geom, f.geom AS product_geom, 'ref' AS method
    FROM control c
    JOIN product_features f
      ON f.ref_code = c.ref_code AND f.product = :'product'

    UNION ALL

    -- Fallback: nearest feature within a tight tolerance, and ONLY where no ref matched.
    SELECT c.point_id, c.geom, f.geom, 'nearest'
    FROM control c
    CROSS JOIN LATERAL (
        SELECT geom
        FROM product_features f
        WHERE f.product = :'product'
          AND ST_DWithin(f.geom::geography, c.geom::geography, :max_match_m)
        ORDER BY f.geom::geography <-> c.geom::geography
        LIMIT 1
    ) f
    WHERE NOT EXISTS (
        SELECT 1 FROM product_features p
        WHERE p.ref_code = c.ref_code AND p.product = :'product'
    )
)
SELECT point_id, method,
       ST_Distance(control_geom::geography, product_geom::geography) AS error_m
FROM matched;

Matching decides the measurement, and a nearest-match fallback measures something weakerControl points are matched to product features by a shared reference code where one exists, which is unambiguous and reproducible. Where no reference exists the match falls back to the nearest feature within a tight tolerance. The matched pairs yield distances, from which RMSE and CE90 are computed. Two rails record what weakens the result: a match dominated by nearest-fallback measures matching error rather than positional error, and fewer than thirty pairs makes the statistic noise rather than measurement.Reference matchshared codeNearest fallbackwithin toleranceRMSE + CE90from the distancesno codematchedmostly fallbackMeasures matching errornot positional error

Verify the match method distribution before trusting the result. A measurement dominated by nearest matches is measuring something weaker than it claims:

bash
psql --csv -f match_control.sql -v control_set="$CONTROL_SET_ID" -v product="$PRODUCT_ID" \
     -v max_match_m=5 | awk -F, 'NR>1 {n[$2]++} END {for (m in n) print m, n[m]}'

3. Compute RMSE and CE90 from the matched errors

RMSE is the headline figure; CE90 — the radius containing 90% of errors — is what a consumer actually reasons about when deciding whether a coordinate is usable.

Why RMSE and CE90 are both published: one distribution, two answersFive statistics from the same set of matched control errors, in metres, against a 2 metre product target. The median is 0.41 and p75 is 0.62, so most features are well inside target. CE90 is 1.8, meaning nine in ten features are within that radius — the number a consumer plans against. RMSE is 4.2, pulled far above the median by a small number of gross errors. The maximum is 31. Reporting only one of RMSE or CE90 hides either the tail or the typical case.median0.41 mp750.62 mCE901.8 mwhat consumers plan againstRMSE4.2 mdominated by the tailmax31 mone bad ingest batchproduct target

python
# accuracy.py — RMSE and CE90 from matched control errors.
import csv
import math
import statistics
import sys


def read_errors(path: str) -> list[float]:
    with open(path, newline="") as fh:
        rows = list(csv.DictReader(fh))
    return [float(r["error_m"]) for r in rows]


def rmse(errors: list[float]) -> float:
    """Root mean square error — dominated by the tail, which is the point."""
    return math.sqrt(sum(e * e for e in errors) / len(errors))


def ce90(errors: list[float]) -> float:
    """Circular error at 90%: the radius containing nine of ten observations.
    Reported alongside RMSE because a consumer plans against a radius, not a moment."""
    ordered = sorted(errors)
    idx = math.ceil(0.90 * len(ordered)) - 1
    return ordered[max(0, idx)]


if __name__ == "__main__":
    errors = read_errors(sys.argv[1])
    if len(errors) < 30:
        print(f"REFUSING: {len(errors)} pairs is too few for a defensible statistic",
              file=sys.stderr)
        raise SystemExit(1)
    print({
        "n": len(errors),
        "rmse_m": round(rmse(errors), 3),
        "ce90_m": round(ce90(errors), 3),
        "median_m": round(statistics.median(errors), 3),
        "max_m": round(max(errors), 3),
    })

Verify that RMSE and median are not wildly divergent. A median of 0.4 m with an RMSE of 6 m means a small number of gross errors dominate, and the honest report names both:

bash
python3 accuracy.py matched_errors.csv

4. Publish the measurement with its method and control set

An accuracy number without its control set and method is not comparable with any other accuracy number, including the same product’s previous measurement.

bash
# The measurement is published to the product's quality block, not to a dashboard.
curl -sS -X PATCH "$CATALOG_API/products/$PRODUCT_ID/quality" \
  -H 'content-type: application/json' \
  -d "$(python3 accuracy.py matched_errors.csv | python3 -c '
import ast, json, sys, os
m = ast.literal_eval(sys.stdin.read())
print(json.dumps({
  "positional_accuracy": {
    "rmse_m": m["rmse_m"], "ce90_m": m["ce90_m"], "n": m["n"],
    "method": "control_points",
    "control_set": os.environ["CONTROL_SET_ID"],
    "measured_at": os.environ.get("MEASURED_AT", "")
  }}))')"

Verify the published block round-trips and carries the control set:

bash
curl -sS "$CATALOG_API/products/$PRODUCT_ID" | jq '.quality.positional_accuracy'

Configuration Reference

Parameter Scope Value Effect
max_match_m Matching 5 (urban), 25 (regional) Nearest-match tolerance; too wide invents matches
Minimum pairs Statistic 30 Below this the script refuses to report
CE90 percentile Statistic 0.90 The radius consumers plan against
Distance type SQL geography Metres; geometry in EPSG:4326 returns degrees
method Published control_points Distinguishes from reference-layer or inherited figures
control_set Published Registered identifier Without it the number is not comparable
Re-measurement cadence Governance Per major version, or annually Accuracy drifts with source and transformation changes

Common Failure Modes & Fixes

RMSE reported in the hundreds of thousands. Root cause: distance computed on geometry in EPSG:4326, which returns degrees, not metres. Fix: cast both sides to geography as in the query above, or reproject to a metric CRS before measuring.

Reading the error distribution: what each shape actually indicatesFour distribution shapes against their likely cause and the fix. A uniform offset with low scatter indicates a datum mismatch between the control set and the product. High scatter with a near-zero mean indicates genuine measurement noise, which is what accuracy is meant to measure. A small median with a large maximum indicates a handful of gross errors from one bad batch. Errors in the hundreds of thousands indicate distance computed in degrees rather than metres.IndicatesFixuniform offset, low scatterdatum mismatchcompare transformationshigh scatter, mean ≈ 0genuine measurement noisenothing — this is accuracysmall median, huge maxgross errors, one batchlocate and repair, re-measurevalues in the 100,000sdegrees, not metrescast to geography

Accuracy is far worse than the team expects, uniformly. Root cause: a datum mismatch between the control set and the product — both are labelled EPSG:4326 but reached it through different transformations. Fix: compare the transformation recorded on the control set against the one recorded on the product; a systematic offset with low scatter is the signature.

Accuracy varies wildly between runs of the same product. Root cause: the match is falling back to nearest for most points, so the measurement changes as features move. Fix: populate ref_code on the product so matching is by reference, and refuse to publish a measurement whose nearest share exceeds a threshold.

CE90 is enormous while the median is small. Root cause: a handful of gross errors, typically features from one bad ingest batch or one merge seam. Fix: locate them — they cluster — and treat them as a data defect rather than as an accuracy characteristic; re-measure after repair.

The measurement passes but consumers still report misplacement. Root cause: the control set covers a different area than the consumer works in. Accuracy is not uniform across an extent. Fix: report accuracy per region where coverage allows, or state the control set’s spatial coverage alongside the figure.

FAQ

Why publish both RMSE and CE90 rather than picking one?

They answer different questions and both are cheap to compute. RMSE is a moment: it is dominated by the tail, so it moves sharply when a few features are badly wrong, which makes it a good regression detector between versions. CE90 is a radius: it says “nine out of ten features are within this distance”, which is directly what a consumer needs in order to decide whether a coordinate is usable for their purpose. A team tracking only RMSE will miss that a product is entirely fit for its consumers despite an alarming number; a team tracking only CE90 will miss a regression confined to the tail. Publishing both costs one extra line and removes both blind spots.

How many control points are actually enough?

Thirty matched pairs is the practical floor for RMSE and roughly sixty for a CE90 that will not swing between runs — below thirty, a single gross error moves the statistic by more than any real change would. The more important consideration is distribution rather than count: sixty points clustered in one city say nothing about accuracy elsewhere in the extent, and a well-distributed forty is worth more than a clustered two hundred. Where coverage is uneven, report per-region figures rather than one estate-wide number that is dominated by wherever the control survey happened to be dense.

Should accuracy be a blocking gate?

No. Accuracy is fitness, not validity — there is no threshold that makes a dataset universally good, only a measurement against which each consumer decides. Gating on it forces a choice between blocking data most consumers would happily use and silently shipping data that is wrong for the demanding ones. The exception is a regression check: a new version whose accuracy is dramatically worse than its predecessor’s is usually evidence of a processing defect rather than a genuine change in the data, and comparing against the previous version is a reasonable blocking gate even when comparing against an absolute threshold is not.

What if the product has no ground truth anywhere?

Publish a weaker measurement rather than none. Agreement against a known-accuracy reference layer already in the mesh, or internal consistency across repeat observations and tile seams, both produce interpretable numbers provided the method is published alongside them. The outcome to avoid is silence: a consumer facing a product with no accuracy statement will assume whatever their use case requires, and they will be wrong in the direction that matters.