Coordinate Reference Systems for Public Health
This guide is part of Spatial Epidemiology Fundamentals & Data Standards, and covers how to enforce coordinate reference systems (CRS) as a hard pipeline constraint so that every distance, buffer, and aggregation operation in a surveillance workflow runs on a geometrically defensible footing. In production epidemiology, the CRS is not passive metadata: it dictates the validity of spatial joins, distance metrics, and the spatial weights matrices that drive cluster detection. A single unprojected geometry that slips past ingestion will silently distort case aggregation, exposure modeling, and regulatory output — and the error surfaces only after the maps have already been published.
Concept & Epidemiological Alignment
A coordinate reference system binds raw coordinate tuples to a position on the Earth by combining a datum (the ellipsoid and its realization, e.g. WGS84 or NAD83), a coordinate system (geographic angular degrees versus projected planar meters), and — for projected systems — a map projection that flattens the ellipsoid onto a grid. Two layers can share identical numeric coordinates yet refer to points hundreds of meters apart if their datums or projections differ. CRS governance is the discipline of making those differences explicit and reconciling them before any metric operation executes.
The epidemiological stakes are concrete. Geographic systems such as EPSG:4326 (WGS84) store longitude/latitude in degrees, where one degree of longitude shrinks from roughly 111 km at the equator to about 78 km at 45° latitude. Computing a Euclidean distance or a fixed-radius buffer directly in degrees therefore produces ellipses, not circles, and biases every downstream radius, k-nearest-neighbor graph, and exposure overlap. Projected systems — UTM zones, State Plane, or an Albers Equal Area Conic for multi-state regions — transform coordinates into meters on a locally accurate plane, which is what distance- and area-based statistics actually assume.
The diagram below makes the distortion tangible: the same “1 km” exposure buffer drawn in degrees collapses into a latitude-stretched ellipse, while the projected buffer stays a true circle. The horizontal bias is the secant of latitude — modest near the equator, severe in the mid-latitudes where most surveillance happens.
Use a projected CRS whenever the operation depends on distance or area: buffer generation, spatial weights construction for Global & Local Moran’s I Implementation, neighborhood sums for Getis-Ord Gi* Hotspot Detection, edge-corrected estimators in K-Function Point Pattern Analysis, and incidence-rate normalization against population polygons. Reserve the geographic CRS (EPSG:4326) for canonical long-term storage and web interchange, where its lossless angular representation and tooling ubiquity are advantages. The non-negotiable rule: reproject to a jurisdiction-appropriate projected CRS at the analytical boundary, never inside a tight metric loop.
Three assumptions must hold for this alignment to remain valid in surveillance data. First, every input layer must declare an authoritative CRS — an unlabeled shapefile or a loosely typed GeoJSON is a defect, not a default. Second, the chosen projection must keep distortion acceptably low across the entire study extent; a single UTM zone is reliable only within its ~6° longitude band, so transboundary analyses need an equal-area or equidistant projection sized to the region. Third, datum transformations must use a published, versioned grid (e.g. the NADCON or NTv2 grids surfaced through pyproj), because an unmodeled NAD83-to-WGS84 shift can reach 1–2 meters and push a case across a tract boundary.
CRS governance is best expressed as an enforcement gate applied at every ingestion point:
Projection Selection: A Decision Matrix
The correct projection is a function of study extent, the dominant operation (distance versus area), and the governing jurisdiction’s reporting conventions. The table below maps common surveillance scenarios to a defensible default. Treat it as a starting point, then confirm against the published authority code for the specific county or state.
| Study extent | Dominant operation | Recommended CRS family | Example EPSG | Notes |
|---|---|---|---|---|
| Single county / metro | Distance, buffers, KNN weights | UTM zone (metric) | EPSG:32617 (UTM 17N) |
Pick the zone covering the centroid; ≤6° wide. |
| Single US state | Area, rate normalization | State Plane (NAD83) | EPSG:6543 (FL East ftUS) |
Tuned per-state; mind US-survey-feet vs meters. |
| Multi-state / regional | Area-preserving aggregation | Albers Equal Area Conic | EPSG:5070 (CONUS Albers) |
Equal-area; correct for population-weighted rates. |
| National (continental US) | Choropleth, equal-area stats | Albers / Lambert Conformal | EPSG:5070 / EPSG:5071 |
Albers for area, Lambert for shape/angles. |
| Global storage / interchange | None (storage only) | Geographic WGS84 | EPSG:4326 |
Reproject before any metric op. |
| Web tiles / dashboards | Display only | Web Mercator | EPSG:3857 |
Display-only; never compute distance in it. |
A common and costly failure is computing standardized incidence ratios on Web Mercator (EPSG:3857) because the basemap used it. Web Mercator inflates area toward the poles by the secant of latitude, so a population-weighted denominator computed there will be systematically wrong. Reproject to an equal-area system before any rate calculation, and keep the display CRS strictly separate from the analytical CRS.
Spatial Data Prerequisites
Before reprojection runs, the inputs must satisfy a fixed set of preconditions, enforced programmatically rather than assumed:
- Declared authority code. Every layer must expose a CRS that resolves to an EPSG (or OGC URN) authority code via
gdf.crs.to_epsg(). ANoneresult is a hard reject. - Geometry type consistency. Points for geocoded cases, polygons for administrative units. Mixed geometry collections must be normalized before spatial joins, or
geopandaswill raise or silently mis-join. See Spatial Data Types & Formats for the geometry-validation pattern that precedes CRS work. - Topological validity. Run
gdf.geometry.is_validand repair withmake_valid()before reprojecting; an invalid ring can become self-intersecting after the transform and corrupt downstream buffers. - Coordinate ordering. Confirm longitude/latitude versus latitude/longitude ordering. WGS84 source data with swapped axes is the single most frequent reprojection bug; a sanity bounds check (longitude in [-180, 180], latitude in [-90, 90]) catches it.
- Minimum and bounds sanity. Verify
total_boundsfalls inside the expected jurisdiction envelope after transform; an out-of-range bounding box signals a wrong source CRS or an axis swap. - Precision posture. Decide source coordinate precision before, not after, projection, because rounding interacts with Precision Standards in Epi-Mapping and with de-identification thresholds.
Production Implementation
The following pipeline enforces a declared CRS, requires a recognized EPSG authority code, reprojects to a jurisdiction target, validates the transform with a centroid-displacement bound, and logs every parameter for the audit trail. It is deterministic: inputs are sorted by a stable identifier so the same data always produces byte-identical output ordering.
# Requires: geopandas==1.0.1, pyproj==3.6.1, shapely==2.0.6, pandas==2.2.3
import logging
import hashlib
import json
from pathlib import Path
import geopandas as gpd
from pyproj import CRS, Transformer
logging.basicConfig(level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s")
TARGET_CRS = "EPSG:32617" # UTM Zone 17N — adjust per jurisdiction
DISPLACEMENT_TOLERANCE_M = 0.5 # max acceptable round-trip centroid drift (meters)
INPUT_PATH = Path("patient_geocodes.gpkg")
def enforce_crs_pipeline(gdf: gpd.GeoDataFrame,
target_crs: str,
id_col: str = "case_id") -> gpd.GeoDataFrame:
# 1. Reject undeclared CRS — never assume a default for health data
if gdf.crs is None:
raise ValueError("Input lacks a CRS definition; rejecting to prevent silent misalignment.")
src_epsg = gdf.crs.to_epsg()
if src_epsg is None:
raise RuntimeError("Source CRS has no recognized EPSG authority code; verify metadata.")
# 2. Deterministic ordering for reproducible, audit-defensible output
gdf = gdf.sort_values(id_col).reset_index(drop=True)
# 3. Reproject via the pyproj backend (uses published NTv2/NADCON datum grids)
transformed = gdf.to_crs(target_crs)
# 4. Round-trip validation: reproject the projected centroids back to source
# and confirm displacement stays within tolerance.
fwd_centroids = transformed.geometry.centroid
back = gpd.GeoSeries(fwd_centroids, crs=target_crs).to_crs(src_epsg)
src_centroids = gdf.geometry.centroid
# measure drift in the projected (metric) space
drift_m = (gpd.GeoSeries(back, crs=src_epsg).to_crs(target_crs)
.distance(fwd_centroids))
max_drift = float(drift_m.max())
if max_drift > DISPLACEMENT_TOLERANCE_M:
raise RuntimeError(
f"Round-trip centroid drift {max_drift:.3f} m exceeds "
f"tolerance {DISPLACEMENT_TOLERANCE_M} m; suspect datum-grid mismatch."
)
# 5. Emit a tamper-evident provenance record
provenance = {
"source_crs": f"EPSG:{src_epsg}",
"target_crs": target_crs,
"datum_source": CRS.from_epsg(src_epsg).datum.name,
"datum_target": CRS.from_user_input(target_crs).datum.name,
"feature_count": int(len(transformed)),
"projected_bounds": [round(b, 3) for b in transformed.total_bounds],
"max_round_trip_drift_m": round(max_drift, 4),
}
provenance["config_sha256"] = hashlib.sha256(
json.dumps(provenance, sort_keys=True).encode()
).hexdigest()
logging.info("CRS provenance: %s", json.dumps(provenance))
return transformed
raw = gpd.read_file(INPUT_PATH)
processed = enforce_crs_pipeline(raw, TARGET_CRS)
processed.to_file("processed_cohort.gpkg", driver="GPKG")
The round-trip check in step 4 is the load-bearing control: it catches the case where a source layer claims EPSG:4326 but was actually digitized in NAD27, or where a missing datum grid causes pyproj to fall back to a null transformation. A drift above sub-meter tolerance is a signal to halt the pipeline, not to round away.
Parameter Selection & Tuning
Three parameters govern correctness here. The target CRS is selected from the decision matrix above; for any analysis that feeds spatial weights, prefer a metric UTM or State Plane system so that bandwidth and k-nearest-neighbor distances are expressed in meters that downstream estimators expect. The displacement tolerance should be set tighter than the smallest spatial unit you will aggregate to — sub-meter for tract- or block-group-level work, since a 10-meter error can shift a case across a tract boundary and perturb the denominator of a standardized incidence ratio.
The datum transformation pipeline itself is a tunable choice when multiple grids exist. pyproj may offer several candidate operations between two CRSs with different accuracies; in production, pin the operation explicitly rather than accepting the default best-guess:
# Requires: pyproj==3.6.1
from pyproj import Transformer
from pyproj.transformer import TransformerGroup
# Inspect every candidate operation and its stated accuracy (meters)
tg = TransformerGroup("EPSG:4267", "EPSG:4326") # NAD27 -> WGS84
for t in tg.transformers:
print(t.description, "| accuracy:", t.accuracy, "m")
# Pin the highest-accuracy, grid-based operation for reproducibility
transformer = Transformer.from_crs(
"EPSG:4267", "EPSG:4326",
always_xy=True,
only_best=True, # require the best available operation; fail if grid missing
)
only_best=True forces the transform to fail loudly when the high-accuracy grid is not installed, instead of silently degrading to a coarse three-parameter shift — the difference between a defensible 1-meter correction and an undocumented 10-meter one.
Edge Cases & Failure Modes
- Undeclared CRS in legacy shapefiles. A missing or stripped
.prjsidecar leavesgdf.crsasNone. Reject at ingestion; do not guess. Migrate such sources to GeoPackage or GeoParquet, which embed the CRS in the file, per Spatial Data Types & Formats. - Transboundary CRS drift. A study spanning two UTM zones (e.g. a watershed straddling zones 17N and 18N) accumulates linear distortion toward the zone edges. Switch the whole study to a single Albers Equal Area or a custom transverse Mercator centered on the region rather than mosaicking zones.
- Web Mercator contamination. Coordinates ingested from a slippy-map export arrive in
EPSG:3857. Any area or rate computed there is wrong by the secant-of-latitude factor; force a reproject to an equal-area CRS before normalization. - Axis-order swaps. Some WFS and GeoJSON sources emit latitude/longitude instead of longitude/latitude. Use
always_xy=Trueinpyprojtransformers and validatetotal_boundsagainst the jurisdiction envelope. - Datum-grid absence. Without the NTv2/NADCON grid installed, NAD27↔WGS84 transforms silently fall back to a low-accuracy shift. Detect via the round-trip drift check and
only_best=True. - Memory pressure for N > 50k. Reprojecting millions of point geometries at once spikes memory. Process in stable, ID-sorted chunks and reproject each chunk with the same pinned transformer to keep results deterministic across batches.
Compliance & Audit Controls
Regulatory defensibility depends on reproducibility. Sort every input by a stable identifier (case_id) before transforming so that two runs over the same data produce identical ordering and identical hashes. Persist the provenance record emitted in the implementation above — source CRS, target CRS, both datum names, feature count, projected bounds, maximum round-trip drift, and a config_sha256 over the configuration — alongside the output dataset. This satisfies ISO 19115 lineage expectations and lets a reviewer reconstruct exactly which transformation produced a given map.
Coordinate precision is itself a disclosure-control parameter. HIPAA Safe Harbor and GDPR de-identification frequently require truncation, jittering, or aggregation to census tracts; record the original precision, the applied generalization, and the resulting spatial accuracy so the de-identification decision is auditable. The output schema should carry stable column names (case_id, geometry, source_crs, target_crs, transform_sha256) plus an ISO 19115 metadata sidecar. For the broader compliance schema this slots into, see Compliance Mapping Frameworks.
For the full hands-on transformation and validation walkthrough on real county data, follow How to Align WGS84 and UTM for County Health Data. Authoritative parameter and grid definitions come from the official EPSG Geodetic Parameter Registry and the pyproj transformation documentation.
Production CRS Checklist
FAQ
Why can’t I just compute distances in EPSG:4326 (WGS84)?
WGS84 stores coordinates as angular degrees on an ellipsoid, so a degree of longitude represents a different ground distance at every latitude. Euclidean distance, fixed-radius buffers, and spatial-weights graphs computed in degrees come out as latitude-dependent ellipses rather than true circles, biasing every downstream radius and neighbor relationship. Reproject to a metric projected CRS (UTM or State Plane) first.
How do I choose between UTM, State Plane, and Albers?
Match the projection to the study extent and dominant operation. Use a single UTM zone for county- or metro-scale distance work, State Plane where state reporting conventions require it, and Albers Equal Area Conic for multi-state or national area-preserving aggregation such as population-weighted rate normalization. Confirm the specific EPSG code against the published authority definition for the jurisdiction.
What tolerance should I set for transformation validation?
Set the displacement tolerance tighter than the smallest spatial unit you aggregate to. For tract- or block-group-level surveillance, a sub-meter round-trip drift threshold (around 0.5 m) is appropriate, because a 10-meter positional error can move a case across a boundary and distort the denominator of a standardized incidence ratio.
Why does my NAD27 or NAD83 data shift after reprojection to WGS84?
Different datums realize the Earth’s shape differently, so the same coordinates map to slightly different ground positions. A NAD27-to-WGS84 conversion can move points by tens of meters and requires a published NTv2 or NADCON grid for high accuracy. If the grid is missing, pyproj may fall back to a coarse shift; force the high-accuracy path with only_best=True and verify with a round-trip drift check.
Which storage format best preserves CRS metadata?
Prefer GeoPackage (GPKG) or GeoParquet, which embed CRS and spatial metadata inside the file. Shapefiles rely on a separate .prj sidecar that is easily lost or corrupted in transfer, and GeoJSON assumes WGS84. Embedded-metadata formats keep the projection definition intact across version control, cloud storage, and interagency handoff.
Related Topics
- Spatial Epidemiology Fundamentals & Data Standards — the parent collection covering governance, preparation, and output standards.
- Spatial Data Types & Formats — geometry validation and storage formats that precede CRS work.
- Precision Standards in Epi-Mapping — coordinate precision and de-identification thresholds.
- Compliance Mapping Frameworks — HIPAA/GDPR metadata schemas for audit-ready pipelines.
- How to Align WGS84 and UTM for County Health Data — the step-by-step transformation walkthrough.