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
Figure — a gated iteration has three exits, and only one of them returns a coordinate.
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.
Figure — quadratic convergence: the correction squares each pass, so five iterations is a generous cap.
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.
Frequently Asked Questions
Why does the iteration fail near antipodal points?
Because the geometry becomes genuinely ambiguous there. For two nearly antipodal points on an ellipsoid, many geodesics of almost equal length connect them, and the iterative formulation oscillates between them instead of converging. This is a property of the problem, not a bug in the implementation, and it is why the iteration must be capped and must raise rather than return its last estimate. Modern series solutions handle the case directly; the classical iteration does not.
What convergence threshold should I use?
Tie it to the ground precision you actually need rather than to a round number. A threshold of 1e-12 radians in latitude corresponds to roughly six micrometres on the ground, comfortably below any survey tolerance and still reached in three or four iterations for well-conditioned geometry. Pushing to the last representable bit costs iterations, buys nothing measurable and can prevent convergence altogether when the correction bottoms out in floating-point noise.
Should I return the number of iterations to the caller?
Yes. It costs nothing and it is the cheapest diagnostic you will ever have: a routine that normally converges in three iterations and suddenly takes nine is telling you something about the data it was given, and that signal is invisible if only the coordinate is returned. Log it with the result in the audit record.
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