AXLR / Catalog / geography

haversine

Distance and bearing between points on the Earth: the haversine great-circle distance on a sphere (R = 6371.0088 km by default) and Vincenty's inverse formula on the WGS-84 or GRS80 ellipsoid (the more exact, to about half a millimetre), with initial and final bearings, the midpoint, and the destination reached from a start point, a bearing and a distance (spherical and Vincenty direct). One pair or a batch of pairs; km, m, statute miles or nautical miles; coordinates as decimal degrees or DMS strings. Near-antipodal pairs where Vincenty does not converge are reported, not looped on.

Install

/tools install haversine
/tools install haversine@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · haversine 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool haversine; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
operationstringno"inverse"one of "inverse", "destination"
fromnoPoint: [lat, lon] or {lat, lon}; numbers, decimal strings or DMS strings such as "50 03 59N"
tonoPoint, as from (inverse)
pairsarraynoInverse batch: [lat1, lon1, lat2, lon2] or {from, to}. items ≥ 1; items ≤ 10000
bearingnumber | stringnoDestination: initial bearing in degrees, or DMS
distancenumber | stringnoDestination: distance in unit. ≥ 0
unitstringno"km"one of "km", "m", "mi", "nmi"
radius_kmnumber | stringno6371.0088Sphere radius for the haversine results
ellipsoidstringno"WGS84"one of "WGS84", "GRS80"
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "operation": {
      "type": "string",
      "enum": [
        "inverse",
        "destination"
      ],
      "default": "inverse"
    },
    "from": {
      "description": "Point: [lat, lon] or {lat, lon}; numbers, decimal strings or DMS strings such as \"50 03 59N\"",
      "oneOf": [
        {
          "type": "array",
          "minItems": 2,
          "maxItems": 2,
          "items": {
            "type": [
              "number",
              "string"
            ]
          }
        },
        {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "lat",
            "lon"
          ],
          "properties": {
            "lat": {
              "type": [
                "number",
                "string"
              ]
            },
            "lon": {
              "type": [
                "number",
                "string"
              ]
            }
          }
        }
      ]
    },
    "to": {
      "description": "Point, as from (inverse)",
      "oneOf": [
        {
          "type": "array",
          "minItems": 2,
          "maxItems": 2,
          "items": {
            "type": [
              "number",
              "string"
            ]
          }
        },
        {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "lat",
            "lon"
          ],
          "properties": {
            "lat": {
              "type": [
                "number",
                "string"
              ]
            },
            "lon": {
              "type": [
                "number",
                "string"
              ]
            }
          }
        }
      ]
    },
    "pairs": {
      "type": "array",
      "minItems": 1,
      "maxItems": 10000,
      "description": "Inverse batch: [lat1, lon1, lat2, lon2] or {from, to}",
      "items": {
        "oneOf": [
          {
            "type": "array",
            "minItems": 4,
            "maxItems": 4,
            "items": {
              "type": [
                "number",
                "string"
              ]
            }
          },
          {
            "type": "object",
            "required": [
              "from",
              "to"
            ],
            "additionalProperties": false,
            "properties": {
              "from": {},
              "to": {}
            }
          }
        ]
      }
    },
    "bearing": {
      "type": [
        "number",
        "string"
      ],
      "description": "Destination: initial bearing in degrees, or DMS"
    },
    "distance": {
      "type": [
        "number",
        "string"
      ],
      "minimum": 0,
      "description": "Destination: distance in unit"
    },
    "unit": {
      "type": "string",
      "enum": [
        "km",
        "m",
        "mi",
        "nmi"
      ],
      "default": "km"
    },
    "radius_km": {
      "type": [
        "number",
        "string"
      ],
      "default": 6371.0088,
      "description": "Sphere radius for the haversine results"
    },
    "ellipsoid": {
      "type": "string",
      "enum": [
        "WGS84",
        "GRS80"
      ],
      "default": "WGS84"
    }
  }
}

Example

Input

{
  "from": [
    "37 57 03.72030S",
    "144 25 29.52440E"
  ],
  "to": [
    "37 39 10.15610S",
    "143 55 35.38390E"
  ],
  "unit": "m"
}

Output (stdout)

{
  "vincenty": {
    "converged": true,
    "distance_m": 54972.2711387,
    "initial_bearing": {
      "dms": "306°52′05.37″"
    },
    "reverse_azimuth": {
      "dms": "127°10′25.07″"
    }
  },
  "more_exact": "vincenty"
}

Files

The command the install card registers is python3 haversine.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

haversine.py · sha256 0e0dcffc14ea4bdaf74ecddc7f5e3ea711be269dab352cff727e4c7c88d2b202
#!/usr/bin/env python3
"""Great-circle and ellipsoidal distance between points: the haversine formula on a sphere and
Vincenty's inverse formula on the WGS-84 (or GRS80) ellipsoid, with initial and final bearings,
the spherical midpoint, and the destination point reached from a start, a bearing and a
distance (spherical and Vincenty's direct formula). One pair or a batch of pairs.

Input (JSON on stdin):
  operation   "inverse" (default) | "destination"
  from, to    a point: [lat, lon] or {"lat": .., "lon": ..}; each value a number, a decimal
              string or a DMS string ("50 03 59N", "005°42′53″W", "-5.7147")
  pairs       inverse batch: [{"from": point, "to": point}, ...] or [[lat1, lon1, lat2, lon2], ...]
  bearing     destination: initial bearing in degrees (number or DMS string)
  distance    destination: distance in `unit`
  unit        "km" (default) | "m" | "mi" | "nmi"
  radius_km   sphere radius for the haversine results, default 6371.0088 (IUGG mean radius)
  ellipsoid   "WGS84" (default) | "GRS80"
"""
import json
import math
import re
import sys

UNITS = {"m": 1.0, "km": 1000.0, "mi": 1609.344, "nmi": 1852.0}
ELLIPSOIDS = {"WGS84": (6378137.0, 1 / 298.257223563), "GRS80": (6378137.0, 1 / 298.257222101)}
MEAN_RADIUS_KM = 6371.0088
MAX_ITERATIONS = 200
MAX_PAIRS = 10000


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def num(x):
    """A float for display, at 12 significant digits."""
    value = float(x)
    return float(f"{value:.12g}") if math.isfinite(value) else None


ANGLE = re.compile(r"^\s*([NSEW])?\s*([+-])?\s*(\d+(?:\.\d+)?)\s*(?:[°ºd:]\s*|\s+|(?=[NSEW])|$)"
                   r"(?:(\d+(?:\.\d+)?)\s*(?:[′':]\s*|\s+|(?=[NSEW])|$))?"
                   r"(?:(\d+(?:\.\d+)?)\s*(?:[″\"])?\s*)?([NSEW])?\s*$", re.IGNORECASE)


def parse_angle(value, field, axis):
    """Degrees from a number, a decimal string or a DMS/DDM string with an optional hemisphere."""
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or an angle string")
    if isinstance(value, (int, float)):
        degrees = float(value)
        if not math.isfinite(degrees):
            fail(f"{field} is not finite")
    else:
        text = value.strip().replace("''", '"')
        match = ANGLE.match(text)
        if not text or not match:
            fail(f"{field}: cannot read angle {value!r}")
        pre, sign, d, m, s, post = match.groups()
        if pre and post:
            fail(f"{field}: two hemisphere letters in {value!r}")
        hemisphere = (pre or post or "").upper()
        if hemisphere and sign:
            fail(f"{field}: both a sign and a hemisphere in {value!r}")
        if (m is not None and "." in d) or (s is not None and m is not None and "." in m):
            fail(f"{field}: only the last component may have decimals in {value!r}")
        minutes = float(m) if m is not None else 0.0
        seconds = float(s) if s is not None else 0.0
        if s is not None and m is None:
            fail(f"{field}: cannot read angle {value!r}")
        if minutes >= 60 or seconds >= 60:
            fail(f"{field}: minutes and seconds must be below 60 in {value!r}")
        degrees = float(d) + minutes / 60 + seconds / 3600
        if sign == "-" or hemisphere in ("S", "W"):
            degrees = -degrees
        if hemisphere and axis == "bearing":
            fail(f"{field}: a bearing takes no hemisphere letter")
        if hemisphere in ("N", "S") and axis == "lon" or hemisphere in ("E", "W") and axis == "lat":
            fail(f"{field}: hemisphere {hemisphere} does not belong to a {'longitude' if axis == 'lon' else 'latitude'}")
    if axis == "lat" and abs(degrees) > 90:
        fail(f"{field}: latitude must be between -90 and 90")
    if axis == "lon" and abs(degrees) > 180:
        fail(f"{field}: longitude must be between -180 and 180")
    return degrees


def parse_point(value, field):
    if isinstance(value, dict):
        extra = set(value) - {"lat", "lon"}
        if extra or "lat" not in value or "lon" not in value:
            fail(f"{field} must be {{\"lat\": .., \"lon\": ..}}")
        lat, lon = value["lat"], value["lon"]
    elif isinstance(value, list) and len(value) == 2:
        lat, lon = value
    else:
        fail(f"{field} must be [lat, lon] or {{\"lat\": .., \"lon\": ..}}")
    return parse_angle(lat, f"{field}.lat", "lat"), parse_angle(lon, f"{field}.lon", "lon")


def wrap180(lon):
    lon = math.fmod(lon + 540.0, 360.0) - 180.0
    return 180.0 if lon == -180.0 else lon


def dms(degrees, axis=None, places=2):
    """306°52′05.37″, or 37°39′10.15610″S with a hemisphere for a latitude or longitude."""
    hemisphere = ""
    if axis == "lat":
        hemisphere = "N" if degrees >= 0 else "S"
    elif axis == "lon":
        hemisphere = "E" if degrees >= 0 else "W"
    value = abs(degrees) if axis else degrees % 360.0
    scale = 10 ** places
    total = round(value * 3600 * scale)
    d, rest = divmod(total, 3600 * scale)
    m, s = divmod(rest, 60 * scale)
    if axis is None:
        d %= 360
    width = {"lat": 2, "lon": 3}.get(axis, 3)
    sec = f"{s // scale:02d}" + (f".{s % scale:0{places}d}" if places else "")
    return f"{d:0{width}d}°{m:02d}′{sec}″{hemisphere}"


def point_out(lat, lon):
    lat, lon = (0.0 if abs(lat) < 1e-12 else lat), (0.0 if abs(lon) < 1e-12 else lon)
    return {"lat": num(lat), "lon": num(lon), "dms": f"{dms(lat, 'lat')} {dms(lon, 'lon')}"}


def bearing_out(degrees):
    degrees %= 360.0
    return {"degrees": num(degrees), "dms": dms(degrees)}


def haversine(lat1, lon1, lat2, lon2, radius_m):
    p1, p2 = math.radians(lat1), math.radians(lat2)
    dp, dl = p2 - p1, math.radians(lon2 - lon1)
    h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    central = 2 * math.atan2(math.sqrt(h), math.sqrt(max(0.0, 1 - h)))
    return radius_m * central


def sphere_bearing(lat1, lon1, lat2, lon2):
    p1, p2, dl = math.radians(lat1), math.radians(lat2), math.radians(lon2 - lon1)
    y = math.sin(dl) * math.cos(p2)
    x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
    return math.degrees(math.atan2(y, x)) % 360.0


def midpoint(lat1, lon1, lat2, lon2):
    p1, p2, l1, dl = math.radians(lat1), math.radians(lat2), math.radians(lon1), math.radians(lon2 - lon1)
    bx, by = math.cos(p2) * math.cos(dl), math.cos(p2) * math.sin(dl)
    lat = math.atan2(math.sin(p1) + math.sin(p2), math.sqrt((math.cos(p1) + bx) ** 2 + by ** 2))
    lon = l1 + math.atan2(by, math.cos(p1) + bx)
    return math.degrees(lat), wrap180(math.degrees(lon))


def sphere_destination(lat1, lon1, bearing, distance_m, radius_m):
    p1, l1, theta, delta = math.radians(lat1), math.radians(lon1), math.radians(bearing), distance_m / radius_m
    p2 = math.asin(max(-1.0, min(1.0, math.sin(p1) * math.cos(delta) + math.cos(p1) * math.sin(delta) * math.cos(theta))))
    l2 = l1 + math.atan2(math.sin(theta) * math.sin(delta) * math.cos(p1), math.cos(delta) - math.sin(p1) * math.sin(p2))
    return math.degrees(p2), wrap180(math.degrees(l2))


def reduced(lat, f):
    tan_u = (1 - f) * math.tan(math.radians(lat))
    cos_u = 1 / math.sqrt(1 + tan_u * tan_u)
    return tan_u * cos_u, cos_u


def vincenty_inverse(lat1, lon1, lat2, lon2, a, f):
    """Vincenty (1975) inverse formula. Returns a dict; converged False near antipodal points."""
    b = a * (1 - f)
    big_l = math.radians(wrap180(lon2 - lon1))
    sin_u1, cos_u1 = reduced(lat1, f)
    sin_u2, cos_u2 = reduced(lat2, f)
    lam = big_l
    for iteration in range(1, MAX_ITERATIONS + 1):
        sin_l, cos_l = math.sin(lam), math.cos(lam)
        sin_sigma = math.hypot(cos_u2 * sin_l, cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_l)
        if sin_sigma == 0:
            return {"converged": True, "iterations": iteration, "distance_m": 0.0, "initial": None, "final": None}
        cos_sigma = sin_u1 * sin_u2 + cos_u1 * cos_u2 * cos_l
        sigma = math.atan2(sin_sigma, cos_sigma)
        sin_alpha = cos_u1 * cos_u2 * sin_l / sin_sigma
        cos2_alpha = 1 - sin_alpha * sin_alpha
        cos_2sm = cos_sigma - 2 * sin_u1 * sin_u2 / cos2_alpha if cos2_alpha != 0 else 0.0
        c = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
        previous = lam
        lam = big_l + (1 - c) * f * sin_alpha * (sigma + c * sin_sigma * (cos_2sm + c * cos_sigma * (-1 + 2 * cos_2sm ** 2)))
        if abs(lam) > math.pi:
            return {"converged": False, "iterations": iteration, "reason": "lambda exceeded pi (near-antipodal points)"}
        if abs(lam - previous) < 1e-12:
            break
    else:
        return {"converged": False, "iterations": MAX_ITERATIONS, "reason": f"no convergence in {MAX_ITERATIONS} iterations (near-antipodal points)"}
    u2 = cos2_alpha * (a * a - b * b) / (b * b)
    big_a = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
    big_b = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
    d_sigma = big_b * sin_sigma * (cos_2sm + big_b / 4 * (cos_sigma * (-1 + 2 * cos_2sm ** 2)
                                                         - big_b / 6 * cos_2sm * (-3 + 4 * sin_sigma ** 2) * (-3 + 4 * cos_2sm ** 2)))
    s = b * big_a * (sigma - d_sigma)
    alpha1 = math.degrees(math.atan2(cos_u2 * sin_l, cos_u1 * sin_u2 - sin_u1 * cos_u2 * cos_l))
    alpha2 = math.degrees(math.atan2(cos_u1 * sin_l, -sin_u1 * cos_u2 + cos_u1 * sin_u2 * cos_l))
    return {"converged": True, "iterations": iteration, "distance_m": s, "initial": alpha1 % 360, "final": alpha2 % 360}


def vincenty_direct(lat1, lon1, bearing, s, a, f):
    """Vincenty (1975) direct formula: the point s metres from (lat1, lon1) on initial bearing."""
    b = a * (1 - f)
    alpha1 = math.radians(bearing)
    sin_a1, cos_a1 = math.sin(alpha1), math.cos(alpha1)
    sin_u1, cos_u1 = reduced(lat1, f)
    sigma1 = math.atan2(sin_u1 / cos_u1, cos_a1)
    sin_alpha = cos_u1 * sin_a1
    cos2_alpha = 1 - sin_alpha * sin_alpha
    u2 = cos2_alpha * (a * a - b * b) / (b * b)
    big_a = 1 + u2 / 16384 * (4096 + u2 * (-768 + u2 * (320 - 175 * u2)))
    big_b = u2 / 1024 * (256 + u2 * (-128 + u2 * (74 - 47 * u2)))
    sigma = s / (b * big_a)
    for _ in range(MAX_ITERATIONS):
        cos_2sm = math.cos(2 * sigma1 + sigma)
        sin_sigma, cos_sigma = math.sin(sigma), math.cos(sigma)
        d_sigma = big_b * sin_sigma * (cos_2sm + big_b / 4 * (cos_sigma * (-1 + 2 * cos_2sm ** 2)
                                                             - big_b / 6 * cos_2sm * (-3 + 4 * sin_sigma ** 2) * (-3 + 4 * cos_2sm ** 2)))
        previous = sigma
        sigma = s / (b * big_a) + d_sigma
        if abs(sigma - previous) < 1e-12:
            break
    cos_2sm = math.cos(2 * sigma1 + sigma)
    sin_sigma, cos_sigma = math.sin(sigma), math.cos(sigma)
    x = sin_u1 * sin_sigma - cos_u1 * cos_sigma * cos_a1
    lat2 = math.atan2(sin_u1 * cos_sigma + cos_u1 * sin_sigma * cos_a1, (1 - f) * math.hypot(sin_alpha, x))
    lam = math.atan2(sin_sigma * sin_a1, cos_u1 * cos_sigma - sin_u1 * sin_sigma * cos_a1)
    c = f / 16 * cos2_alpha * (4 + f * (4 - 3 * cos2_alpha))
    big_l = lam - (1 - c) * f * sin_alpha * (sigma + c * sin_sigma * (cos_2sm + c * cos_sigma * (-1 + 2 * cos_2sm ** 2)))
    final = math.degrees(math.atan2(sin_alpha, -x)) % 360
    return math.degrees(lat2), wrap180(lon1 + math.degrees(big_l)), final


def inverse(lat1, lon1, lat2, lon2, unit, radius_m, a, f):
    factor = UNITS[unit]
    hav_m = haversine(lat1, lon1, lat2, lon2, radius_m)
    sphere = {"distance": num(hav_m / factor), "distance_m": num(hav_m)}
    same = lat1 == lat2 and wrap180(lon1 - lon2) == 0
    if not same:
        initial = sphere_bearing(lat1, lon1, lat2, lon2)
        final = (sphere_bearing(lat2, lon2, lat1, lon1) + 180.0) % 360.0
        sphere["initial_bearing"] = bearing_out(initial)
        sphere["final_bearing"] = bearing_out(final)
    sphere["midpoint"] = point_out(*midpoint(lat1, lon1, lat2, lon2))
    v = vincenty_inverse(lat1, lon1, lat2, lon2, a, f)
    ellipsoid = {"converged": v["converged"], "iterations": v["iterations"]}
    if v["converged"]:
        ellipsoid["distance"] = num(v["distance_m"] / factor)
        ellipsoid["distance_m"] = num(v["distance_m"])
        if v["initial"] is not None:
            ellipsoid["initial_bearing"] = bearing_out(v["initial"])
            ellipsoid["final_bearing"] = bearing_out(v["final"])
            ellipsoid["reverse_azimuth"] = bearing_out(v["final"] + 180.0)
        ellipsoid["difference_from_haversine_pct"] = num((hav_m - v["distance_m"]) / v["distance_m"] * 100) if v["distance_m"] else 0.0
    else:
        ellipsoid["reason"] = v["reason"]
    return {"from": point_out(lat1, lon1), "to": point_out(lat2, lon2), "haversine": sphere, "vincenty": ellipsoid,
            "more_exact": "vincenty" if v["converged"] else "haversine (Vincenty did not converge)"}


def number(value, field):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number")
    try:
        x = float(value)
    except ValueError:
        fail(f"{field} must be a number")
    if not math.isfinite(x):
        fail(f"{field} must be finite")
    return x


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    allowed = {"operation", "from", "to", "pairs", "bearing", "distance", "unit", "radius_km", "ellipsoid"}
    unknown = sorted(set(request) - allowed)
    if unknown:
        fail(f"unknown field(s): {', '.join(unknown)}")
    operation = request.get("operation", "inverse")
    unit = request.get("unit", "km")
    if unit not in UNITS:
        fail("unit must be one of km, m, mi, nmi")
    radius_km = number(request.get("radius_km", MEAN_RADIUS_KM), "radius_km")
    if radius_km <= 0:
        fail("radius_km must be positive")
    radius_m = radius_km * 1000
    name = request.get("ellipsoid", "WGS84")
    if name not in ELLIPSOIDS:
        fail("ellipsoid must be WGS84 or GRS80")
    a, f = ELLIPSOIDS[name]
    common = {"operation": operation, "unit": unit, "radius_km": num(radius_km), "ellipsoid": name}
    if operation == "inverse":
        if "pairs" in request:
            pairs = request["pairs"]
            if not isinstance(pairs, list) or not pairs or len(pairs) > MAX_PAIRS:
                fail(f"pairs must be a list of 1 to {MAX_PAIRS} pairs")
            results = []
            for i, pair in enumerate(pairs):
                if isinstance(pair, list) and len(pair) == 4:
                    p, q = parse_point(pair[:2], f"pairs[{i}].from"), parse_point(pair[2:], f"pairs[{i}].to")
                elif isinstance(pair, dict) and set(pair) == {"from", "to"}:
                    p, q = parse_point(pair["from"], f"pairs[{i}].from"), parse_point(pair["to"], f"pairs[{i}].to")
                else:
                    fail(f"pairs[{i}] must be {{\"from\": point, \"to\": point}} or [lat1, lon1, lat2, lon2]")
                results.append(inverse(*p, *q, unit, radius_m, a, f))
            print(json.dumps({**common, "count": len(results), "results": results}, ensure_ascii=False))
            return
        if "from" not in request or "to" not in request:
            fail("inverse needs from and to, or pairs")
        p, q = parse_point(request["from"], "from"), parse_point(request["to"], "to")
        print(json.dumps({**common, **inverse(*p, *q, unit, radius_m, a, f)}, ensure_ascii=False))
    elif operation == "destination":
        if "from" not in request or "bearing" not in request or "distance" not in request:
            fail("destination needs from, bearing and distance")
        lat1, lon1 = parse_point(request["from"], "from")
        bearing = request["bearing"]
        if isinstance(bearing, str):
            bearing = parse_angle(bearing, "bearing", "bearing")
        else:
            bearing = number(bearing, "bearing")
        distance = number(request["distance"], "distance")
        if distance < 0:
            fail("distance must not be negative")
        distance_m = distance * UNITS[unit]
        slat, slon = sphere_destination(lat1, lon1, bearing, distance_m, radius_m)
        sphere_final = (sphere_bearing(slat, slon, lat1, lon1) + 180.0) % 360.0 if distance_m else bearing % 360
        elat, elon, efinal = vincenty_direct(lat1, lon1, bearing, distance_m, a, f)
        print(json.dumps({**common, "from": point_out(lat1, lon1), "bearing": bearing_out(bearing), "distance": num(distance),
                          "distance_m": num(distance_m),
                          "haversine": {"destination": point_out(slat, slon), "final_bearing": bearing_out(sphere_final)},
                          "vincenty": {"destination": point_out(elat, elon), "final_bearing": bearing_out(efinal),
                                       "reverse_azimuth": bearing_out(efinal + 180.0)},
                          "more_exact": "vincenty"}, ensure_ascii=False))
    else:
        fail("operation must be inverse or destination")


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/haversine/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/haversine/1.0.0/haversine.py