Handling UTM Zone Boundary Crossings in AV Maps

Route an autonomous vehicle across a 6° UTM meridian and, unless the map is built for it, the vehicle frame jumps by hundreds of kilometres of easting the instant a tile switches zones — a discontinuity no localization filter recovers from cleanly. This task makes the crossing invisible: assign one zone per tile, stand up an overlap corridor around the meridian, blend the two zone solutions across it, and gate the residual step. It follows directly from the coordinate reference systems for AVs stage, which commits each tile to a projection; here we handle what happens where two of those projections meet.

An overlap corridor around the 6° meridian lets the vehicle frame blend from zone A to zone B without a step:

UTM Zone Overlap Corridor and Pose Blend A horizontal track with zone A on the left and zone B on the right, separated by a dashed vertical meridian. A corridor band spans the meridian. A blend weight t goes from 0 at the corridor entry to 1 at the exit. A vehicle marker sits mid-corridor. Below, a note states the seam step must stay under 0.02 metres. 6° meridian Zone A EPSG 326xx Zone B EPSG 326yy overlap corridor pose = (1−t)·A + t·B t = 0 t = 1 seam step ≤ 0.02 m

Prerequisites #

  • Python 3.10+, pyproj 3.6+ (PROJ 9.x), NumPy 1.24+, and Shapely 2.0+ for corridor geometry.
  • Input: HD map tiles with per-tile metadata carrying a resolved UTM EPSG code (from the WGS84→UTM conversion stage) and a tile centroid in WGS84.
  • Upstream stage: dynamic zone resolution has already assigned each tile a single zone; this step handles adjacency where those assignments differ.
  • Output: an overlap corridor of dual-zone tiles plus a blend function that returns a continuous pose, with the seam discontinuity certified ≤0.02 m.

Step-by-Step #

1. Resolve and pin one zone per tile #

Never let a tile hold coordinates from two zones. Resolve the zone from the tile centroid, and detect tiles near a meridian so they can be flagged for the corridor.

python
import numpy as np

def utm_zone(lon: float) -> int:
    return int((lon + 180.0) // 6) + 1            # 1..60

def zone_central_meridian(zone: int) -> float:
    return -180.0 + (zone - 0.5) * 6.0            # degrees longitude

def near_meridian(lon: float, lat: float, margin_m: float = 400.0) -> bool:
    """True if the point is within margin_m of its zone's east/west edge."""
    edge_lon = -180.0 + round((lon + 180.0) / 6.0) * 6.0   # nearest 6° edge
    m_per_deg = 111_320.0 * np.cos(np.radians(lat))
    return abs(lon - edge_lon) * m_per_deg < margin_m

Key parameters: margin_m is the corridor half-width in metres; m_per_deg scales longitude degrees to metres at the tile's latitude so the margin is metric, not angular. Expected output: a boolean flag per tile marking corridor membership.

2. Build the overlap corridor #

For every corridor tile, materialize a second copy reprojected into the neighbouring zone. The vehicle then always has coverage in whichever zone it currently occupies.

python
from pyproj import Transformer

def dual_zone_tile(points_wgs84: np.ndarray, zone_a: int, zone_b: int):
    """Return the tile's points in both neighbouring UTM zones (metres)."""
    def to_zone(epsg):
        tr = Transformer.from_crs(4326, epsg, always_xy=True)
        e, n = tr.transform(points_wgs84[:, 0], points_wgs84[:, 1])
        return np.column_stack([e, n])
    epsg_a = 32600 + zone_a
    epsg_b = 32600 + zone_b
    return {"zone_a": to_zone(epsg_a), "zone_b": to_zone(epsg_b)}

Key parameters: zone_a/zone_b are the adjacent zones; the function returns both projections so the runtime can pick by current position. Store both under the tile id with the source WGS84 retained for re-derivation.

The corridor is sized from the runtime, not from the map: it has to still be underneath the vehicle after a full localization lookahead plus the tile it is about to leave.

Sizing the Overlap Corridor from Lookahead Plus Tile Length A horizontal distance scale from zero to 500 metres from the meridian. Stacked bars show a 120 metre lookahead and a 250 metre tile summing to a 370 metre minimum. A 400 metre corridor bar clears the minimum; a 150 metre bar falls short and is marked as a hard switch mid-manoeuvre. 0100200 300400500 m distance from the 6° meridian lookahead 120 m one tile 250 m minimum 370 m margin_m = 400 m — blend completes 150 m vehicle exits before t reaches 1 — hard switch mid-manoeuvre

3. Blend poses across the seam #

Inside the corridor, compute the pose in both zones and linearly weight them by the vehicle's fractional position across the corridor. Blending happens after reprojecting both solutions into one working frame (ENU or the outgoing zone) so the interpolation is metrically meaningful.

python
def blend_pose(pose_a: np.ndarray, pose_b: np.ndarray, t: float) -> np.ndarray:
    """Linear blend of two same-frame poses; t in [0, 1] across the corridor."""
    t = float(np.clip(t, 0.0, 1.0))
    return (1.0 - t) * pose_a + t * pose_b

Key parameter: t is the crossing fraction — 0 at the entry edge, 1 at the exit edge — so the vehicle frame slides continuously from zone A to zone B. Both inputs must already be in the same working frame; blending raw eastings from two different zones is meaningless.

Verification & Acceptance Criteria #

Certify the seam before shipping the tile pair. Sample poses densely across the corridor and confirm the maximum step between consecutive samples stays under the discontinuity budget.

python
def assert_seam_continuous(poses: np.ndarray, tol_m: float = 0.02):
    steps = np.linalg.norm(np.diff(poses, axis=0), axis=1)
    # remove the expected per-sample motion; only the residual jump matters
    residual = steps - np.median(steps)
    assert residual.max() < tol_m, f"seam step {residual.max():.3f} m > {tol_m}"
    print(f"max seam residual = {residual.max()*1000:.1f} mm")   # < 20 mm

Acceptance gate: maximum seam residual ≤0.02 m, blend weight t monotonic across the corridor, and both zone copies present for every corridor tile. Cross-check that no shipped tile carries two EPSG codes — the single-zone invariant from the parent CRS stage still holds per tile.

Why heading is the one quantity the linear blend must not touch: at the ±180° branch cut, averaging the numbers sweeps the vehicle through the whole compass to travel 20°.

Blending Heading Across the ±180° Branch Cut Left compass: raw angle averaging between plus 170 and minus 170 degrees traverses the long 340 degree arc through zero. Right compass: interpolating the heading as a unit vector traverses the short 20 degree arc through 180 degrees. +170° −170° lerp on raw yaw — 340° sweep (170 + −170) / 2 = 0° +170° −170° 20° unit-vector / SLERP — 20° arc interpolate (cos θ, sin θ), then atan2

Common Errors & Fixes #

Easting jumps ~500 km at the tile boundary. The runtime switched zones without reprojecting the previous pose into the new zone. Always transform the carried-forward pose into the incoming zone before comparing or blending; never subtract eastings across zones.

t never reaches 1 and the blend snaps at the far edge. The corridor is narrower than the localization lookahead, so the vehicle exits before the blend completes. Widen margin_m to at least the lookahead distance plus one tile length.

Scale error grows toward the meridian. Tiles were forced into a single zone across too wide a span. Confirm each tile uses its own centroid-resolved zone; beyond ~3–4° from the central meridian the transverse-Mercator scale exceeds the 0.05 m budget and needs the neighbouring zone.

Blended heading spins near ±180°. Linear interpolation of raw yaw wraps incorrectly across the branch cut. Blend heading as a unit vector (or with SLERP), not as a raw angle — the same interpolation used in motion-compensating LiDAR scans with SLERP.

FAQ #

Why not just keep the whole map in one UTM zone? #

Forcing a distant region into a single UTM zone grows the transverse-Mercator scale error beyond the 0.04 percent zone-edge budget — past roughly 3 to 4 degrees of longitude from the central meridian the scale distortion exceeds the 0.05 m RMSE localization tolerance. A fleet operating across a wide east-west span must use multiple zones and manage the seam, or switch to a custom transverse Mercator centred on the operational region.

How wide should the overlap corridor be? #

Size it to at least the localization lookahead plus one tile, typically 200 to 500 m either side of the meridian. It must be wide enough that the vehicle is fully inside the corridor for several localization cycles, so the blend completes before either zone's coverage runs out. Wider corridors cost duplicated storage; narrower ones risk a hard switch mid-manoeuvre.

Can I avoid the seam with a local tangent plane instead? #

Yes. Switching the runtime planner to a local ENU tangent plane anchored at a per-tile origin removes the zone seam entirely, because ENU has no zone concept. The trade-off is per-tile origin bookkeeping and a reprojection when tiles are stitched. Many fleets keep the archival map in UTM for reproducibility and derive ENU at runtime, which localizes the seam problem to tile load rather than the live control loop.

Up one level: Coordinate Reference Systems for AVs — the parent workflow covering zone resolution, datum, and geoid handling this seam step extends.