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:

Tangent-Plane Vertical Error against Distance from the Anchor A quadratic error curve on a logarithmic vertical axis with a horizontal budget line, marking the radius at which the approximation stops meeting a lane-level vertical tolerance. 0.01 m0.1 m 1 m10 m 01 km2 km 5 km10 km horizontal distance from the anchor → 0.10 m vertical budget usable radius ≈ 1.1 km 0.080.31 2.07.8 m horizontal error grows far more slowly — it is the vertical term that bounds the plane

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.

python
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 #

python
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 #

python
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 #

python
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:

The Anchor Record and What Each Field Prevents Five rows pairing an anchor metadata field with the specific failure its absence produces. five fields, written per tile rather than per release origin lon / lat where the plane touches without it, no conversion back to a global frame unusable outside the pipeline origin height the vertical datum point without it, a constant vertical offset between areas silent grade error datum which realisation of the frame without it, a metre-scale shift between sources silent position error epoch when that realisation was measured without it, tectonic drift is blamed on extraction grows every release axes ENU rather than NED without it, east and north can be swapped obvious once, invisible twice per-tile rather than per-release, because the failure this prevents is two areas built against different anchors

Verification & Acceptance Criteria #

python
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:

Zoned Projection Against a Local Plane, by Operating Area Three rows pairing an operating-area size with the frame it favours and the specific cost each incurs. the frame follows the operating area, not the other way round one city · ~30 km no zone boundary inside it tangent planes, ~30 anchors, no seam work tangent plane metro region · ~120 km crosses one zone boundary either many anchors or one seam corridor usually UTM + seam national fleet · several zones crosses many boundaries thousands of anchors is not a design UTM, seams managed the middle row is where teams argue; the answer usually turns on whether tiles are already stitched across areas

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.

Up one level: Coordinate Reference Systems for AVs — the stage this frame choice belongs to.