"""Geohash neighbor calculation functionality.
This module provides functionality for calculating adjacent geohashes
in different directions (right, left, top, bottom).
"""
from __future__ import annotations
from typing import Dict, Final, Literal, Tuple
from pygeohash.geohash import __base32
from pygeohash.types import Direction, is_valid_geohash
from pygeohash.logging import get_logger
logger = get_logger(__name__)
# Configuration -- from https://github.com/davetroy/geohash-js/blob/master/geohash.js
NEIGHBORS: Final[Dict[Direction, Dict[Literal["even", "odd"], str]]] = {
"right": {
"even": "bc01fg45238967deuvhjyznpkmstqrwx",
"odd": "p0r21436x8zb9dcf5h7kjnmqesgutwvy", # = top-even
},
"left": {
"even": "238967debc01fg45kmstqrwxuvhjyznp",
"odd": "14365h7k9dcfesgujnmqp0r2twvyx8zb", # = bottom-even
},
"top": {
"even": "p0r21436x8zb9dcf5h7kjnmqesgutwvy",
"odd": "bc01fg45238967deuvhjyznpkmstqrwx", # = right-even
},
"bottom": {
"even": "14365h7k9dcfesgujnmqp0r2twvyx8zb",
"odd": "238967debc01fg45kmstqrwxuvhjyznp", # = left-even
},
}
# Used change of parent tile
BORDERS: Final[Dict[Direction, Dict[Literal["even", "odd"], str]]] = {
"right": {
"even": "bcfguvyz",
"odd": "prxz", # top-even
},
"left": {
"even": "0145hjnp",
"odd": "028b", # bottom-even
},
"top": {
"even": "prxz",
"odd": "bcfguvyz", # right-even
},
"bottom": {
"even": "028b",
"odd": "0145hjnp", # left-even
},
}
DIRECTIONS: Final[Tuple[Direction, ...]] = ("right", "left", "top", "bottom")
[docs]
def get_adjacent(geohash: str, direction: Direction) -> str:
"""Calculate the adjacent geohash in the specified direction.
Args:
geohash (str): The input geohash string.
direction (Direction): The direction to find the adjacent geohash.
Must be one of: "right", "left", "top", "bottom".
Returns:
str: The adjacent geohash in the specified direction. Longitude is cyclic, so the
"left"/"right" neighbors of a cell touching the antimeridian wrap around to the
other side of the grid.
Raises:
ValueError: If the geohash is empty; if it is not a canonical geohash (1-12
characters drawn from the geohash base32 alphabet, case-insensitive); if the
direction is not one of "right", "left", "top" or "bottom"; or if the requested
neighbor would lie beyond the north or south pole ("top" of the top row,
"bottom" of the bottom row).
Example:
>>> get_adjacent("u4pruyd", "top")
'u4pruyf'
"""
logger.debug("Finding adjacent geohash for %s in direction %s", geohash, direction)
if len(geohash) == 0:
logger.error("Cannot find adjacent geohash: input geohash length is 0")
raise ValueError("The geohash length cannot be 0. Possible when close to poles")
if not is_valid_geohash(geohash):
logger.error("Cannot find adjacent geohash: %r is not a valid geohash", geohash)
raise ValueError(
f"Invalid geohash: {geohash!r}. A geohash must be 1 to 12 characters "
"from the base32 alphabet '0123456789bcdefghjkmnpqrstuvwxyz'"
)
if direction not in DIRECTIONS:
logger.error("Cannot find adjacent geohash: %r is not a valid direction", direction)
raise ValueError(f"Invalid direction: {direction!r}. Must be one of 'right', 'left', 'top', 'bottom'")
source_hash = geohash.lower()
last_char = source_hash[-1]
base = source_hash[:-1]
logger.debug("Processing: base=%s, last_char=%s", base, last_char)
split_direction: Literal["even", "odd"] = "even" if len(source_hash) % 2 == 0 else "odd"
logger.debug("Using %s lookup tables based on hash length", split_direction)
if last_char in BORDERS[direction][split_direction]:
if base:
logger.debug("Last character %s is on border, recursively finding parent neighbor", last_char)
base = get_adjacent(base, direction)
elif direction in ("top", "bottom"):
pole = "north" if direction == "top" else "south"
logger.error("Cannot find adjacent geohash: the %s neighbor lies beyond the %s pole", direction, pole)
raise ValueError(f"No adjacent geohash to the {direction}: it would lie beyond the {pole} pole")
else:
# Longitude is cyclic, so the top-level tables already encode the antimeridian wrap.
logger.debug("Last character %s is on the antimeridian, wrapping longitude", last_char)
else:
logger.debug("Last character %s is not on border, using direct neighbor lookup", last_char)
neighbor_str = NEIGHBORS[direction][split_direction]
idx = neighbor_str.index(last_char)
base32_char = __base32[idx]
result = base + base32_char
logger.debug("Found adjacent geohash: %s", result)
return result