AXLR / Catalog / geography

coord_convert

Convert geographic coordinates on WGS-84 between notations: parse and format decimal degrees, degrees-minutes-seconds and degrees-decimal-minutes (N/S/E/W or signs, symbols ° ′ ″ ' " d :), UTM both ways with zone and latitude band including the Norway (32V) and Svalbard (31X-37X) exceptions by Krueger's transverse Mercator series to sixth order as in Karney (2011) and GeographicLib, MGRS both ways (truncated, square centre on input, as GeoConvert), and geohash encode and decode with error bounds. Inputs are validated: ranges, minutes and seconds below 60, band against hemisphere, MGRS and geohash alphabets.

Install

/tools install coord_convert
/tools install coord_convert@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 · coord_convert 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 coord_convert; each run is approved as a local_exec.

Arguments

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

nametyperequireddefaultmeaning
operationstringno"all"one of "all", "format", "to_utm", "from_utm", "to_mgrs", "from_mgrs", "geohash_encode", "geohash_decode"
latnumber | stringnoDecimal degrees or a DMS/DDM string such as "40°26′46″N"
lonnumber | stringnoDecimal degrees or a DMS/DDM string such as "079 58 56W"
positionstringnoBoth coordinates in one string, instead of lat and lon: "E44d24 N33d20", "33.3, 44.4"; without letters the order is lat, lon. length ≤ 200
zoneintegernofrom_utm; to_utm to force a zone. ≥ 1; ≤ 60
hemispherestringnofrom_utm (or give band). one of "N", "S", "n", "s"
bandstringnofrom_utm latitude band, checked against the result. pattern ^[C-HJ-NP-Xc-hj-np-x]$
eastingnumber | stringnofrom_utm, metres
northingnumber | stringnofrom_utm, metres
mgrsstringnofrom_mgrs, e.g. "38SMB4488". length ≤ 40
mgrs_digitsintegerno5Digits per coordinate: 5 = 1 m, 2 = 1 km. ≥ 0; ≤ 5
cornerstringno"center"from_mgrs: point of the square returned. one of "center", "southwest"
geohashstringnogeohash_decode. length ≤ 22
geohash_lengthintegerno9≥ 1; ≤ 12
seconds_decimalsintegerno2DMS seconds decimals; DDM minutes get two more. ≥ 0; ≤ 6
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "operation": {
      "type": "string",
      "enum": [
        "all",
        "format",
        "to_utm",
        "from_utm",
        "to_mgrs",
        "from_mgrs",
        "geohash_encode",
        "geohash_decode"
      ],
      "default": "all"
    },
    "lat": {
      "type": [
        "number",
        "string"
      ],
      "description": "Decimal degrees or a DMS/DDM string such as \"40°26′46″N\""
    },
    "lon": {
      "type": [
        "number",
        "string"
      ],
      "description": "Decimal degrees or a DMS/DDM string such as \"079 58 56W\""
    },
    "position": {
      "type": "string",
      "maxLength": 200,
      "description": "Both coordinates in one string, instead of lat and lon: \"E44d24 N33d20\", \"33.3, 44.4\"; without letters the order is lat, lon"
    },
    "zone": {
      "type": "integer",
      "minimum": 1,
      "maximum": 60,
      "description": "from_utm; to_utm to force a zone"
    },
    "hemisphere": {
      "type": "string",
      "enum": [
        "N",
        "S",
        "n",
        "s"
      ],
      "description": "from_utm (or give band)"
    },
    "band": {
      "type": "string",
      "pattern": "^[C-HJ-NP-Xc-hj-np-x]$",
      "description": "from_utm latitude band, checked against the result"
    },
    "easting": {
      "type": [
        "number",
        "string"
      ],
      "description": "from_utm, metres"
    },
    "northing": {
      "type": [
        "number",
        "string"
      ],
      "description": "from_utm, metres"
    },
    "mgrs": {
      "type": "string",
      "maxLength": 40,
      "description": "from_mgrs, e.g. \"38SMB4488\""
    },
    "mgrs_digits": {
      "type": "integer",
      "minimum": 0,
      "maximum": 5,
      "default": 5,
      "description": "Digits per coordinate: 5 = 1 m, 2 = 1 km"
    },
    "corner": {
      "type": "string",
      "enum": [
        "center",
        "southwest"
      ],
      "default": "center",
      "description": "from_mgrs: point of the square returned"
    },
    "geohash": {
      "type": "string",
      "maxLength": 22,
      "description": "geohash_decode"
    },
    "geohash_length": {
      "type": "integer",
      "minimum": 1,
      "maximum": 12,
      "default": 9
    },
    "seconds_decimals": {
      "type": "integer",
      "minimum": 0,
      "maximum": 6,
      "default": 2,
      "description": "DMS seconds decimals; DDM minutes get two more"
    }
  }
}

Example

Input

{
  "operation": "from_mgrs",
  "mgrs": "38SMB4488"
}

Output (stdout)

{
  "lat": 33.3342350846,
  "lon": 44.4036278478,
  "dms": "33°20′03.25″N 044°24′13.06″E",
  "utm": {
    "zone": 38,
    "hemisphere": "N",
    "band": "S",
    "easting": 444500.0,
    "northing": 3688500.0
  }
}

Files

The command the install card registers is python3 coord_convert.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.

coord_convert.py · sha256 8c7fdad711fea5682fd4cc87332856270f01e7043b392a67188257170085e3e7
#!/usr/bin/env python3
"""Convert geographic coordinates between notations: decimal degrees, degrees-minutes-seconds
and degrees-decimal-minutes (parsing and formatting), UTM on WGS-84 (transverse Mercator by
Krueger's series to sixth order in n, as in Karney 2011 and GeographicLib) with zone and
latitude band including the Norway and Svalbard exceptions, MGRS, and geohash.

Input (JSON on stdin):
  operation   "all" (default) | "format" | "to_utm" | "from_utm" | "to_mgrs" | "from_mgrs" |
              "geohash_encode" | "geohash_decode"
  lat, lon    numbers, decimal strings or DMS/DDM strings ("40°26′46″N", "079 58 56W", "-5.6")
  position    instead of lat and lon, one string holding both ("40 26 46N 79 58 56W",
              "E44d24 N33d20", "33.3, 44.4"); without hemisphere letters the order is lat, lon
  zone        1..60: from_utm; to_utm to force a zone other than the standard one
  hemisphere  "N" | "S" (from_utm; or give band)
  band        latitude band letter C..X (from_utm; checked against the result)
  easting, northing   metres (from_utm)
  mgrs        MGRS string (from_mgrs), e.g. "38SMB4488"
  mgrs_digits digits per coordinate 0..5 for to_mgrs/all, default 5 (1 m)
  corner      from_mgrs: "center" (default, as GeoConvert) or "southwest" of the square
  geohash     geohash string (geohash_decode)
  geohash_length  1..12 for geohash_encode/all, default 9
  seconds_decimals  0..6, DMS seconds decimals, default 2; DDM minutes get two more
"""
import json
import math
import re
import sys

A_WGS84 = 6378137.0
F_WGS84 = 1 / 298.257223563
K0 = 0.9996
FALSE_EASTING = 500000.0
FALSE_NORTHING_SOUTH = 10000000.0
BANDS = "CDEFGHJKLMNPQRSTUVWX"
GEOHASH = "0123456789bcdefghjkmnpqrstuvwxyz"
MGRS_COLUMNS = ("STUVWXYZ", "ABCDEFGH", "JKLMNPQR")   # zone % 3 == 0, 1, 2
MGRS_ROWS = "ABCDEFGHJKLMNPQRSTUV"
OPERATIONS = ("all", "format", "to_utm", "from_utm", "to_mgrs", "from_mgrs", "geohash_encode", "geohash_decode")


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


def num(x, digits=12):
    value = float(x)
    if value == 0:
        return 0.0
    return float(f"{value:.{digits}g}") if math.isfinite(value) else None


# ---------------------------------------------------------------- angles: parsing and formatting

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 read_angle(text):
    """(degrees, hemisphere letter or '') from a DMS/DDM/decimal string; ValueError if unreadable."""
    text = text.strip().replace("''", '"').replace("’", "'").replace("”", '"')
    match = ANGLE.match(text)
    if not text or not match:
        raise ValueError(f"cannot read angle {text!r}")
    pre, sign, d, m, s, post = match.groups()
    if pre and post:
        raise ValueError(f"two hemisphere letters in {text!r}")
    hemisphere = (pre or post or "").upper()
    if hemisphere and sign:
        raise ValueError(f"both a sign and a hemisphere in {text!r}")
    if s is not None and m is None:
        raise ValueError(f"cannot read angle {text!r}")
    if (m is not None and "." in d) or (s is not None and "." in m):
        raise ValueError(f"only the last component may have decimals in {text!r}")
    minutes = float(m) if m is not None else 0.0
    seconds = float(s) if s is not None else 0.0
    if minutes >= 60 or seconds >= 60:
        raise ValueError(f"minutes and seconds must be below 60 in {text!r}")
    degrees = float(d) + minutes / 60 + seconds / 3600
    if sign == "-" or hemisphere in ("S", "W"):
        degrees = -degrees
    return degrees, hemisphere


def check_range(degrees, field, axis):
    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_angle(value, field, axis):
    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)):
        if not math.isfinite(value):
            fail(f"{field} is not finite")
        return check_range(float(value), field, axis)
    try:
        degrees, hemisphere = read_angle(value)
    except ValueError as error:
        fail(f"{field}: {error}")
    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'}")
    return check_range(degrees, field, axis)


def parse_position(text):
    """Split one string into a latitude and a longitude, by a comma or the one whitespace split
    at which both halves read as angles; hemisphere letters decide which is which."""
    if not isinstance(text, str) or not text.strip():
        fail("position must be a non-empty string")
    if "," in text:
        parts = text.split(",")
        if len(parts) != 2:
            fail("position: use one comma between the two coordinates")
        candidates = [(parts[0], parts[1])]
    else:
        tokens = text.split()
        candidates = [(" ".join(tokens[:i]), " ".join(tokens[i:])) for i in range(1, len(tokens))]
    readings = []
    for left, right in candidates:
        try:
            readings.append((read_angle(left), read_angle(right)))
        except ValueError:
            continue
    if not readings:
        fail(f"position: cannot read two coordinates in {text!r}")
    if len(readings) > 1:
        fail(f"position: {text!r} is ambiguous; separate the coordinates with a comma or use hemisphere letters")
    (a, ha), (b, hb) = readings[0]
    axes = {"N": "lat", "S": "lat", "E": "lon", "W": "lon", "": None}
    xa, xb = axes[ha], axes[hb]
    if xa == "lon" or xb == "lat":
        a, b, xa, xb = b, a, xb, xa
    if (xa or "lat") != "lat" or (xb or "lon") != "lon":
        fail(f"position: {text!r} needs one latitude and one longitude")
    return check_range(a, "position latitude", "lat"), check_range(b, "position longitude", "lon")


def sexagesimal(value, places, with_seconds):
    """Integer degrees, minutes and (seconds or decimal minutes) of |value|, rounded with carry."""
    scale = 10 ** places
    unit = 3600 if with_seconds else 60
    total = round(abs(value) * unit * scale)
    d, rest = divmod(total, unit * scale)
    if with_seconds:
        m, s = divmod(rest, 60 * scale)
        return d, m, s, scale
    return d, rest, None, scale


def fmt_dms(value, axis, places):
    d, m, s, scale = sexagesimal(value, places, True)
    hemisphere = ("N" if value >= 0 else "S") if axis == "lat" else ("E" if value >= 0 else "W")
    sec = f"{s // scale:02d}" + (f".{s % scale:0{places}d}" if places else "")
    return f"{d:0{2 if axis == 'lat' else 3}d}°{m:02d}′{sec}″{hemisphere}"


def fmt_ddm(value, axis, places):
    d, m, _, scale = sexagesimal(value, places, False)
    hemisphere = ("N" if value >= 0 else "S") if axis == "lat" else ("E" if value >= 0 else "W")
    minutes = f"{m // scale:02d}" + (f".{m % scale:0{places}d}" if places else "")
    return f"{d:0{2 if axis == 'lat' else 3}d}°{minutes}′{hemisphere}"


def formats(lat, lon, places):
    return {"lat": num(lat), "lon": num(lon),
            "decimal": f"{abs(lat):.{places + 4}f}°{'N' if lat >= 0 else 'S'} {abs(lon):.{places + 4}f}°{'E' if lon >= 0 else 'W'}",
            "dms": f"{fmt_dms(lat, 'lat', places)} {fmt_dms(lon, 'lon', places)}",
            "ddm": f"{fmt_ddm(lat, 'lat', places + 2)} {fmt_ddm(lon, 'lon', places + 2)}"}


# ---------------------------------------------------------------- transverse Mercator (Krueger series)

N3 = F_WGS84 / (2 - F_WGS84)
E2 = F_WGS84 * (2 - F_WGS84)
E = math.sqrt(E2)


def series(n):
    alpha = [0.0,
             n * (1 / 2 + n * (-2 / 3 + n * (5 / 16 + n * (41 / 180 + n * (-127 / 288 + n * 7891 / 37800))))),
             n ** 2 * (13 / 48 + n * (-3 / 5 + n * (557 / 1440 + n * (281 / 630 + n * -1983433 / 1935360)))),
             n ** 3 * (61 / 240 + n * (-103 / 140 + n * (15061 / 26880 + n * 167603 / 181440))),
             n ** 4 * (49561 / 161280 + n * (-179 / 168 + n * 6601661 / 7257600)),
             n ** 5 * (34729 / 80640 + n * -3418889 / 1995840),
             n ** 6 * (212378941 / 319334400)]
    beta = [0.0,
            n * (1 / 2 + n * (-2 / 3 + n * (37 / 96 + n * (-1 / 360 + n * (-81 / 512 + n * 96199 / 604800))))),
            n ** 2 * (1 / 48 + n * (1 / 15 + n * (-437 / 1440 + n * (46 / 105 + n * -1118711 / 3870720)))),
            n ** 3 * (17 / 480 + n * (-37 / 840 + n * (-209 / 4480 + n * 5569 / 90720))),
            n ** 4 * (4397 / 161280 + n * (-11 / 504 + n * -830251 / 7257600)),
            n ** 5 * (4583 / 161280 + n * -108847 / 3991680),
            n ** 6 * (20648693 / 638668800)]
    rectifying = A_WGS84 / (1 + n) * (1 + n ** 2 / 4 + n ** 4 / 64 + n ** 6 / 256)
    return alpha, beta, rectifying


ALPHA, BETA, RECTIFYING_RADIUS = series(N3)


def tm_forward(lat, dlon):
    """Transverse Mercator x (east) and y (north) in metres, unscaled origin at the equator on
    the central meridian, for latitude lat and longitude difference dlon (degrees)."""
    phi, lam = math.radians(lat), math.radians(dlon)
    if abs(lat) == 90:
        return 0.0, math.copysign(RECTIFYING_RADIUS * math.pi / 2, lat)
    tau = math.tan(phi)
    sigma = math.sinh(E * math.atanh(E * tau / math.sqrt(1 + tau * tau)))
    tau_c = tau * math.sqrt(1 + sigma * sigma) - sigma * math.sqrt(1 + tau * tau)
    xi_c = math.atan2(tau_c, math.cos(lam))
    eta_c = math.asinh(math.sin(lam) / math.sqrt(tau_c * tau_c + math.cos(lam) ** 2))
    xi, eta = xi_c, eta_c
    for j in range(1, 7):
        xi += ALPHA[j] * math.sin(2 * j * xi_c) * math.cosh(2 * j * eta_c)
        eta += ALPHA[j] * math.cos(2 * j * xi_c) * math.sinh(2 * j * eta_c)
    return RECTIFYING_RADIUS * eta, RECTIFYING_RADIUS * xi


def tm_inverse(x, y):
    """Latitude and longitude difference (degrees) of unscaled transverse Mercator x, y."""
    xi, eta = y / RECTIFYING_RADIUS, x / RECTIFYING_RADIUS
    xi_c, eta_c = xi, eta
    for j in range(1, 7):
        xi_c -= BETA[j] * math.sin(2 * j * xi) * math.cosh(2 * j * eta)
        eta_c -= BETA[j] * math.cos(2 * j * xi) * math.sinh(2 * j * eta)
    sinh_eta, cos_xi = math.sinh(eta_c), math.cos(xi_c)
    r = math.hypot(sinh_eta, cos_xi)
    if r == 0:
        return math.copysign(90.0, xi_c), 0.0
    tau_c = math.sin(xi_c) / r
    lam = math.atan2(sinh_eta, cos_xi)
    tau = tau_c   # Newton's method on tau'(tau) = tau_c, as in Karney 2011
    for _ in range(10):
        sigma = math.sinh(E * math.atanh(E * tau / math.sqrt(1 + tau * tau)))
        tau_i = tau * math.sqrt(1 + sigma * sigma) - sigma * math.sqrt(1 + tau * tau)
        step = (tau_c - tau_i) / math.sqrt(1 + tau_i * tau_i) * (1 + (1 - E2) * tau * tau) / ((1 - E2) * math.sqrt(1 + tau * tau))
        tau += step
        if abs(step) < 1e-14 * max(1.0, abs(tau)):
            break
    return math.degrees(math.atan(tau)), math.degrees(lam)


# ---------------------------------------------------------------- UTM and MGRS

def standard_zone(lat, lon):
    if lon == 180:
        lon = -180.0
    zone = int((lon + 180) // 6) + 1
    if 56 <= lat < 64 and 3 <= lon < 12:
        zone = 32
    if 72 <= lat <= 84 and 0 <= lon < 42:
        zone = 31 if lon < 9 else 33 if lon < 21 else 35 if lon < 33 else 37
    return zone


def band_of(lat):
    if lat < -80 or lat > 84:
        return None
    return BANDS[min(19, int((lat + 80) // 8))]


def central_meridian(zone):
    return zone * 6 - 183


def to_utm(lat, lon, zone=None):
    if lat < -80 or lat > 84:
        fail("UTM covers latitudes -80 to 84; the polar regions use UPS, which this tool does not do")
    standard = standard_zone(lat, lon)
    if zone is None:
        zone = standard
    dlon = (lon - central_meridian(zone) + 540) % 360 - 180
    if abs(dlon) > 9:
        fail(f"longitude {lon} is {abs(dlon):.1f}° from the central meridian of zone {zone}; at most 9° allowed")
    x, y = tm_forward(lat, dlon)
    easting = FALSE_EASTING + K0 * x
    northing = K0 * y + (FALSE_NORTHING_SOUTH if lat < 0 else 0.0)
    return {"zone": zone, "hemisphere": "N" if lat >= 0 else "S", "band": band_of(lat),
            "easting": easting, "northing": northing, "standard_zone": standard == zone,
            "central_meridian": central_meridian(zone)}


def from_utm(zone, hemisphere, easting, northing):
    x = (easting - FALSE_EASTING) / K0
    y = (northing - (FALSE_NORTHING_SOUTH if hemisphere == "S" else 0.0)) / K0
    lat, dlon = tm_inverse(x, y)
    lon = (central_meridian(zone) + dlon + 540) % 360 - 180
    return lat, lon


def utm_out(u):
    return {"zone": u["zone"], "hemisphere": u["hemisphere"], "band": u["band"], "easting": round(u["easting"], 3),
            "northing": round(u["northing"], 3), "central_meridian": u["central_meridian"], "standard_zone": u["standard_zone"],
            "text": f"{u['zone']}{u['hemisphere'].lower()} {u['easting']:.0f} {u['northing']:.0f}"}


def to_mgrs(u, digits):
    zone, easting, northing = u["zone"], u["easting"], u["northing"]
    column = int(easting // 100000)
    if not 1 <= column <= 8:
        fail("easting outside the MGRS 100 km columns (100000 to 899999 m)")
    letters = MGRS_COLUMNS[zone % 3][column - 1]
    row = int(northing // 100000) % 20
    if zone % 2 == 0:
        row = (row + 5) % 20
    letters += MGRS_ROWS[row]
    unit = 10 ** (5 - digits)
    e = int((easting % 100000) // unit)   # truncated, not rounded, as MGRS and GeoConvert do
    n = int((northing % 100000) // unit)
    text = f"{zone:02d}{u['band']}{letters}" + (f"{e:0{digits}d}{n:0{digits}d}" if digits else "")
    return {"mgrs": text, "digits": digits, "square_m": unit}


MGRS_PATTERN = re.compile(r"^\s*(\d{1,2})\s*([C-HJ-NP-X])\s*([A-HJ-NP-Z])([A-HJ-NP-V])\s*(\d*)\s*(\d*)\s*$", re.IGNORECASE)


def from_mgrs(text, corner):
    if not isinstance(text, str):
        fail("mgrs must be a string")
    match = MGRS_PATTERN.match(text)
    if not match:
        fail(f"mgrs: cannot read {text!r} (zone, band, two 100 km letters, an even number of digits; UPS not supported)")
    zone, band, col, row, d1, d2 = match.groups()
    zone, band, col, row = int(zone), band.upper(), col.upper(), row.upper()
    digits_text = d1 + d2
    if not 1 <= zone <= 60:
        fail("mgrs: zone must be 1 to 60")
    if len(digits_text) % 2 or len(digits_text) > 10:
        fail("mgrs: the numeric part must have an even number of digits, at most 10")
    digits = len(digits_text) // 2
    columns = MGRS_COLUMNS[zone % 3]
    if col not in columns:
        fail(f"mgrs: column letter {col} does not occur in zone {zone} (letters {columns})")
    e100k = (columns.index(col) + 1) * 100000
    r = MGRS_ROWS.index(row)
    if zone % 2 == 0:
        r = (r - 5) % 20
    unit = 10 ** (5 - digits)
    offset = unit / 2 if corner == "center" else 0.0
    easting = e100k + (int(digits_text[:digits]) * unit if digits else 0) + offset
    within = (int(digits_text[digits:]) * unit if digits else 0) + offset
    band_south = -80 + 8 * BANDS.index(band)
    hemisphere = "N" if band >= "N" else "S"
    minimum = K0 * tm_forward(band_south, 0)[1] + (FALSE_NORTHING_SOUTH if hemisphere == "S" else 0.0) - 100000
    northing = r * 100000 + within
    while northing < minimum:
        northing += 2000000
    lat, lon = from_utm(zone, hemisphere, easting, northing)
    warnings = []
    if band_of(lat) != band:
        warnings.append(f"the point falls in band {band_of(lat)}, not {band}")
    return zone, hemisphere, band, easting, northing, digits, lat, lon, warnings


# ---------------------------------------------------------------- geohash

def geohash_encode(lat, lon, length):
    lat_range, lon_range = [-90.0, 90.0], [-180.0, 180.0]
    out, bits, value, even = [], 0, 0, True
    while len(out) < length:
        rng, coordinate = (lon_range, lon) if even else (lat_range, lat)
        mid = (rng[0] + rng[1]) / 2
        if coordinate >= mid:
            value = value * 2 + 1
            rng[0] = mid
        else:
            value *= 2
            rng[1] = mid
        even = not even
        bits += 1
        if bits == 5:
            out.append(GEOHASH[value])
            bits = value = 0
    return "".join(out)


def geohash_decode(text):
    if not isinstance(text, str) or not 1 <= len(text) <= 22:
        fail("geohash must be a string of 1 to 22 characters")
    lat_range, lon_range = [-90.0, 90.0], [-180.0, 180.0]
    even = True
    for ch in text.lower():
        if ch not in GEOHASH:
            fail(f"geohash: {ch!r} is not a geohash character (0-9 and b-z without a, i, l, o)")
        value = GEOHASH.index(ch)
        for shift in range(4, -1, -1):
            rng = lon_range if even else lat_range
            mid = (rng[0] + rng[1]) / 2
            if value >> shift & 1:
                rng[0] = mid
            else:
                rng[1] = mid
            even = not even
    return lat_range, lon_range


# ---------------------------------------------------------------- main

def integer(request, field, low, high, default=None):
    value = request.get(field, default)
    if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
        fail(f"{field} must be an integer from {low} to {high}")
    return value


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


def latlon(request):
    if "position" in request:
        if "lat" in request or "lon" in request:
            fail("give position or lat and lon, not both")
        return parse_position(request["position"])
    if "lat" not in request or "lon" not in request:
        fail("this operation needs lat and lon (or position)")
    return parse_angle(request["lat"], "lat", "lat"), parse_angle(request["lon"], "lon", "lon")


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", "lat", "lon", "position", "zone", "hemisphere", "band", "easting", "northing", "mgrs",
               "mgrs_digits", "corner", "geohash", "geohash_length", "seconds_decimals"}
    unknown = sorted(set(request) - allowed)
    if unknown:
        fail(f"unknown field(s): {', '.join(unknown)}")
    operation = request.get("operation", "all")
    if operation not in OPERATIONS:
        fail(f"operation must be one of {', '.join(OPERATIONS)}")
    places = integer(request, "seconds_decimals", 0, 6, 2)
    digits = integer(request, "mgrs_digits", 0, 5, 5)
    length = integer(request, "geohash_length", 1, 12, 9)
    result = {"operation": operation, "datum": "WGS84"}

    if operation in ("all", "format", "to_utm", "to_mgrs", "geohash_encode"):
        lat, lon = latlon(request)
        result.update(formats(lat, lon, places))
        zone = integer(request, "zone", 1, 60) if "zone" in request else None
        if operation in ("all", "to_utm", "to_mgrs"):
            if operation == "all" and not -80 <= lat <= 84:
                result["utm"] = None
                result["mgrs"] = None
                result["note"] = "UTM and MGRS cover latitudes -80 to 84 only (UPS not supported)"
            else:
                u = to_utm(lat, lon, zone)
                if operation != "to_mgrs":
                    result["utm"] = utm_out(u)
                if operation != "to_utm":
                    result["mgrs"] = to_mgrs(u, digits)
        if operation in ("all", "geohash_encode"):
            gh = geohash_encode(lat, lon, length)
            lat_range, lon_range = geohash_decode(gh)
            result["geohash"] = {"geohash": gh, "length": length,
                                 "lat_error": num((lat_range[1] - lat_range[0]) / 2), "lon_error": num((lon_range[1] - lon_range[0]) / 2)}
    elif operation == "from_utm":
        zone = integer(request, "zone", 1, 60)
        band = request.get("band")
        hemisphere = request.get("hemisphere")
        if band is not None:
            if not isinstance(band, str) or band.upper() not in BANDS or len(band) != 1:
                fail("band must be one letter C..X without I and O")
            band = band.upper()
            implied = "N" if band >= "N" else "S"
            if hemisphere is not None and str(hemisphere).upper() != implied:
                fail(f"band {band} is in the {'northern' if implied == 'N' else 'southern'} hemisphere")
            hemisphere = implied
        if not isinstance(hemisphere, str) or hemisphere.upper() not in ("N", "S"):
            fail("from_utm needs hemisphere N or S (or a band letter)")
        hemisphere = hemisphere.upper()
        easting, northing = metres(request, "easting"), metres(request, "northing")
        if not 0 < easting < 1000000 or not 0 <= northing <= 10000000:
            fail("easting must be between 0 and 1000000 m and northing between 0 and 10000000 m")
        lat, lon = from_utm(zone, hemisphere, easting, northing)
        result.update(formats(lat, lon, places))
        result["utm"] = {"zone": zone, "hemisphere": hemisphere, "easting": easting, "northing": northing,
                         "band": band_of(lat), "standard_zone": standard_zone(lat, lon) == zone}
        warnings = []
        if not 100000 <= easting <= 900000:
            warnings.append("easting is outside 100000..900000 m, beyond the usual extent of a zone")
        if band is not None and band_of(lat) != band:
            warnings.append(f"the point falls in band {band_of(lat)}, not {band}")
        if warnings:
            result["warnings"] = warnings
    elif operation == "from_mgrs":
        if "mgrs" not in request:
            fail("from_mgrs needs mgrs")
        corner = request.get("corner", "center")
        if corner not in ("center", "southwest"):
            fail("corner must be center or southwest")
        zone, hemisphere, band, easting, northing, mdigits, lat, lon, warnings = from_mgrs(request["mgrs"], corner)
        result.update(formats(lat, lon, places))
        result["utm"] = {"zone": zone, "hemisphere": hemisphere, "band": band, "easting": easting, "northing": northing,
                         "text": f"{zone}{hemisphere.lower()} {easting:.0f} {northing:.0f}"}
        result["mgrs"] = {"digits": mdigits, "square_m": 10 ** (5 - mdigits), "point": corner}
        if warnings:
            result["warnings"] = warnings
    else:
        if "geohash" not in request:
            fail("geohash_decode needs geohash")
        lat_range, lon_range = geohash_decode(request["geohash"])
        lat, lon = (lat_range[0] + lat_range[1]) / 2, (lon_range[0] + lon_range[1]) / 2
        result.update(formats(lat, lon, places))
        result["geohash"] = {"geohash": request["geohash"].lower(), "lat_error": num((lat_range[1] - lat_range[0]) / 2),
                             "lon_error": num((lon_range[1] - lon_range[0]) / 2),
                             "bbox": [num(lon_range[0]), num(lat_range[0]), num(lon_range[1]), num(lat_range[1])]}
    print(json.dumps(result, ensure_ascii=False))


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/coord_convert/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/coord_convert/1.0.0/coord_convert.py