Algorithm Search⌘K

Geohash in Python

Encodes latitude and longitude as an interleaved base-32 string so that nearby points share prefixes.

File
geohash.py, 77 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
geohash.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/geohash/. The authors are listed in prov/CF8HNQ.jsonl.

BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
_INDEX = {c: i for i, c in enumerate(BASE32)}


def geohash_encode(lat: float, lon: float, p: int) -> str:
    """Return the geohash of (lat, lon) with p base-32 characters.

    Precondition: -90 <= lat <= 90, -180 <= lon <= 180, p >= 0.
    The bits alternate between longitude (first) and latitude. A value equal
    to the midpoint of its interval goes to the upper half. Cost: Theta(p).
    All midpoints are dyadic fractions of 180 and 360, so they are exact in
    binary floating point for p up to 18.
    """
    if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0) or p < 0:
        raise ValueError("latitude, longitude or precision out of range")
    lat_lo, lat_hi = -90.0, 90.0  # @line L1
    lon_lo, lon_hi = -180.0, 180.0
    s: list[str] = []  # @line L2
    bits, ch, even = 0, 0, True
    while len(s) < p:  # @line L3
        if even:  # @line L4
            mid = (lon_lo + lon_hi) / 2
            if lon >= mid:
                ch, lon_lo = 2 * ch + 1, mid
            else:
                ch, lon_hi = 2 * ch, mid
        else:
            mid = (lat_lo + lat_hi) / 2  # @line L5
            if lat >= mid:
                ch, lat_lo = 2 * ch + 1, mid
            else:
                ch, lat_hi = 2 * ch, mid
        even = not even  # @line L6
        bits += 1
        if bits == 5:  # @line L7
            s.append(BASE32[ch])
            bits, ch = 0, 0
    return "".join(s)  # @line L8


def geohash_decode(h: str) -> tuple[float, float, float, float]:
    """Return the cell (lat_lo, lat_hi, lon_lo, lon_hi) of the geohash h.

    Raises ValueError on a character outside the alphabet. Cost: Theta(|h|).
    """
    lat_lo, lat_hi = -90.0, 90.0
    lon_lo, lon_hi = -180.0, 180.0
    even = True
    for c in h.lower():
        if c not in _INDEX:
            raise ValueError(f"not a geohash character: {c!r}")
        v = _INDEX[c]
        for k in range(4, -1, -1):
            bit = (v >> k) & 1
            if even:
                mid = (lon_lo + lon_hi) / 2
                if bit:
                    lon_lo = mid
                else:
                    lon_hi = mid
            else:
                mid = (lat_lo + lat_hi) / 2
                if bit:
                    lat_lo = mid
                else:
                    lat_hi = mid
            even = not even
    return lat_lo, lat_hi, lon_lo, lon_hi


def geohash_center(h: str) -> tuple[float, float]:
    """Return the center (lat, lon) of the cell of h."""
    a, b, c, d = geohash_decode(h)
    return (a + b) / 2, (c + d) / 2
test_geohash.py 82 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/geohash/. The authors are listed in prov/CF8HNQ.jsonl.

import random
from fractions import Fraction

import pytest

from geohash import BASE32, geohash_center, geohash_decode, geohash_encode


def oracle(lat: float, lon: float, p: int) -> str:
    """Independent encoder: exact integer cell indices, then interleave."""
    nbits = 5 * p
    lon_bits = (nbits + 1) // 2
    lat_bits = nbits // 2
    xi = int((Fraction(lon) + 180) / 360 * 2**lon_bits)
    yi = int((Fraction(lat) + 90) / 180 * 2**lat_bits)
    xi = min(xi, 2**lon_bits - 1)
    yi = min(yi, 2**lat_bits - 1)
    out = []
    for k in range(nbits):
        if k % 2 == 0:
            out.append((xi >> (lon_bits - 1 - k // 2)) & 1)
        else:
            out.append((yi >> (lat_bits - 1 - k // 2)) & 1)
    chars = []
    for c in range(p):
        v = 0
        for b in out[5 * c : 5 * c + 5]:
            v = 2 * v + b
        chars.append(BASE32[v])
    return "".join(chars)


def test_page_example() -> None:
    assert geohash_encode(57.64911, 10.40744, 11) == "u4pruydqqvj"
    assert geohash_encode(57.64911, 10.40744, 2) == "u4"
    assert geohash_decode("u4") == (56.25, 61.875, 0.0, 11.25)


def test_boundary_example() -> None:
    assert geohash_encode(0.0001, 10.0, 6) == "s0p058"
    assert geohash_encode(-0.0001, 10.0, 6) == "kpzpgx"


def test_edges() -> None:
    assert geohash_encode(10.0, 20.0, 0) == ""
    assert geohash_encode(90.0, 180.0, 4) == "zzzz"
    assert geohash_encode(-90.0, -180.0, 4) == "0000"
    assert geohash_encode(0.0, 0.0, 1) == "s"
    with pytest.raises(ValueError):
        geohash_encode(91.0, 0.0, 3)
    with pytest.raises(ValueError):
        geohash_decode("ab")  # 'a' is not in the alphabet


def test_cell_sizes() -> None:
    lat_lo, lat_hi, lon_lo, lon_hi = geohash_decode("u09tunq")
    assert lat_hi - lat_lo == 180 / 2**17
    assert lon_hi - lon_lo == 360 / 2**18


def test_random_against_oracle() -> None:
    rng = random.Random(4096)
    for _ in range(2000):
        lat = rng.uniform(-90, 90)
        lon = rng.uniform(-180, 180)
        if rng.random() < 0.2:  # land on grid lines on purpose
            lat = round(lat / 11.25) * 11.25
            lon = round(lon / 22.5) * 22.5
        p = rng.randint(0, 12)
        h = geohash_encode(lat, lon, p)
        assert h == oracle(lat, lon, p)
        # the point lies in its cell, and shorter hashes are prefixes
        a, b, c, d = geohash_decode(h)
        assert a <= lat <= b and c <= lon <= d
        for q in range(p + 1):
            assert geohash_encode(lat, lon, q) == h[:q]
        if p:
            clat, clon = geohash_center(h)
            assert geohash_encode(clat, clon, p) == h

What the next level needs

  • the tests did not run in this build

Read the full page about Geohash