Controlling Floating-Point Drift in Coordinate Pipelines
This guide, part of Precision Standards in Epi-Mapping, deals with the error that accumulates not from any one step but from many: the slow wander of coordinates as they pass through reprojection, text serialisation, database round-trips and geometry operations.
Problem Context & Constraints
A double-precision float represents a projected coordinate in the millions of metres to around a nanometre. No single operation in a spatial pipeline loses meaningful precision. The problem is composition. A case point reprojected from geographic to projected coordinates, written to a text format with eight decimals, read back, transformed to a web projection for a map tile, transformed back for analysis, snapped during an overlay, and written again has been through a chain of approximations. Each contributes a tiny error, and the errors do not cancel reliably.
For most analyses the result is irrelevant — a few millimetres. It becomes relevant in three situations that surveillance pipelines hit routinely. Points on or near boundaries. A case geocoded to a parcel centroid that lies exactly on a tract line can flip between tracts from one run to the next if its coordinates wander by a micrometre across the line, and a count moves with it. Equality tests. Deduplication by exact coordinate, cache lookups keyed on geometry, and joins on coordinate values all break when two representations of the same point differ in the last bit. Idempotency. A pipeline whose output geometry changes slightly on every re-run cannot be tested by comparing outputs, which defeats the regression discipline described in Testing Geospatial Analysis Code.
Three constraints shape the fix. Establish a canonical precision well below any meaningful spatial scale and well above floating-point noise — a millimetre or a centimetre in projected coordinates — and snap to it at defined points. Minimise round-trips between coordinate systems and between binary and text. And measure the drift, because the only way to know whether it matters is to run the chain and compare.
Prerequisites
python3.11shapely2.0.4,geopandas0.14.4,pyproj3.6.1,numpy1.26.4- A declared working coordinate reference system in metres
- A chosen canonical precision — a grid size in metres
- A representative sample of case points, including some known to fall on area boundaries
Step-by-Step Solution
Snap geometry to the canonical grid immediately after entering the working system, serialise in binary, avoid unnecessary transformations, and measure drift by running the chain twice.
# Canonical precision, drift measurement and boundary-stable assignment.
# Pinned: shapely==2.0.4, geopandas==0.14.4, pyproj==3.6.1, numpy==1.26.4
import logging
import numpy as np
import geopandas as gpd
import shapely
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("precision.drift")
GRID_M = 0.01 # canonical precision: one centimetre in the working CRS
def to_canonical(gdf: gpd.GeoDataFrame, work_crs: str, grid: float = GRID_M) -> gpd.GeoDataFrame:
"""Enter the working CRS once, then snap every vertex to the canonical grid."""
g = gdf.to_crs(work_crs) if gdf.crs != work_crs else gdf.copy()
g["geometry"] = shapely.set_precision(g.geometry.values, grid_size=grid)
invalid = int((~g.geometry.is_valid).sum())
if invalid:
log.warning("%d geometries became invalid on snapping — repair before use", invalid)
log.info("canonicalised %d geometries to a %.3f m grid in %s", len(g), grid, work_crs)
return g
def measure_drift(points: gpd.GeoDataFrame, chain) -> dict:
"""Run a transformation chain and report how far points moved."""
before = points.geometry
after = chain(points).geometry
d = before.distance(after)
out = {"max_m": float(d.max()), "p99_m": float(np.percentile(d, 99)),
"moved_over_1mm": int((d > 0.001).sum()), "n": int(len(d))}
log.info("drift over chain: max=%.6f m p99=%.6f m, %d of %d moved more than 1 mm",
out["max_m"], out["p99_m"], out["moved_over_1mm"], out["n"])
return out
def boundary_stable_assign(points: gpd.GeoDataFrame, areas: gpd.GeoDataFrame,
id_col: str = "geoid", tolerance: float = 0.05) -> gpd.GeoDataFrame:
"""Point-in-polygon with a deterministic rule for points on or near a boundary.
Points within `tolerance` of an area boundary are assigned to the candidate
with the smallest identifier, so a micrometre of drift cannot flip them.
"""
near = gpd.sjoin(points, areas[[id_col, "geometry"]].assign(
geometry=areas.geometry.buffer(tolerance)), how="left", predicate="within")
counts = near.groupby(level=0)[id_col].nunique()
ambiguous = counts[counts > 1].index
resolved = (near.sort_values(id_col).groupby(level=0)[id_col].first())
out = points.copy()
out[id_col] = resolved.reindex(out.index)
out["boundary_ambiguous"] = out.index.isin(ambiguous)
log.info("assigned %d points; %d within %.2f m of a boundary resolved by rule",
len(out), len(ambiguous), tolerance)
return out
Snapping once, immediately after entering the working system, fixes the representation that every later step starts from. Two runs that read the same input then begin from bit-identical coordinates, and any later operation that is itself deterministic produces identical output — which restores the ability to regression-test geometry by exact comparison.
The boundary-stable assignment addresses the case that matters epidemiologically. A point within a few centimetres of a tract line is on that line for every practical purpose, and whether it lands in one tract or the other is a numerical accident. Resolving such points by a fixed rule — smallest identifier — makes the assignment deterministic, and flagging them makes the count of boundary cases visible, which is useful in its own right because parcel centroids on boundaries are more common than one expects.
Validation & Edge Cases
Snapping that invalidates polygons. Snapping a polygon with a narrow neck to a coarse grid can make it self-intersect. Choose the grid well below the narrowest real feature — a centimetre is safe for administrative boundaries — and check validity after snapping.
Text formats with too few decimals. Writing geographic coordinates with six decimals truncates to about ten centimetres, which is coarser than the canonical grid and re-introduces drift on every text round-trip. Prefer binary formats; where text is unavoidable, write enough decimals to exceed the canonical precision.
Web projection round-trips. Transforming to a web mapping projection and back for analysis is a common and entirely avoidable round-trip. Keep analysis geometry in the working system and derive web geometry as a one-way output.
Coordinate equality in caches and joins. Keying a cache on raw coordinates fails when two representations differ in the last bit. Key on the canonical snapped coordinates, or better, on a stable identifier.
Different grids in different stages. Snapping to a millimetre in one stage and a centimetre in another produces a pipeline whose precision is the coarser of the two and whose intermediate geometry is inconsistent. Declare one canonical grid for the pipeline.
INFO precision.drift canonicalised 48900 geometries to a 0.010 m grid in EPSG:5070
INFO precision.drift drift over chain: max=0.000000 m p99=0.000000 m, 0 of 48900 moved more than 1 mm
INFO precision.drift assigned 48900 points; 37 within 0.05 m of a boundary resolved by rule
A grid chosen too coarse. A snapping grid of a metre looks harmless and silently merges distinct parcel centroids in dense housing, collapsing records that should stay separate and inflating apparent duplicates. The canonical grid is a computational precision, not an analytic one, and should sit two or three orders of magnitude below the finest distance any analysis uses.
Legacy data already carrying drift. Archives written before a canonical grid existed contain coordinates that differ in their last digits from the same points written today. Snap the archive once, record that it was done and with which grid, and treat the snapped archive as the new reference rather than mixing snapped and unsnapped partitions.
Choosing the grid is a matter of placing it in the wide gap between floating-point noise and the smallest distance any analysis cares about:
Compliance Notes
Record the canonical grid, the working coordinate reference system, the point in the pipeline where snapping occurs, the serialisation formats used, the boundary tolerance and assignment rule, and the count of boundary-ambiguous points per run.
Precision decisions interact with disclosure. A canonical grid is an internal representation choice and is far finer than any published precision; publishing coordinates at the canonical precision would be a disclosure regardless of drift. The published precision is governed separately by Setting Decimal Precision for Disease Coordinate Mapping, and keeping the two decisions distinct — one for computational stability, one for release — avoids the error of treating an internal precision as safe to publish.
Related Topics
- Precision Standards in Epi-Mapping — the parent guide on precision governance.
- Making a Spatial Pipeline Idempotent and Restartable — the property that canonical precision restores for geometry.
- Validating and Repairing Invalid Polygon Geometry — fixing polygons that snapping makes invalid.