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.
# 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:
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.
-- 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;
Verify the match method distribution before trusting the result. A measurement dominated by nearest matches is measuring something weaker than it claims:
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.
# 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:
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.
# 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:
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.
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.
Related
- Spatial Data Quality and Validation Standards — the parent topic and the full indicator set
- Detecting Attribute Drift in Published Layers — the non-geometric half of quality monitoring
- CRS Governance and Reprojection Standards — why a datum mismatch shows up as a uniform offset