Selecting a Minimum-Distortion Target CRS Jump to heading
Every harmonized dataset resolves to one authoritative CRS, and that single choice fixes how much every distance, area, and shape in the output is distorted. Pick a UTM zone for a dataset that spans three zones and features near the edges stretch by a metre per kilometre; pick a conformal projection when the consumer measures acreage and every area is subtly wrong. This procedure replaces intuition with measurement: compute the true combined extent, evaluate candidate projections over it, and quantify the worst-case linear and area distortion before committing. It sets the target_crs that Multi-CRS Dataset Harmonization, its parent stage inside CRS Normalization & Sync, pins in its manifest for every source to resolve onto.
The steps follow configure (Steps 1-2, extent and candidates), execute (Step 3, distortion measurement), validate (Step 4, ranking against a threshold), and log (Step 5, rationale). This is a comparison-and-decision procedure: the deliverable is a defensible EPSG code plus the numbers that justify it, not a transform.
Prerequisites checklist Jump to heading
Step 1: Compute the combined extent Jump to heading
The target must cover the union of all footprints, not the largest single input. Reproject each source footprint to a geographic CRS and union the bounds so the extent reflects the true area, including any zone-straddling corridor that will feed Merging UTM Zone Boundary Datasets Without Slivers.
# geopandas >= 0.14, pyproj >= 3.6 — Python 3.10+
import geopandas as gpd
from shapely.geometry import box
def combined_extent(sources: list[gpd.GeoDataFrame]) -> tuple[float, float, float, float]:
"""Union all footprints in EPSG:4326 and return (west, south, east, north)."""
geo = [s.to_crs("EPSG:4326") for s in sources]
minx = min(g.total_bounds[0] for g in geo)
miny = min(g.total_bounds[1] for g in geo)
maxx = max(g.total_bounds[2] for g in geo)
maxy = max(g.total_bounds[3] for g in geo)
return (minx, miny, maxx, maxy)
def extent_shape(bounds: tuple[float, float, float, float]) -> str:
"""Classify the extent so candidate selection can react to its aspect ratio."""
w, s, e, n = bounds
ew_deg, ns_deg = e - w, n - s
return "narrow_ns" if ns_deg > 1.6 * ew_deg else "wide_ew" if ew_deg > 1.6 * ns_deg else "compact"
Extent rules:
- Union footprints in a geographic CRS so mixed-projection inputs are compared on one datum.
- Record the extent’s aspect ratio — a tall-narrow extent favors a Transverse Mercator, a wide one favors Lambert/Albers.
- Include every source, even small outliers; one distant footprint can invalidate a UTM-zone choice.
Step 2: Enumerate candidate projections Jump to heading
Match the candidate family to the extent shape and the use case. A narrow extent under ~3° of longitude can stay in a UTM zone; a wide extent needs a Lambert Conformal Conic (shape/angle preserving) or an Albers Equal Area (area preserving). Resolving each candidate to a concrete EPSG code is the job of Projection Normalization Workflows.
# pyproj >= 3.6 — Python 3.10+
from pyproj import CRS
def candidate_crs(bounds: tuple[float, float, float, float], use_case: str) -> list[str]:
"""Return EPSG/PROJ candidates appropriate to extent and use case."""
w, s, e, n = bounds
lon0, lat0 = (w + e) / 2, (s + n) / 2
candidates: list[str] = []
if (e - w) <= 3.0: # narrow enough for a single UTM zone
zone = int((lon0 + 180) // 6) + 1
candidates.append(f"EPSG:{32600 + zone if lat0 >= 0 else 32700 + zone}")
# Wide-extent conformal (shape) and equal-area (area) candidates, centered on the extent.
candidates.append(
f"+proj=lcc +lat_1={s:.3f} +lat_2={n:.3f} +lat_0={lat0:.3f} +lon_0={lon0:.3f} +datum=NAD83 +units=m"
)
candidates.append(
f"+proj=aea +lat_1={s:.3f} +lat_2={n:.3f} +lat_0={lat0:.3f} +lon_0={lon0:.3f} +datum=NAD83 +units=m"
)
if use_case == "area":
candidates.append("EPSG:5070") # CONUS Albers Equal Area, a common national grid
return candidates
Candidate rules:
- Offer at least one conformal and one equal-area candidate so the ranking can trade shape against area.
- Center custom Lambert/Albers standard parallels on the extent (
lat_1/lat_2at ~1/6 and 5/6 of the height) to minimize mid-latitude scale error. - Include an established national grid (e.g.
EPSG:5070) as a candidate; a standard code eases interoperability even at slightly higher distortion.
Step 3: Quantify linear and area distortion Jump to heading
Distortion is measured, not assumed. Sample a grid of points over the extent, and for each candidate compute the local scale factor (linear distortion) and its square-ish area factor by comparing tiny reprojected displacements against their true geodesic lengths.
# pyproj >= 3.6, numpy >= 1.26 — Python 3.10+
import numpy as np
from pyproj import Geod, Transformer
def measure_distortion(crs: str, bounds: tuple[float, float, float, float], n: int = 20) -> dict[str, float]:
"""Sample the extent and return worst-case linear and area distortion for one candidate."""
w, s, e, north = bounds
lons = np.linspace(w, e, n)
lats = np.linspace(s, north, n)
grid_lon, grid_lat = np.meshgrid(lons, lats)
geod = Geod(ellps="GRS80")
tf = Transformer.from_crs("EPSG:4326", crs, always_xy=True)
d = 0.001 # ~100 m probe in degrees
x0, y0 = tf.transform(grid_lon, grid_lat)
xe, ye = tf.transform(grid_lon + d, grid_lat)
# True geodesic length of the east probe vs its projected length -> linear scale factor.
_, _, true_e = geod.inv(grid_lon, grid_lat, grid_lon + d, grid_lat)
proj_e = np.hypot(xe - x0, ye - y0)
linear = proj_e / true_e
return {
"crs": crs,
"max_linear_distortion": float(np.max(np.abs(linear - 1.0))),
"mean_linear_distortion": float(np.mean(np.abs(linear - 1.0))),
}
Measurement rules:
- Sample worst-case, not just the centroid; distortion peaks at the extent edges where a UTM zone fails.
- Compare projected displacements against true geodesic lengths from
pyproj.Geod, never against planar assumptions. - Report both max and mean — a candidate with a good mean but a bad max is unfit for edge-sensitive analysis.
Step 4: Rank candidates and select Jump to heading
Rank the candidates by worst-case distortion against the budget and pick the family that satisfies the use case at the lowest cost. The decision matrix below records the trade-off explicitly.
| Extent / use case | Preferred family | Why it wins | Fails when |
|---|---|---|---|
| Narrow (< 3° lon), distance-critical | UTM zone (Transverse Mercator) | Scale error < 1:2500 within the zone | Extent straddles a zone boundary |
| Wide east-west, area-critical | Albers Equal Area (aea) |
Preserves area exactly across parallels | Angles/shapes matter more than area |
| Wide east-west, shape-critical | Lambert Conformal Conic (lcc) |
Preserves local angles and shape | Area totals must be exact |
| Continental, interoperability-first | National grid (e.g. EPSG:5070) |
Standard code, published parameters | Local scale error exceeds the budget |
# Python 3.10+
def select_target(measurements: list[dict[str, float]], budget_linear: float) -> dict[str, float]:
"""Pick the candidate with the smallest worst-case linear distortion inside budget."""
within = [m for m in measurements if m["max_linear_distortion"] <= budget_linear]
pool = within or measurements # if none fit, surface the least-bad for review
chosen = min(pool, key=lambda m: m["max_linear_distortion"])
chosen["within_budget"] = chosen["max_linear_distortion"] <= budget_linear
return chosen
Ranking rules:
- Reject candidates whose worst-case distortion exceeds the budget before comparing means.
- When no candidate fits the budget, surface the least-bad and flag it for human review rather than auto-selecting.
- Break ties toward an established EPSG code over a custom PROJ string for downstream interoperability.
Step 5: Record the selection rationale Jump to heading
The chosen CRS is a governance decision. Persist the winner, its measured distortion, and the rejected candidates so the choice survives an audit and can be revisited when a new source extends the extent.
# Python 3.10+
import json
from pathlib import Path
def record_rationale(path: Path, chosen: dict, rejected: list[dict], bounds: tuple) -> None:
record = {
"selected_crs": chosen["crs"],
"max_linear_distortion": round(chosen["max_linear_distortion"], 7),
"within_budget": chosen["within_budget"],
"combined_extent_wsen": [round(b, 4) for b in bounds],
"rejected": [{"crs": r["crs"], "max_linear_distortion": round(r["max_linear_distortion"], 7)} for r in rejected],
}
with path.open("a") as fh:
fh.write(json.dumps(record) + "\n")
This rationale row is exactly the kind of decision provenance that Geospatial Compliance Reporting & Audit Trails preserves, and it becomes the target_crs justification behind the harmonization manifest’s SHA-256 hash.
Verification Jump to heading
Confirm the selection is reproducible and honestly within budget:
bounds = combined_extent(sources)
cands = candidate_crs(bounds, use_case="area")
measured = [measure_distortion(c, bounds) for c in cands]
chosen = select_target(measured, budget_linear=0.0004)
# 1. The winner is genuinely the minimum, not an arbitrary tie.
assert chosen["max_linear_distortion"] == min(m["max_linear_distortion"] for m in measured)
# 2. If flagged within_budget, it truly satisfies the linear budget.
assert not chosen["within_budget"] or chosen["max_linear_distortion"] <= 0.0004
# 3. The chosen string resolves to a usable CRS.
from pyproj import CRS
assert CRS.from_user_input(chosen["crs"]).is_projected
On the CLI, projinfo "<chosen>" confirms the projection parameters, and re-running the measurement on the committed extent should reproduce the recorded max_linear_distortion to seven decimals — a drift means the extent or candidate list changed.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Edge features stretch after harmonization | A single UTM zone chosen for a multi-zone extent | Re-run over the true combined extent and prefer a spanning Lambert/Albers (Steps 1-2). |
| Acreage totals drift after reprojection | A conformal projection used for an area-critical use case | Select an equal-area (aea) candidate (Step 4 matrix). |
| Selection changes run to run | Candidate list or extent recomputed from a different source set | Pin the source set and record the extent in the rationale (Steps 1, 5). |
| No candidate meets the budget | Extent too large for any single projection at that accuracy | Split the extent into distortion-bounded tiles, or relax the budget with sign-off (Step 4). |
| Custom Lambert scale error higher than expected | Standard parallels not centered on the extent | Set lat_1/lat_2 near 1/6 and 5/6 of the extent height (Step 2). |
Related Jump to heading
- Multi-CRS Dataset Harmonization — the parent stage whose manifest pins the CRS this page selects
- Merging UTM Zone Boundary Datasets Without Slivers — the seam merge that reprojects onto the chosen spanning CRS
- Projection Normalization Workflows — resolving each candidate to a canonical EPSG code
- Unit Conversion & Tolerance Thresholds — the distortion and deviation budgets the ranking gates against
- Geospatial Compliance Reporting & Audit Trails — preserving the CRS-selection rationale as auditable evidence