Choosing a Local Tangent Plane for AV Mapping
A UTM zone is a compromise designed for global coverage, and an AV fleet operating inside one city is paying for coverage it never uses — a scale factor that varies across the map, and a seam that has to be detected, corridored and blended as handling UTM zone boundary crossings in AV maps describes. An East-North-Up tangent plane anchored at the operating area removes both, at the cost of coordinates that only mean something relative to their anchor.
This task chooses that plane for the pipeline described in coordinate reference systems for AVs: an origin drawn from where the fleet drives, an extent bounded by the plane's own curvature error, an exact conversion through ECEF, and the metadata that makes the choice reversible.
How the plane's error grows away from its anchor, and where that bounds the extent:
Prerequisites #
- Python 3.10+, NumPy 1.24+, pyproj 3.6+ with a pinned PROJ data directory.
- Input: the fleet's driven trajectories or the operating-area polygon, in WGS84.
- Upstream stage: trajectory ingestion; this decision precedes every metric computation.
- Output: an origin, an extent bound, and the anchor metadata recorded per tile.
Step-by-Step #
1. Pick the origin from driven distance, not from the bounding box #
A bounding-box centre sits wherever the extremes happen to be, which on a fleet that mostly works one corridor puts the anchor where nobody drives.
import numpy as np
def weighted_origin(traj_lonlat: np.ndarray) -> tuple[float, float]:
"""Origin at the driven-distance-weighted centroid of the trajectories."""
seg = np.linalg.norm(np.diff(traj_lonlat, axis=0), axis=1)
mid = 0.5 * (traj_lonlat[:-1] + traj_lonlat[1:])
w = seg / seg.sum()
lon, lat = (mid * w[:, None]).sum(axis=0)
return float(lon), float(lat)
Weighting by segment length rather than by vertex count keeps a densely sampled depot from dragging the anchor toward itself. Expected output: a lon/lat pair inside the area the fleet actually covers.
2. Bound the extent by the vertical error #
EARTH_R = 6_378_137.0
def max_radius_m(vertical_budget_m: float) -> float:
"""Distance at which the tangent-plane vertical error reaches the budget."""
return float(np.sqrt(2.0 * EARTH_R * vertical_budget_m))
For a 0.10 m budget this returns about 1 130 m; for 0.02 m, about 505 m. If the operating area exceeds the bound, the answer is more anchors rather than a larger plane — several small planes with recorded origins compose cleanly, and one oversized plane does not.
3. Convert through ECEF rather than approximating #
from pyproj import Transformer
def enu_frame(origin_lon: float, origin_lat: float, origin_h: float = 0.0):
"""Return a function converting WGS84 lon/lat/h to metric ENU about the origin."""
to_ecef = Transformer.from_crs("EPSG:4979", "EPSG:4978",
always_xy=True, allow_network=False)
ox, oy, oz = to_ecef.transform(origin_lon, origin_lat, origin_h)
lam, phi = np.radians(origin_lon), np.radians(origin_lat)
R = np.array([
[-np.sin(lam), np.cos(lam), 0.0],
[-np.sin(phi) * np.cos(lam), -np.sin(phi) * np.sin(lam), np.cos(phi)],
[ np.cos(phi) * np.cos(lam), np.cos(phi) * np.sin(lam), np.sin(phi)],
])
def to_enu(lon, lat, h=0.0):
x, y, z = to_ecef.transform(lon, lat, h)
return R @ (np.array([x, y, z]) - np.array([ox, oy, oz]))
return to_enu
The rotation matrix is the whole of the tangent plane — everything else is an exact geodetic conversion. A flat-earth approximation that scales degrees by a fixed metres-per-degree is where the "tangent planes are inaccurate" folklore comes from, and it is avoidable.
4. Record the anchor with every tile #
def anchor_metadata(lon, lat, h, datum="WGS84", epoch="2026.0") -> dict:
return {"origin_lon": lon, "origin_lat": lat, "origin_h": h,
"datum": datum, "epoch": epoch, "axes": "ENU"}
Five fields, written into every tile rather than into a release-level manifest. The failure this prevents is two operating areas built against different anchors being stitched by a consumer that assumed one, which produces a map offset by the distance between the two origins — kilometres — and no error anywhere.
The five fields that have to travel with every tile, and what breaks when each is missing:
Verification & Acceptance Criteria #
def assert_tangent_plane(to_enu, origin, tiles, budget_m=0.10) -> None:
assert np.allclose(to_enu(*origin), 0.0, atol=1e-6), "origin is not at zero"
for t in tiles:
r = np.linalg.norm(to_enu(*t.centroid_lonlat)[:2])
assert r <= max_radius_m(budget_m), \
f"tile {t.id} is {r:.0f} m from the anchor, past the {budget_m} m bound"
assert t.anchor == anchor_metadata(*origin), \
f"tile {t.id} carries a different anchor"
Acceptance gate: the origin maps to exactly zero; every tile centroid inside the computed radius; every tile carrying an identical anchor record; and a round trip through ENU and back to WGS84 agreeing with the input to ≤1e-4 m, the same round-trip discipline applied in converting WGS84 to UTM for AV pipelines.
What each frame costs a fleet, once the operating area is fixed:
Common Errors & Fixes #
Heights drift as you move away from the anchor. Expected — that is the curvature term. Either shrink the plane or accept the budget; do not add a fudge factor, which converts a predictable error into an unpredictable one.
Two areas stitch with a kilometre-scale offset. Different anchors, both unrecorded. Store the anchor per tile and assert equality before stitching.
Coordinates are metric and slightly wrong everywhere. A flat-earth degrees-to-metres approximation was used instead of the ECEF path. Replace it; the exact conversion is not measurably slower.
The origin sits in a river. The bounding-box centre was used. Weight by driven distance as in step 1.
FAQ #
When is a tangent plane better than UTM? #
When the operating area is compact and the zone seam would fall inside it. A tangent plane has no zones, so there is no discontinuity to blend across and no scale factor varying with distance from a central meridian. The trade is that it is local by construction: coordinates are only meaningful relative to their anchor, so every tile has to carry that anchor and two areas cannot be compared without converting both back through a global frame.
How large can a tangent plane be before the flat-earth assumption hurts? #
The vertical error grows roughly as the square of the horizontal distance over twice the Earth radius, so it reaches about 0.08 metres at 1 kilometre, 2 metres at 5 kilometres and 8 metres at 10 kilometres. Horizontal error grows far more slowly. For a lane-level vertical budget of a few centimetres the usable radius is a couple of kilometres, which is why fleets anchor per operating area rather than per city.
What has to be stored alongside a tangent-plane map? #
The origin's geodetic latitude, longitude and height, the datum and its epoch, and the axis convention. Without all five, the coordinates cannot be converted back to anything global and the map is unusable outside the pipeline that produced it. Storing them per tile rather than per release costs almost nothing and removes an entire class of integration failure where two areas silently use different anchors.
Related #
- Handling UTM Zone Boundary Crossings in AV Maps — the problem a tangent plane removes rather than manages.
- Converting WGS84 to UTM for AV Pipelines — the alternative, and the round-trip gate both frames need.
- Handling Coordinate Drift in Multi-Sensor Setups — the runtime consumer that benefits most from a seamless frame.
Up one level: Coordinate Reference Systems for AVs — the stage this frame choice belongs to.