Implementing Transverse Mercator Forward Projection
The forward Transverse Mercator mapping — carrying geodetic float64 implementation of it using the classic Redfearn meridional-arc series, the direction-of-travel companion to the round-trip treatment in map projection forward and inverse in Python under Algorithmic Math & Geodetic Workflows. Where that reference builds the modern Krüger
Figure — the Redfearn forward mapping splits into an even-power northing branch built on the meridional arc and an odd-power easting branch.
The Series, Term by Term
Transverse Mercator is conformal, so a single scale governs both axes at each point, but that scale grows with distance from the central meridian; the Redfearn expansion makes the growth explicit. Two ellipsoidal radii appear: the prime-vertical radius
The northing is anchored on the meridional arc
With
Truncating after the fifth/sixth-order terms holds sub-millimetre agreement across a standard 6° zone — adequate for cadastral use, though the Krüger
Complete Runnable Implementation
The function below is self-contained: it takes the ellipsoid constants, the projection parameters, and a point, and returns easting, northing, and the point scale factor. It works entirely in float64 and converts degrees to radians internally so the caller never has to.
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class TMParameters:
"""Transverse Mercator definition. Angles in degrees, lengths in metres."""
a: float # semi-major axis, e.g. 6378137.0 (WGS84)
inv_f: float # inverse flattening, e.g. 298.257223563
lon_cm_deg: float # central meridian (longitude of natural origin)
lat_origin_deg: float # latitude of natural origin
k0: float # scale factor on the central meridian
false_easting: float # metres
false_northing: float # metres
def _meridian_arc(phi: float, a: float, e2: float) -> float:
"""Meridional arc M(phi): ellipsoid distance from the equator to phi (radians)."""
return a * (
(1 - e2/4 - 3*e2**2/64 - 5*e2**3/256) * phi
- (3*e2/8 + 3*e2**2/32 + 45*e2**3/1024) * math.sin(2*phi)
+ (15*e2**2/256 + 45*e2**3/1024) * math.sin(4*phi)
- (35*e2**3/3072) * math.sin(6*phi)
)
def tm_forward(p: TMParameters, lat_deg: float, lon_deg: float) -> tuple[float, float, float]:
"""Project geodetic (lat, lon) in degrees to (easting, northing, scale_factor).
Ellipsoidal Redfearn series truncated at the 5th/6th order in the longitude
difference; float64 throughout. Returns metres and a dimensionless scale factor.
"""
f = 1.0 / p.inv_f
e2 = f * (2.0 - f) # first eccentricity squared
ep2 = e2 / (1.0 - e2) # second eccentricity squared
phi = math.radians(lat_deg)
phi0 = math.radians(p.lat_origin_deg)
omega = math.radians(lon_deg - p.lon_cm_deg) # degrees -> radians here only
sin_phi, cos_phi = math.sin(phi), math.cos(phi)
t = math.tan(phi)
t2, t4 = t * t, t**4
nu = p.a / math.sqrt(1.0 - e2 * sin_phi * sin_phi) # prime-vertical radius
psi = 1.0 + ep2 * cos_phi * cos_phi # nu / rho
c = cos_phi
easting = p.false_easting + p.k0 * nu * (
omega * c
+ (omega**3 / 6.0) * c**3 * (psi - t2)
+ (omega**5 / 120.0) * c**5 * (
4.0*psi**3*(1 - 6*t2) + psi**2*(1 + 8*t2) - psi*2*t2 + t4)
)
m_phi = _meridian_arc(phi, p.a, e2)
m_phi0 = _meridian_arc(phi0, p.a, e2)
northing = p.false_northing + p.k0 * (
m_phi - m_phi0
+ nu * (
(omega**2 / 2.0) * sin_phi * c
+ (omega**4 / 24.0) * sin_phi * c**3 * (4.0*psi**2 + psi - t2)
+ (omega**6 / 720.0) * sin_phi * c**5 * (
8.0*psi**4*(11 - 24*t2) - 28*psi**3*(1 - 6*t2)
+ psi**2*(1 - 32*t2) - psi*2*t2 + t4)
)
)
# Point scale factor: k0 grown by the distance from the central meridian.
k = p.k0 * (
1.0
+ 0.5 * omega**2 * c**2 * psi
+ (omega**4 / 24.0) * c**4 * (4.0*psi**3*(1 - 6*t2) + psi**2*(1 + 24*t2) - 4*psi*t2)
)
return easting, northing, k
Parameter Reference
| Name | Type | Units | Valid range | Notes |
|---|---|---|---|---|
a |
float |
metres | 6378137.0 (WGS84/GRS80) | semi-major axis of the CRS ellipsoid |
inv_f |
float |
— | 298.2572… | inverse flattening; sets the eccentricity |
lon_cm_deg |
float |
degrees | zone central meridian | subtract from lon before the series; wrong value shifts every easting |
lat_origin_deg |
float |
degrees | 0 for UTM | enters only through |
k0 |
float |
— | 0.9996 (UTM) | scale factor on the central meridian |
false_easting / false_northing |
float |
metres | 500000 / 0 (UTM N) | added last to keep coordinates positive |
lat_deg, lon_deg |
float |
degrees | within the zone | the point to project; degrees, not radians |
| return | tuple[float, float, float] |
m, m, — | — | easting, northing, point scale factor |
Worked Example
Projecting a point in Vienna onto UTM zone 33N (EPSG:32633: central meridian 15°E,
utm33 = TMParameters(
a=6378137.0, inv_f=298.257223563, lon_cm_deg=15.0, lat_origin_deg=0.0,
k0=0.9996, false_easting=500000.0, false_northing=0.0,
)
E, N, k = tm_forward(utm33, 48.208889, 16.372778)
print(round(E, 3), round(N, 3), round(k, 9))
# 601987.912 5340428.817 0.999727803
Validation Check
The forward output is gated against an independent oracle — pyproj, driving the same EPSG:32633 definition — and accepted only if the two agree within the millimetre budget cadastral deliverables are graded against.
from pyproj import Transformer
xf = Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)
px, py = xf.transform(16.372778, 48.208889) # always_xy => (lon, lat)
E, N, _ = tm_forward(utm33, 48.208889, 16.372778)
separation = math.hypot(E - px, N - py)
assert separation < 1e-3, f"Redfearn forward disagrees with PROJ by {separation*1e3:.3f} mm"
The recovery of
Common Mistakes
Using the spherical Transverse Mercator formulas for cadastral accuracy
_meridian_arc series and the psi = nu/rho ratio are exactly the ellipsoidal corrections a survey grid depends on; never substitute R * phi for M(phi).Forgetting the false easting and northing
false_easting and false_northing only at the very end. Omit them and points west of the central meridian come out negative, and every coordinate is offset by 500 000 m in easting for a UTM zone — a defect that no downstream check will silently repair.Wrong central meridian, or feeding degrees where radians are expected
lon_deg already in radians (or forgetting to subtract lon_cm_deg before converting) inflates omega and produces nonsense. This implementation converts degrees to radians in exactly one place — inside tm_forward — so callers must always pass plain degrees.Related
- Map projection forward and inverse in Python — the parent reference pairing this forward mapping with the series inverse and the round-trip gate.
- Geodetic conversion math: ellipsoid to Cartesian — the ECEF lift used when the input first needs a datum shift.
- Error distribution modeling in Python — propagating a projection residual into a reported positional uncertainty.
- Algorithmic Math & Geodetic Workflows — the parent reference on the deterministic transformation chain.