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
Figure — how much each term of the series is worth at 300 km from the central meridian.
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.
Figure — the seven constants a forward projection needs before it can start.
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.Frequently Asked Questions
How many terms of the series do I need?
Through the eighth order — commonly written as terms one, three, five and seven of the easting series — is the standard for survey work, and it holds sub-millimetre accuracy across a normal three-degree zone. Truncating after the fifth term costs under a millimetre at the zone edge; truncating after the third costs nearly half a metre there while looking perfect near the central meridian, which is the worst kind of error because testing near the origin will not catch it.
Why does my implementation disagree with PROJ by a few millimetres far from the zone?
Almost certainly the series order, or the meridian arc formula. PROJ uses an expansion accurate well beyond the standard zone width; a classical eighth-order series diverges from it as you move out. Agreement should be tested at the zone edge and beyond, not at the origin. If the disagreement is constant rather than growing, suspect the false easting or the scale factor instead.
Should the projection routine also return the scale factor and convergence?
Yes. They are computed from quantities the projection already has, so they are nearly free, and without them the easting and northing cannot be used for reductions or bearings. A routine that returns only the coordinate pair pushes that work — and the chance of getting it wrong — onto every caller.
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.