Enforcing Coordinate Precision and CRS in CI
Assert three things about every changed spatial file: that it declares a CRS from an allowed set, that its coordinates fall inside that CRS’s area of use, and that its coordinate precision matches the accuracy the data actually has — the third catches the fabricated precision that makes a metre-accurate survey look like a millimetre one.
Coordinate problems are the most expensive class of spatial defect because they are silent. A layer in the wrong CRS renders, joins and exports without complaint; it simply sits in the wrong place, and the discovery usually happens downstream in somebody else’s analysis. This guide sits under automated topology and geometry validation, within CI/CD Validation & Policy Enforcement for Spatial Data.
Automated Python Implementation
#!/usr/bin/env python3
"""Assert CRS and coordinate precision on changed spatial files."""
import math
import geopandas as gpd
from pyproj import CRS
# The CRSs this repository publishes in. Anything else is a mistake or a decision.
ALLOWED = {"EPSG:4326", "EPSG:3857", "EPSG:27700"}
# Declared positional accuracy per CRS, in the CRS's own units.
ACCURACY_M = {"EPSG:4326": 1.0, "EPSG:3857": 1.0, "EPSG:27700": 0.1}
def check_crs(gdf, path):
findings = []
if gdf.crs is None:
findings.append(f"{path}: no CRS declared")
return findings, None
crs = CRS.from_user_input(gdf.crs)
authority = crs.to_authority()
if authority is None:
findings.append(f"{path}: CRS has no authority code; bare WKT is not portable")
return findings, crs
code = ":".join(authority)
if code not in ALLOWED:
findings.append(f"{path}: CRS {code} is not in the allowed set {sorted(ALLOWED)}")
return findings, crs
def check_area_of_use(gdf, crs, path):
"""Coordinates outside the CRS's own area of use usually mean swapped axes."""
if crs is None or crs.area_of_use is None:
return []
bounds = gdf.to_crs("EPSG:4326").total_bounds # west, south, east, north
aou = crs.area_of_use
inside = (bounds[0] >= aou.west - 1 and bounds[2] <= aou.east + 1
and bounds[1] >= aou.south - 1 and bounds[3] <= aou.north + 1)
if not inside:
return [f"{path}: extent {list(bounds)} falls outside the area of use "
f"of {crs.to_authority()}; check axis order"]
return []
def decimals_needed(accuracy_m, crs):
"""How many decimals the stated accuracy justifies."""
if crs.is_geographic:
# One degree of latitude is about 111 320 m.
return max(0, math.ceil(-math.log10(accuracy_m / 111320.0)))
return max(0, math.ceil(-math.log10(accuracy_m)))
def check_precision(gdf, crs, path, sample=500):
"""Stored decimals beyond the stated accuracy are fabricated precision."""
code = ":".join(crs.to_authority() or ("EPSG", "4326"))
justified = decimals_needed(ACCURACY_M.get(code, 1.0), crs)
worst = 0
for geom in gdf.geometry.head(sample):
if geom is None or geom.is_empty:
continue
for x, y in _coords(geom):
worst = max(worst, _decimals(x), _decimals(y))
if worst > justified + 1:
return [f"{path}: coordinates stored to {worst} decimals but the declared "
f"accuracy justifies {justified}; round before publishing"]
return []
def _decimals(value):
text = repr(float(value))
return len(text.split(".")[1]) if "." in text else 0
def _coords(geom):
if geom.geom_type == "Point":
yield (geom.x, geom.y)
elif hasattr(geom, "exterior"):
yield from geom.exterior.coords
elif hasattr(geom, "coords"):
yield from geom.coords
elif hasattr(geom, "geoms"):
for part in geom.geoms:
yield from _coords(part)
def check_file(path):
gdf = gpd.read_file(path)
findings, crs = check_crs(gdf, path)
if crs is not None:
findings += check_area_of_use(gdf, crs, path)
findings += check_precision(gdf, crs, path)
return findings
The precision check is the one teams have usually not thought about, and it is worth explaining. A survey with metre accuracy stored to nine decimal places in degrees asserts sub-millimetre positioning, which is false. The consequences are practical rather than theoretical: files are several times larger than they need to be, diffs are unreadable because every vertex differs in noise digits, and downstream users reasonably assume the precision is real. Rounding to the justified number of decimals fixes all three.
Validation and Pipeline Integration
def test_missing_crs_is_reported():
gdf = gpd.GeoDataFrame(geometry=[], crs=None)
findings, _ = check_crs(gdf, "x.gpkg")
assert findings
def test_swapped_axes_fall_outside_the_area_of_use():
findings = check_file("fixtures/swapped_axes.geojson")
assert any("area of use" in f for f in findings)
def test_excess_precision_is_reported():
findings = check_file("fixtures/nine_decimals.geojson")
assert any("decimals" in f for f in findings)
def test_clean_file_produces_no_findings():
assert check_file("fixtures/clean.gpkg") == []
Wire this into the per-file check set described in GitHub Actions workflows for spatial data, running only on changed files. All three assertions read geometry and complete in well under a second on a typical layer, which puts them comfortably inside the budget for a pull request check.
Report the CRS findings as errors and the precision finding as a warning at first. Excess precision is pervasive in existing data, and enforcing it immediately across an established repository produces a wall of failures on files nobody touched — the introduction pattern set out in policy enforcement gates for data PRs applies directly.
Choosing the Allowed Set
An allow-list of CRSs is more useful than a rule requiring any valid CRS, and choosing what goes in it is a decision about the organisation rather than about the data.
Include the CRS you publish in. Most organisations have one — a national grid, or WGS 84 for anything shared externally. Everything leaving the repository should be in it.
Include the CRS the data is captured in, if different. Survey data frequently arrives in a projected system with better local accuracy than the publication CRS, and forcing an early reprojection loses precision for no reason. Both belong in the set.
Do not include a CRS because one file uses it. That is the mechanism by which an allow-list becomes a list of everything ever encountered, at which point it asserts nothing. A file in an unexpected CRS is either a mistake to fix or a decision to make explicitly, and the check exists to force that choice.
Revisit the set when a supplier changes. New data sources arrive in their own systems, and a set that has not been reviewed in three years will be either too narrow, generating friction, or quietly widened past usefulness.
One further refinement is worth the effort in larger repositories: allow different sets per directory. Raw incoming data may legitimately be in any of a dozen systems while published output is restricted to one, and a single global set cannot express that without permitting the published output to be wrong.
Long-Term Compliance Best Practices
- Require an authority code, not bare WKT. A CRS nothing downstream can resolve by identifier will be re-guessed by every consumer.
- Round at export, not at ingestion. The source keeps whatever precision it had; the published artifact carries what is justified.
- Introduce the precision rule as a warning. Excess precision is pervasive, and immediate enforcement blocks work on files nobody touched.
- Check the area of use against EPSG:4326 bounds. It is the cheapest available test for swapped axis order and it costs one reprojection of the extent.
- Record the CRS in metadata as well as in the file. A catalogue that cannot state a dataset’s CRS forces every consumer to open it.
Related
- Automated Topology & Geometry Validation — the wider geometry check set this joins
- Detecting Invalid Geometries with Shapely in CI — structural validity, the other half of geometry checking
- Spatial Data Schema Linting in CI — where the CRS assertions sit among the other schema rules
- GitHub Actions Workflows for Spatial Data — running these checks against the diff