Python Script for Geodetic Inverse Problem Solving: Deterministic Vincenty with Antipodal Fallback
Solving the geodetic inverse problem — the ellipsoidal distance, forward azimuth, and reverse azimuth between two known coordinates — must return a finite, auditable result within ±0.001 m of the reference solution even at antipodal configurations where the classical iteration refuses to converge. This operation feeds directly into error distribution modeling in Python, the parent topic under Algorithmic Math & Geodetic Workflows, because every distance and bearing it produces becomes an observation whose residual is later propagated through a covariance matrix.
Why the Iteration Must Be Gated
The Vincenty inverse formula evaluates the angular separation
and updates NaN or hangs the pipeline. A hard iteration cap converts that pathology into a deterministic branch: when the cap is reached, the solver routes to a spherical great-circle approximation that always returns a finite value, flagging the result so downstream stages can widen the uncertainty buffer. This is the same numerical-stability discipline that governs least-squares adjustment for control networks, where a single non-finite observation invalidates the entire normal-equation system.
A second hazard is IEEE-754 micro-drift: math.sqrt or division then raises a domain error. Clamping
Complete Runnable Implementation
The solver below is self-contained, type-hinted, and frozen-dataclass output for safe reuse inside automated pipelines. It hard-caps iteration, clamps the eccentricity term, and falls back to a great-circle solution when the ellipsoidal series fails to contract.
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class GeodeticInverseResult:
distance_m: float
forward_azimuth_deg: float
reverse_azimuth_deg: float
convergence_iterations: int
fallback_triggered: bool
class GeodeticInverseSolver:
"""Deterministic Vincenty inverse solver with antipodal fallback routing.
Implements the NGS Vincenty inverse formulation on the WGS84 ellipsoid
with an explicit iteration cap and a survey-grade convergence tolerance.
"""
# WGS84 ellipsoid parameters (EPSG:7030)
A: float = 6378137.0 # semi-major axis (m)
F: float = 1.0 / 298.257223563 # flattening
B: float = A * (1.0 - F) # semi-minor axis (m)
MAX_ITERATIONS: int = 1000
CONVERGENCE_TOLERANCE: float = 1e-12 # radians on lambda
@classmethod
def solve(cls, lat1_deg: float, lon1_deg: float,
lat2_deg: float, lon2_deg: float) -> GeodeticInverseResult:
# Short-circuit coincident coordinates before the iteration runs.
if (math.isclose(lat1_deg, lat2_deg, abs_tol=1e-12)
and math.isclose(lon1_deg, lon2_deg, abs_tol=1e-12)):
return GeodeticInverseResult(0.0, 0.0, 0.0, 0, False)
lat1, lon1, lat2, lon2 = map(math.radians,
(lat1_deg, lon1_deg, lat2_deg, lon2_deg))
U1 = math.atan((1.0 - cls.F) * math.tan(lat1)) # reduced latitude
U2 = math.atan((1.0 - cls.F) * math.tan(lat2))
L = lon2 - lon1
lambda_val = L
sinU1, cosU1 = math.sin(U1), math.cos(U1)
sinU2, cosU2 = math.sin(U2), math.cos(U2)
fallback_triggered = False
iterations = 0
sin_sigma = cos_sigma = sigma = cos2_sigma_m = cos_sq_alpha = 0.0
sin_lambda = cos_lambda = 0.0
for _ in range(cls.MAX_ITERATIONS):
iterations += 1
sin_lambda = math.sin(lambda_val)
cos_lambda = math.cos(lambda_val)
sin_sigma = math.sqrt(
(cosU2 * sin_lambda) ** 2
+ (cosU1 * sinU2 - sinU1 * cosU2 * cos_lambda) ** 2
)
if sin_sigma == 0.0: # coincident on the auxiliary sphere
return GeodeticInverseResult(0.0, 0.0, 0.0, iterations, False)
cos_sigma = sinU1 * sinU2 + cosU1 * cosU2 * cos_lambda
sigma = math.atan2(sin_sigma, cos_sigma)
sin_alpha = (cosU1 * cosU2 * sin_lambda) / sin_sigma
cos_sq_alpha = max(1.0 - sin_alpha ** 2, 0.0) # clamp IEEE-754 drift
cos2_sigma_m = (cos_sigma - (2.0 * sinU1 * sinU2) / cos_sq_alpha
if cos_sq_alpha != 0.0 else 0.0)
C = (cls.F / 16.0) * cos_sq_alpha * (4.0 + cls.F * (4.0 - 3.0 * cos_sq_alpha))
lambda_prev = lambda_val
lambda_val = L + (1.0 - C) * cls.F * sin_alpha * (
sigma + C * sin_sigma * (
cos2_sigma_m + C * cos_sigma * (-1.0 + 2.0 * cos2_sigma_m ** 2)
)
)
if abs(lambda_val - lambda_prev) < cls.CONVERGENCE_TOLERANCE:
break
else:
fallback_triggered = True
if fallback_triggered:
# Spherical great-circle fallback: always finite near antipodal points.
cos_sig = math.sin(lat1) * math.sin(lat2) + \
math.cos(lat1) * math.cos(lat2) * math.cos(L)
sigma = math.acos(max(-1.0, min(1.0, cos_sig)))
distance_m = cls.A * sigma
fwd = math.atan2(math.sin(L) * math.cos(lat2),
math.cos(lat1) * math.sin(lat2)
- math.sin(lat1) * math.cos(lat2) * math.cos(L))
rev = math.atan2(math.sin(-L) * math.cos(lat1),
math.cos(lat2) * math.sin(lat1)
- math.sin(lat2) * math.cos(lat1) * math.cos(-L))
return GeodeticInverseResult(
distance_m=distance_m,
forward_azimuth_deg=math.degrees(fwd) % 360.0,
reverse_azimuth_deg=(math.degrees(rev) + 180.0) % 360.0,
convergence_iterations=iterations,
fallback_triggered=True,
)
# Post-convergence ellipsoidal distance via the Vincenty series.
u_sq = cos_sq_alpha * (cls.A ** 2 - cls.B ** 2) / cls.B ** 2
A_c = 1.0 + (u_sq / 16384.0) * (4096.0 + u_sq * (-768.0 + u_sq * (320.0 - 175.0 * u_sq)))
B_c = (u_sq / 1024.0) * (256.0 + u_sq * (-128.0 + u_sq * (74.0 - 47.0 * u_sq)))
delta_sigma = B_c * sin_sigma * (
cos2_sigma_m + (B_c / 4.0) * (
cos_sigma * (-1.0 + 2.0 * cos2_sigma_m ** 2)
- (B_c / 6.0) * cos2_sigma_m * (-3.0 + 4.0 * sin_sigma ** 2)
* (-3.0 + 4.0 * cos2_sigma_m ** 2)
)
)
distance_m = cls.B * A_c * (sigma - delta_sigma)
fwd = math.atan2(cosU2 * sin_lambda, cosU1 * sinU2 - sinU1 * cosU2 * cos_lambda)
rev = math.atan2(cosU1 * sin_lambda, -sinU1 * cosU2 + cosU1 * sinU2 * cos_lambda)
return GeodeticInverseResult(
distance_m=distance_m,
forward_azimuth_deg=math.degrees(fwd) % 360.0,
reverse_azimuth_deg=(math.degrees(rev) + 180.0) % 360.0,
convergence_iterations=iterations,
fallback_triggered=False,
)
Parameter Reference
| Name | Type | Units | Valid range | Notes |
|---|---|---|---|---|
lat1_deg, lat2_deg |
float |
degrees | −90 to +90 | Geodetic latitudes of the two stations |
lon1_deg, lon2_deg |
float |
degrees | −180 to +180 | Geodetic longitudes |
CONVERGENCE_TOLERANCE |
float |
radians | 1e-12 typical |
Δλ contraction threshold per iteration |
MAX_ITERATIONS |
int |
count | 1000 default | Hard cap that triggers the fallback branch |
distance_m |
float |
metres | ≥ 0 | Ellipsoidal (or great-circle, on fallback) distance |
forward_azimuth_deg |
float |
degrees | 0 ≤ x < 360 | Bearing at point 1 toward point 2 |
reverse_azimuth_deg |
float |
degrees | 0 ≤ x < 360 | Bearing at point 2 toward point 1 |
fallback_triggered |
bool |
— | — | True when the great-circle path was used |
Worked Example
Solving the inverse between two U.S. National Spatial Reference System control points — Flinders Peak and Buninyong in the classic Vincenty test pair — returns the published ellipsoidal distance to sub-millimetre agreement.
# Flinders Peak (-37°57'03.72030", 144°25'29.52440") to
# Buninyong (-37°39'10.15610", 143°55'35.38390") in decimal degrees.
r = GeodeticInverseSolver.solve(-37.95103342, 144.42486789,
-37.65282114, 143.92649553)
print(round(r.distance_m, 3), round(r.forward_azimuth_deg, 5), r.fallback_triggered)
# 54972.271 306.86816 False
Validation Check
Gate the result against survey tolerance before it is committed to a control network adjustment or a cadastral database.
EXPECTED_M = 54972.271
assert math.isfinite(r.distance_m), "non-finite distance"
assert 0.0 <= r.forward_azimuth_deg < 360.0, "azimuth out of bounds"
assert abs(r.distance_m - EXPECTED_M) < 0.001, "distance drift exceeds survey tolerance"
Common Mistakes
- Treating a fallback result as ellipsoidal. When
fallback_triggeredisTrue, the distance is a spherical great-circle value that can deviate by tens of metres from the true geodesic. Always inspect the flag and inflate the observation variance — or switch to Karney’s algorithm — before passing the value into a covariance propagation step. - Omitting the iteration cap. A
while abs(dlambda) > tolloop with no bound will hang or emitNaNon antipodal pairs. Thefor ... elseconstruction here guarantees a deterministic exit on every input. - Forgetting the reduced latitude. Vincenty operates on the reduced (parametric) latitude
, not the geodetic latitude. Passing directly produces a systematic bias that grows with the flattening term — the same class of error that distorts an affine transformation fit for a local grid when the input frame is mislabelled.
Related
- Error Distribution Modeling in Python — the parent topic where these distances and azimuths become bounded observations
- Visualizing geodetic error ellipses with Matplotlib — render the positional uncertainty derived from these residuals
- Least-squares adjustment for control networks — consume inverse-solved vectors as network observations
- Geodetic conversion math: ellipsoid to Cartesian — the complementary forward mapping into ECEF space
- Algorithmic Math & Geodetic Workflows — the full deterministic pipeline reference