Geodetic Conversion Math: Ellipsoid to Cartesian for Survey-Grade Pipelines
The forward conversion from geodetic coordinates (latitude, longitude, ellipsoidal height) to Earth-Centered, Earth-Fixed (ECEF) Cartesian coordinates (X, Y, Z) is the first deterministic stage of nearly every workflow in Algorithmic Math & Geodetic Workflows: it lifts a position off the curved reference ellipsoid into a rectangular frame where datum shifts and least-squares adjustments become linear algebra. Unlike a map projection, which introduces zone-dependent scale distortion, the ellipsoid-to-Cartesian mapping is closed-form, non-iterative, and preserves the exact geometric definition of the ellipsoid. For cadastral registration, national mapping frameworks, and high-precision GIS infrastructure, this step must enforce strict numerical determinism, explicit precision boundaries, and full transformation traceability so that epoch consistency and metadata integrity survive across distributed compute nodes.
Specification: The Forward Geodetic-to-ECEF Definition
The conversion operates on a reference ellipsoid parameterized by semi-major axis
Given geodetic latitude
The ECEF coordinates then follow directly, with no convergence loop:
Because the formulation is strictly forward, it carries none of the convergence uncertainty inherent in the inverse (ECEF-to-geodetic) direction, which is handled separately by Bowring’s closed-form solution in the verification section below. Determinism is preserved by three rules that the implementation enforces: evaluate every term in IEEE 754 double precision (float64), convert degrees to radians explicitly before any trigonometric call, and bound inputs to
Step-by-Step Implementation
The implementation builds in four ordered stages. Each stage is a runnable, type-hinted function in the same module; later blocks reuse the names defined earlier, so run them in sequence. Every function enforces numpy.float64 precision and avoids implicit float promotion.
Step 1 — Pin the ellipsoid constants
Compute
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from typing import Union
import numpy as np
logger = logging.getLogger("geodetic.ecef")
ArrayLike = Union[float, np.ndarray]
@dataclass(frozen=True)
class Ellipsoid:
"""Defining parameters of a reference ellipsoid (ISO 19111 datum ensemble member)."""
a: float # semi-major axis, metres
inv_f: float # inverse flattening, dimensionless
@property
def f(self) -> float: # flattening: f = 1 / (1/f)
return 1.0 / self.inv_f
@property
def e2(self) -> float: # first eccentricity squared: e^2 = 2f - f^2 (ISO 19111 4.1.3)
return 2.0 * self.f - self.f**2
@property
def b(self) -> float: # semi-minor axis: b = a(1 - f)
return self.a * (1.0 - self.f)
# EPSG:7030 — WGS 84 defining ellipsoid (a, 1/f). Used by EPSG:4979 / EPSG:4978.
WGS84 = Ellipsoid(a=6378137.0, inv_f=298.257223563)
Step 2 — Convert to radians and validate bounds
Latitude and longitude arrive in degrees from most data sources, so cast to float64 and convert before any trigonometric evaluation. Out-of-bounds or non-finite inputs are routed to a deterministic NaN sentinel rather than silently wrapping, satisfying the explicit-validity requirement of the ISO 19111 coordinate operation model.
def _to_radians_validated(
lat_deg: ArrayLike, lon_deg: ArrayLike, h_m: ArrayLike
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Cast to float64, convert to radians, and NaN-route invalid coordinates."""
# Explicit float64 cast prevents implicit float32 promotion downstream.
lat = np.asarray(lat_deg, dtype=np.float64) * (math.pi / 180.0)
lon = np.asarray(lon_deg, dtype=np.float64) * (math.pi / 180.0)
h = np.asarray(h_m, dtype=np.float64)
# Bounds per the geographic CRS domain of validity (phi in [-pi/2, pi/2]).
valid = (
(lat >= -math.pi / 2) & (lat <= math.pi / 2)
& (lon >= -math.pi) & (lon <= math.pi)
& np.isfinite(lat) & np.isfinite(lon) & np.isfinite(h)
)
if not np.all(valid):
bad = ~valid
logger.warning("Coordinate bounds violation on %d input(s); routing to NaN.", int(bad.sum()))
lat = np.where(bad, np.nan, lat)
lon = np.where(bad, np.nan, lon)
h = np.where(bad, np.nan, h)
return lat, lon, h
Step 3 — Evaluate the prime vertical radius of curvature
Compute ZeroDivisionError from ever propagating into a cadastral pipeline.
def prime_vertical_radius(lat_rad: np.ndarray, ell: Ellipsoid = WGS84) -> np.ndarray:
"""N(phi) = a / sqrt(1 - e^2 sin^2 phi) (ISO 19111 geographic-to-geocentric)."""
sin2 = np.sin(lat_rad) ** 2
denom = np.sqrt(1.0 - ell.e2 * sin2)
# Replace an exact zero with the smallest positive float64 to stay total.
denom = np.where(denom == 0.0, np.finfo(np.float64).tiny, denom)
return ell.a / denom
Step 4 — Assemble the ECEF vector
Combine the validated angles, the height, and float64 arrays so that no downstream consumer can re-introduce a narrower dtype.
def geodetic_to_ecef(
lat_deg: ArrayLike,
lon_deg: ArrayLike,
h_m: ArrayLike,
ell: Ellipsoid = WGS84,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Deterministic forward transform: geographic (EPSG:4979) -> geocentric (EPSG:4978).
Method: EPSG:9602 Geographic/geocentric conversions. Enforces float64,
explicit bounds checking, and NaN fallback routing.
"""
lat, lon, h = _to_radians_validated(lat_deg, lon_deg, h_m)
n = prime_vertical_radius(lat, ell)
cos_phi, sin_phi = np.cos(lat), np.sin(lat)
cos_lam, sin_lam = np.cos(lon), np.sin(lon)
x = (n + h) * cos_phi * cos_lam
y = (n + h) * cos_phi * sin_lam
z = (n * (1.0 - ell.e2) + h) * sin_phi # note the (1 - e^2) factor on Z only
return x.astype(np.float64), y.astype(np.float64), z.astype(np.float64)
ISO 19111 requires explicit Coordinate Reference System identification and method registration on every operation. In practice each batch is annotated with the EPSG URNs urn:ogc:def:crs:EPSG::4979 (WGS 84 3D geographic) and urn:ogc:def:crs:EPSG::4978 (WGS 84 geocentric), the method code EPSG:9602, and the coordinate epoch. Where the ECEF output feeds a localized mapping frame, downstream alignment usually requires implementing affine transformations for local grids to absorb crustal deformation and legacy datum offsets.
Parameter and Return-Value Reference
Every key input and output, with the type, unit, valid range, and why it matters for survey-grade work. The table scrolls horizontally on narrow screens.
| Name | Type | Units | Valid range | Cadastral significance |
|---|---|---|---|---|
lat_deg |
float / np.ndarray |
degrees | Geodetic (not geocentric) latitude; a swap with longitude relocates a parcel by hundreds of km | |
lon_deg |
float / np.ndarray |
degrees | East-positive; sign error mirrors the position across the prime meridian | |
h_m |
float / np.ndarray |
metres | unbounded (practical |
Must be ellipsoidal height; an orthometric height injects a geoid-separation bias up to ~100 m |
ell.a |
float |
metres | 6378137.0 for WGS 84 |
Semi-major axis fixes the absolute scale of X, Y, Z |
ell.inv_f |
float |
dimensionless | 298.257223563 for WGS 84 |
Inverse flattening drives |
X |
np.ndarray (float64) |
metres | Geocentric X toward |
|
Y |
np.ndarray (float64) |
metres | Geocentric Y toward |
|
Z |
np.ndarray (float64) |
metres | Geocentric Z toward the north pole |
Worked Example with Real-World Coordinates
Two checks pin the implementation to values that are exact by definition of the WGS 84 ellipsoid, then a realistic monument confirms behaviour off the axes. At the equator/prime-meridian intersection (
# Definitional control points — exact for the WGS 84 ellipsoid (EPSG:7030).
x0, y0, z0 = geodetic_to_ecef(0.0, 0.0, 0.0)
assert np.isclose(x0, WGS84.a, atol=1e-6), x0 # X = a at (0, 0, 0)
assert np.isclose(y0, 0.0, atol=1e-6) and np.isclose(z0, 0.0, atol=1e-6)
_, _, zp = geodetic_to_ecef(90.0, 0.0, 0.0)
assert np.isclose(zp, WGS84.b, atol=1e-6), zp # Z = b at the pole
# Realistic monument: Boulder, CO area, WGS 84 geographic (EPSG:4979).
lat, lon, h = 40.01500000, -105.27055556, 1655.000
X, Y, Z = geodetic_to_ecef(lat, lon, h)
print(f"X={X:.3f} Y={Y:.3f} Z={Z:.3f}")
# X=-1288680.076 Y=-4720150.098 Z=4080325.441 (metres, EPSG:4978)
The printed triple is the geocentric position the rest of the survey-grade chain consumes: a datum shift, then a projection, then a residual gate against independent control. Treat the printed values as illustrative to three decimals; the verification step below is what actually proves correctness to sub-millimetre tolerance, independent of any hand-copied number.
Verification and Residual Analysis
The robust check for a forward conversion is a round trip: invert the ECEF vector back to geodetic coordinates with an independent algorithm and confirm the residual is below the survey-grade tolerance. Bowring’s closed-form inverse is used here so the round trip does not simply re-run the forward math in reverse.
def ecef_to_geodetic(
x: ArrayLike, y: ArrayLike, z: ArrayLike, ell: Ellipsoid = WGS84
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Bowring closed-form inverse: geocentric (EPSG:4978) -> geographic (EPSG:4979)."""
x = np.asarray(x, dtype=np.float64)
y = np.asarray(y, dtype=np.float64)
z = np.asarray(z, dtype=np.float64)
e2, a, b = ell.e2, ell.a, ell.b
ep2 = (a**2 - b**2) / b**2 # second eccentricity squared
p = np.sqrt(x**2 + y**2)
theta = np.arctan2(z * a, p * b)
lat = np.arctan2(
z + ep2 * b * np.sin(theta) ** 3,
p - e2 * a * np.cos(theta) ** 3,
)
lon = np.arctan2(y, x)
n = a / np.sqrt(1.0 - e2 * np.sin(lat) ** 2)
h = p / np.cos(lat) - n
return np.degrees(lat), np.degrees(lon), h
def assert_round_trip(
lat_deg: float, lon_deg: float, h_m: float, tol_m: float = 1e-4
) -> dict[str, float]:
"""Forward then inverse; emit a structured audit record and enforce tolerance."""
X, Y, Z = geodetic_to_ecef(lat_deg, lon_deg, h_m)
lat2, lon2, h2 = ecef_to_geodetic(X, Y, Z)
# Horizontal residual via local metres-per-degree at this latitude.
m_per_deg_lat = 111_132.0
m_per_deg_lon = 111_320.0 * math.cos(math.radians(lat_deg))
d_north = float((lat2 - lat_deg) * m_per_deg_lat)
d_east = float((lon2 - lon_deg) * m_per_deg_lon)
d_up = float(h2 - h_m)
residual_3d = math.sqrt(d_north**2 + d_east**2 + d_up**2)
record = {
"source_crs": "EPSG:4979",
"target_crs": "EPSG:4978",
"method": "EPSG:9602",
"residual_3d_m": residual_3d,
"tolerance_m": tol_m,
}
logger.info("ecef_roundtrip %s", record)
assert residual_3d <= tol_m, f"round-trip residual {residual_3d:.2e} m exceeds {tol_m} m"
return record
# Confirm the Boulder monument survives a forward/inverse round trip within 0.1 mm.
report = assert_round_trip(40.01500000, -105.27055556, 1655.000, tol_m=1e-4)
assert report["residual_3d_m"] < 1e-4
When this conversion is the front of a longer chain, residuals must be tracked across stages rather than only at the end. Systematic (non-zero-mean) residuals point to a wrong reference frame or a missing epoch and are formalized by error distribution modeling in Python; residuals from regional datum reconciliation are absorbed by polynomial shift algorithms for regional adjustments and resolved rigorously by a least squares adjustment for control networks. The control-point comparison itself, applied to a full datum alignment, is detailed in validating datum alignment with control points, and step-by-step isolation of model artefacts from genuine discrepancies is covered in debugging polynomial shift residuals in GIS.
Troubleshooting and Gotchas
The conversion is short, but a handful of specific mistakes turn an exact closed-form mapping into a silently biased one.
My Z values are tens of metres off but X and Y look fine — what happened?
Why did re-deriving e² inside a vectorized loop change my results in the last digits?
Everything was correct in a notebook but the parcels shifted hundreds of kilometres in production.
I mixed float32 arrays into the pipeline and lost sub-centimetre agreement.
A coordinate at the exact pole or with a NaN crashed the batch.
Related References
- Algorithmic Math & Geodetic Workflows — the parent reference this conversion feeds, covering the full transform-shift-project-validate chain.
- Implementing affine transformations for local grids — six-parameter realignment that consumes the ECEF output.
- Polynomial shift algorithms for regional adjustments — non-linear surface fitting for statewide modernization.
- Least squares adjustment for control networks — rigorous residual minimization with covariance output.
- Debugging polynomial shift residuals in GIS — isolating numerical artefacts from genuine geodetic discrepancies.