├── geohash_hilbert ├── py.typed ├── _hilbert_cython.pyi ├── __init__.py ├── _hilbert_cython.pyx ├── _int2str.py ├── _utils.py └── _hilbert.py ├── img ├── hilbert.png ├── neighbors.png └── rectangle.png ├── setup.cfg ├── .gitignore ├── Makefile ├── .github └── workflows │ ├── Publish.yml │ └── CI.yml ├── LICENSE.txt ├── tests ├── test_int2str.py ├── test_utils.py └── test_hilbert.py ├── pyproject.toml └── README.md /geohash_hilbert/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/hilbert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tammoippen/geohash-hilbert/HEAD/img/hilbert.png -------------------------------------------------------------------------------- /img/neighbors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tammoippen/geohash-hilbert/HEAD/img/neighbors.png -------------------------------------------------------------------------------- /img/rectangle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tammoippen/geohash-hilbert/HEAD/img/rectangle.png -------------------------------------------------------------------------------- /geohash_hilbert/_hilbert_cython.pyi: -------------------------------------------------------------------------------- 1 | MAX_BITS: int 2 | 3 | def xy2hash_cython(x: int, y: int, dim: int) -> int: ... 4 | def hash2xy_cython(hashcode: int, dim: int) -> tuple[int, int]: ... 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | addopts = --cov=geohash_hilbert --cov-branch --cov-report term-missing --cov-report html:cov_html --cov-report=xml:coverage.xml 3 | 4 | [bdist_wheel] 5 | universal = 0 6 | 7 | [metadata] 8 | license_file = LICENSE.txt 9 | description-file = README.md 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | venv/ 3 | data/ 4 | build/ 5 | *.egg-info 6 | .DS_Store 7 | .cache/ 8 | .coverage 9 | coverage.xml 10 | cov_html/ 11 | .ruff_cache/ 12 | .pytest_cache/ 13 | *.so 14 | *.c 15 | *.html 16 | *.pyc 17 | __pycache__/ 18 | dist/ 19 | README.rst 20 | Pipfile.lock 21 | pyproject.lock 22 | poetry.lock 23 | .hypothesis/ 24 | .vscode/ 25 | pip-wheel-metadata/ 26 | .benchmarks/ 27 | .mypy_cache/ 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: fmt check test 2 | 3 | fmt: 4 | poetry run ruff format . 5 | poetry run ruff check --fix . 6 | 7 | check: 8 | poetry run ruff format --check . 9 | poetry run ruff check . 10 | poetry run mypy geohash_hilbert 11 | 12 | test: 13 | PYTHONDEVMODE=1 poetry run pytest -vvv -s 14 | 15 | cythonize: 16 | poetry run cythonize -i geohash_hilbert/*.pyx 17 | poetry run python -c "import geohash_hilbert as ghh; print(ghh._hilbert.CYTHON_AVAILABLE)" 18 | -------------------------------------------------------------------------------- /.github/workflows/Publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | environment: pypi 13 | 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v4 17 | 18 | - name: Set Up Python 3.12 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.12 22 | 23 | - name: Install Poetry 24 | uses: snok/install-poetry@v1 25 | with: 26 | version: 1.8.3 27 | 28 | - name: Build 29 | run: poetry build -vvv -f sdist 30 | 31 | - name: Archive artifacts 32 | uses: actions/upload-artifact@v4 33 | with: 34 | path: dist/*.tar.gz 35 | 36 | - name: Publish 37 | run: poetry publish -vvv -n -u __token__ -p ${{ secrets.PYPI_PASS }} 38 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Tammo Ippen, tammo.ippen@posteo.de 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_int2str.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | import sys 3 | 4 | import pytest 5 | 6 | from geohash_hilbert._int2str import decode_int, encode_int 7 | 8 | 9 | def test_parameters(): 10 | for bpc in (2, 4, 6): 11 | assert "1" == encode_int(1, bpc) 12 | assert 1 == decode_int("1", bpc) 13 | 14 | for nbpc in [1, 3, 5] + list(range(7, 100)) + list(range(0, -50, -1)): 15 | with pytest.raises(ValueError): 16 | encode_int(1, nbpc) 17 | 18 | with pytest.raises(ValueError): 19 | decode_int(1, nbpc) 20 | 21 | 22 | def test_invalid(): 23 | for bpc in (2, 4, 6): 24 | with pytest.raises(ValueError): 25 | encode_int(-1, bpc) 26 | 27 | 28 | def test_empty(): 29 | for bpc in (2, 4, 6): 30 | assert 0 == decode_int("", bpc) 31 | 32 | 33 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 34 | def test_randoms(bpc): 35 | prev_code = None 36 | for _i in range(100): 37 | i = randint(0, sys.maxsize) 38 | code = encode_int(i, bpc) 39 | assert isinstance(code, str) 40 | assert code != i 41 | assert i == decode_int(code, bpc) 42 | 43 | if prev_code is not None: 44 | assert code != prev_code 45 | 46 | prev_code = code 47 | -------------------------------------------------------------------------------- /geohash_hilbert/__init__.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2017 - 2024 Tammo Ippen, tammo.ippen@posteo.de 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | from ._hilbert import decode, decode_exactly, encode 24 | from ._utils import hilbert_curve, neighbours, rectangle 25 | 26 | 27 | __all__ = [ 28 | "decode_exactly", 29 | "decode", 30 | "encode", 31 | "hilbert_curve", 32 | "neighbours", 33 | "rectangle", 34 | ] 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | 3 | name = "geohash-hilbert" 4 | version = "2.0.0" 5 | description = "Geohash a lng/lat coordinate using the hilbert curve." 6 | authors = ["Tammo Ippen "] 7 | license = "MIT" 8 | 9 | readme = "README.md" 10 | 11 | repository = "https://github.com/tammoippen/geohash-hilbert" 12 | homepage = "https://github.com/tammoippen/geohash-hilbert" 13 | 14 | build = "build.py" 15 | 16 | include = ["img/*", "tests/*.py"] 17 | 18 | keywords = ["geohash", "hilbert", "space filling curve", "geometry"] 19 | 20 | classifiers = [ 21 | # Trove classifiers 22 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 23 | 'License :: OSI Approved :: MIT License', 24 | 'Programming Language :: Python', 25 | 'Programming Language :: Python :: 3.9', 26 | 'Programming Language :: Python :: 3.10', 27 | 'Programming Language :: Python :: 3.11', 28 | 'Programming Language :: Python :: 3.12', 29 | 'Programming Language :: Python :: 3.13', 30 | 'Programming Language :: Python :: Implementation :: CPython', 31 | 'Programming Language :: Python :: Implementation :: PyPy' 32 | ] 33 | 34 | [tool.poetry.dependencies] 35 | 36 | python = "^3.9" 37 | 38 | [tool.poetry.group.dev.dependencies] 39 | 40 | coveralls = "*" 41 | cython = "*" 42 | mypy = "*" 43 | pytest = "*" 44 | pytest-benchmark = "*" 45 | pytest-cov = "*" 46 | ruff = "*" 47 | # cython needs distutils, but 3.12 removed it (https://github.com/cython/cython/issues/5751) 48 | setuptools = { version = "*", python = ">=3.12" } 49 | 50 | [build-system] 51 | requires = ["poetry>=0.12"] 52 | build-backend = "poetry.masonry.api" 53 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | branches: 9 | - "master" 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | python-version: ['pypy-3.9', 3.9, '3.10', 'pypy-3.10', '3.11', '3.12', '3.13'] 18 | cython: ['yes', 'no'] 19 | include: 20 | - os: ubuntu-latest 21 | path: ~/.cache/pip 22 | - os: macos-latest 23 | path: ~/Library/Caches/pip 24 | - os: windows-latest 25 | path: ~\AppData\Local\pip\Cache 26 | exclude: 27 | - os: macos-latest 28 | python-version: 'pypy-3.9' 29 | - os: macos-latest 30 | python-version: 'pypy-3.10' 31 | - os: windows-latest 32 | python-version: 'pypy-3.9' 33 | - os: windows-latest 34 | python-version: 'pypy-3.10' 35 | # - os: windows-latest 36 | # cython: 'yes' 37 | defaults: 38 | run: 39 | shell: bash 40 | 41 | runs-on: ${{ matrix.os }} 42 | env: 43 | PYTHONIOENCODING: UTF-8 44 | steps: 45 | - name: Checkout repo 46 | uses: actions/checkout@v4 47 | 48 | - name: Set Up Python ${{ matrix.python-version }} 49 | uses: actions/setup-python@v5 50 | with: 51 | python-version: ${{ matrix.python-version }} 52 | 53 | - name: Cache Install 54 | id: restore-cache 55 | uses: actions/cache@v4 56 | with: 57 | path: | 58 | ${{ matrix.path }} 59 | poetry.lock 60 | key: ${{ matrix.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('tests/requirements.txt') }} 61 | 62 | - name: Install Poetry 63 | uses: snok/install-poetry@v1 64 | with: 65 | version: '1.8.3' 66 | virtualenvs-create: false 67 | 68 | - name: Install 69 | run: poetry install 70 | 71 | - name: Build Cython file 72 | if: ${{ matrix.cython == 'yes' }} 73 | run: make cythonize 74 | 75 | - name: Style 76 | if: ${{ ! startsWith(matrix.python-version, 'pypy-') && startsWith(matrix.os, 'ubuntu') }} 77 | run: make check 78 | 79 | - name: Tests 80 | run: make test 81 | 82 | - uses: codecov/codecov-action@v4 83 | with: 84 | file: coverage.xml 85 | name: coverage-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.cython }} 86 | token: ${{ secrets.CODECOV_TOKEN }} 87 | verbose: true 88 | -------------------------------------------------------------------------------- /geohash_hilbert/_hilbert_cython.pyx: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2017 - 2024 Tammo Ippen, tammo.ippen@posteo.de 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | 24 | ctypedef unsigned long long ghh_uint 25 | MAX_BITS = sizeof(ghh_uint) * 8 26 | 27 | cdef ghh_uint cy_xy2hash_cython(ghh_uint x, ghh_uint y, const ghh_uint dim): 28 | cdef ghh_uint d = 0 29 | cdef ghh_uint lvl = dim >> 1 30 | cdef ghh_uint rx, ry 31 | 32 | while (lvl > 0): 33 | rx = ((x & lvl) > 0) 34 | ry = ((y & lvl) > 0) 35 | d += lvl * lvl * ((3 * rx) ^ ry) 36 | _rotate(lvl, &x, &y, rx, ry) 37 | lvl >>= 1 38 | return d 39 | 40 | def xy2hash_cython(x: long, y: long, dim: long) -> long: 41 | '''Convert (x, y) to hashcode. 42 | 43 | Based on the implementation here: 44 | https://en.wikipedia.org/w/index.php?title=Hilbert_curve&oldid=797332503 45 | 46 | Cython implementation. 47 | 48 | Parameters: 49 | x: int x value of point [0, dim) in dim x dim coord system 50 | y: int y value of point [0, dim) in dim x dim coord system 51 | dim: int Number of coding points each x, y value can take. 52 | Corresponds to 2^level of the hilbert curve. 53 | 54 | Returns: 55 | int: hashcode ∈ [0, dim**2) 56 | ''' 57 | return cy_xy2hash_cython(x, y, dim) 58 | 59 | 60 | cdef void cy_hash2xy_cython(ghh_uint hashcode, const ghh_uint dim, ghh_uint* x, ghh_uint* y): 61 | cdef ghh_uint lvl = 1 62 | cdef ghh_uint rx, ry 63 | x[0] = y[0] = 0 64 | 65 | while (lvl < dim): 66 | rx = 1 & (hashcode >> 1) 67 | ry = 1 & (hashcode ^ rx) 68 | _rotate(lvl, x, y, rx, ry) 69 | x[0] += lvl * rx 70 | y[0] += lvl * ry 71 | hashcode >>= 2 72 | lvl <<= 1 73 | 74 | 75 | cpdef hash2xy_cython(ghh_uint hashcode, const ghh_uint dim): 76 | '''Convert hashcode to (x, y). 77 | 78 | Based on the implementation here: 79 | https://en.wikipedia.org/w/index.php?title=Hilbert_curve&oldid=797332503 80 | 81 | Cython implementation. 82 | 83 | Parameters: 84 | hashcode: int Hashcode to decode [0, dim**2) 85 | dim: int Number of coding points each x, y value can take. 86 | Corresponds to 2^level of the hilbert curve. 87 | 88 | Returns: 89 | Tuple[int, int]: (x, y) point in dim x dim-grid system 90 | ''' 91 | cdef unsigned long long x, y 92 | x = y = 0 93 | 94 | cy_hash2xy_cython(hashcode, dim, &x, &y) 95 | 96 | return x, y 97 | 98 | 99 | cdef void _rotate(ghh_uint n, ghh_uint* x, ghh_uint* y, ghh_uint rx, ghh_uint ry): 100 | if ry == 0: 101 | if rx == 1: 102 | x[0] = n - 1 - x[0] 103 | y[0] = n - 1 - y[0] 104 | # swap x, y 105 | x[0] ^= y[0] 106 | y[0] ^= x[0] 107 | x[0] ^= y[0] 108 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | 3 | import pytest 4 | 5 | from geohash_hilbert import _utils as utils 6 | from geohash_hilbert import decode_exactly, encode 7 | from geohash_hilbert._int2str import decode_int 8 | 9 | 10 | def rand_lng(): 11 | return random() * 360 - 180 12 | 13 | 14 | def rand_lat(): 15 | return random() * 180 - 90 16 | 17 | 18 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 19 | @pytest.mark.parametrize("prec", range(2, 7)) 20 | def test_neighbours(bpc, prec): 21 | for _i in range(100): 22 | code = encode(rand_lng(), rand_lat(), bits_per_char=bpc, precision=prec) 23 | lng, lat, lng_err, lat_err = decode_exactly(code, bits_per_char=bpc) 24 | neighbours = utils.neighbours(code, bpc) 25 | 26 | expected_directions = {"east", "west"} 27 | if lat + lat_err < 90: # more earth to the north 28 | expected_directions.update({"north", "north-west", "north-east"}) 29 | if lat - lat_err > -90: # more earth to the south 30 | expected_directions.update({"south", "south-west", "south-east"}) 31 | 32 | assert expected_directions == set(neighbours.keys()) 33 | 34 | # no duplicates (depends on level) 35 | assert len(neighbours) == len(set(neighbours.values())) 36 | 37 | for v in neighbours.values(): 38 | n_lng, n_lat, n_lng_err, n_lat_err = decode_exactly(v, bits_per_char=bpc) 39 | 40 | # same level 41 | assert len(code) == len(v) 42 | assert lng_err == n_lng_err 43 | assert lat_err == n_lat_err 44 | 45 | # neighbour is in disc 4x error 46 | assert ( 47 | lng == n_lng # east / west 48 | or lng - 2 * lng_err == n_lng 49 | or lng - 2 * lng_err + 360 == n_lng # south 50 | or lng + 2 * lng_err == n_lng 51 | or lng + 2 * lng_err - 360 == n_lng 52 | ) # north 53 | 54 | assert ( 55 | lat == n_lat # north / south 56 | or lat - 2 * lat_err == n_lat 57 | or lat - 2 * lat_err + 180 == n_lat # west 58 | or lat + 2 * lat_err == n_lat 59 | or lat + 2 * lat_err - 180 == n_lat 60 | ) # east 61 | 62 | 63 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 64 | @pytest.mark.parametrize("prec", range(1, 7)) 65 | def test_rectangle(bpc, prec): 66 | code = encode(rand_lng(), rand_lat(), bits_per_char=bpc, precision=prec) 67 | lng, lat, lng_err, lat_err = decode_exactly(code, bits_per_char=bpc) 68 | 69 | rect = utils.rectangle(code, bits_per_char=bpc) 70 | 71 | assert isinstance(rect, dict) 72 | assert rect["type"] == "Feature" 73 | assert rect["geometry"]["type"] == "Polygon" 74 | assert rect["bbox"] == (lng - lng_err, lat - lat_err, lng + lng_err, lat + lat_err) 75 | assert rect["properties"] == { 76 | "code": code, 77 | "lng": lng, 78 | "lat": lat, 79 | "lng_err": lng_err, 80 | "lat_err": lat_err, 81 | "bits_per_char": bpc, 82 | } 83 | 84 | coords = rect["geometry"]["coordinates"] 85 | assert 1 == len(coords) # one external ring 86 | assert 5 == len(coords[0]) # rectangle has 5 coordinates 87 | 88 | # ccw 89 | assert (lng - lng_err, lat - lat_err) == coords[0][0] # lower left 90 | assert (lng + lng_err, lat - lat_err) == coords[0][1] # lower right 91 | assert (lng + lng_err, lat + lat_err) == coords[0][2] # upper right 92 | assert (lng - lng_err, lat + lat_err) == coords[0][3] # upper left 93 | assert (lng - lng_err, lat - lat_err) == coords[0][4] # lower left 94 | 95 | 96 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 97 | @pytest.mark.parametrize("prec", range(1, 4)) 98 | def test_hilbert_curve(bpc, prec): 99 | hc = utils.hilbert_curve(prec, bpc) 100 | bits = bpc * prec 101 | 102 | assert isinstance(hc, dict) 103 | assert hc["type"] == "Feature" 104 | assert hc["geometry"]["type"] == "LineString" 105 | 106 | coords = hc["geometry"]["coordinates"] 107 | assert 1 << bits == len(coords) 108 | 109 | for i, coord in enumerate(coords): 110 | code = encode(*coord, precision=prec, bits_per_char=bpc) 111 | assert i == decode_int(code, bpc) 112 | -------------------------------------------------------------------------------- /geohash_hilbert/_int2str.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2017 - 2024 Tammo Ippen, tammo.ippen@posteo.de 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | from typing import Literal 24 | 25 | BitsPerChar = Literal[2, 4, 6] 26 | 27 | 28 | def encode_int(code: int, bits_per_char: BitsPerChar = 6) -> str: 29 | """Encode int into a string preserving order 30 | 31 | It is using 2, 4 or 6 bits per coding character (default 6). 32 | 33 | Parameters: 34 | code: int Positive integer. 35 | bits_per_char: int The number of bits per coding character. 36 | 37 | Returns: 38 | str: the encoded integer 39 | """ 40 | if code < 0: 41 | raise ValueError("Only positive ints are allowed!") 42 | 43 | if bits_per_char == 6: 44 | return _encode_int64(code) 45 | if bits_per_char == 4: 46 | return _encode_int16(code) 47 | if bits_per_char == 2: 48 | return _encode_int4(code) 49 | 50 | raise ValueError("`bits_per_char` must be in {6, 4, 2}") 51 | 52 | 53 | def decode_int(tag: str, bits_per_char: BitsPerChar = 6) -> int: 54 | """Decode string into int assuming encoding with `encode_int()` 55 | 56 | It is using 2, 4 or 6 bits per coding character (default 6). 57 | 58 | Parameters: 59 | tag: str Encoded integer. 60 | bits_per_char: int The number of bits per coding character. 61 | 62 | Returns: 63 | int: the decoded string 64 | """ 65 | if bits_per_char == 6: 66 | return _decode_int64(tag) 67 | if bits_per_char == 4: 68 | return _decode_int16(tag) 69 | if bits_per_char == 2: 70 | return _decode_int4(tag) 71 | 72 | raise ValueError("`bits_per_char` must be in {6, 4, 2}") 73 | 74 | 75 | # Own base64 encoding with integer order preservation via lexicographical (byte) order. 76 | _BASE64 = ( 77 | "0123456789" # noqa: E262 # 10 0x30 - 0x39 78 | "@" # + 1 0x40 79 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ" # + 26 0x41 - 0x5A 80 | "_" # + 1 0x5F 81 | "abcdefghijklmnopqrstuvwxyz" # + 26 0x61 - 0x7A 82 | ) # = 64 0x30 - 0x7A 83 | _BASE64_MAP = {c: i for i, c in enumerate(_BASE64)} 84 | 85 | 86 | def _encode_int64(code: int) -> str: 87 | code_len = (code.bit_length() + 5) // 6 # 6 bit per code point 88 | res = ["0"] * code_len 89 | for i in range(code_len - 1, -1, -1): 90 | res[i] = _BASE64[code & 0b111111] 91 | code >>= 6 92 | return "".join(res) 93 | 94 | 95 | def _decode_int64(t: str) -> int: 96 | code = 0 97 | for ch in t: 98 | code <<= 6 99 | code += _BASE64_MAP[ch] 100 | return code 101 | 102 | 103 | def _encode_int16(code: int) -> str: 104 | code_str = "" + hex(code)[2:] # this makes it unicode in py2 105 | if code_str.endswith("L"): 106 | code_str = code_str[:-1] 107 | return code_str 108 | 109 | 110 | def _decode_int16(t: str) -> int: 111 | if len(t) == 0: 112 | return 0 113 | return int(t, 16) 114 | 115 | 116 | def _encode_int4(code: int) -> str: 117 | _BASE4 = "0123" 118 | code_len = (code.bit_length() + 1) // 2 # two bit per code point 119 | res = ["0"] * code_len 120 | 121 | for i in range(code_len - 1, -1, -1): 122 | res[i] = _BASE4[code & 0b11] 123 | code >>= 2 124 | 125 | return "".join(res) 126 | 127 | 128 | def _decode_int4(t: str) -> int: 129 | if len(t) == 0: 130 | return 0 131 | return int(t, 4) 132 | -------------------------------------------------------------------------------- /tests/test_hilbert.py: -------------------------------------------------------------------------------- 1 | from random import random 2 | 3 | import pytest 4 | 5 | from geohash_hilbert import _hilbert as hilbert 6 | 7 | 8 | def rand_lng(): 9 | return random() * 360 - 180 10 | 11 | 12 | def rand_lat(): 13 | return random() * 180 - 90 14 | 15 | 16 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 17 | def test_decode_empty(bpc): 18 | assert (0, 0) == hilbert.decode("", bits_per_char=bpc) 19 | assert (0, 0, 180, 90) == hilbert.decode_exactly("", bits_per_char=bpc) 20 | 21 | 22 | @pytest.mark.parametrize("prec", range(1, 15)) 23 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 24 | def test_encode_decode(bpc, prec): 25 | for _i in range(100): 26 | lng, lat = rand_lng(), rand_lat() 27 | code = hilbert.encode(lng, lat, precision=prec, bits_per_char=bpc) 28 | lng_code, lat_code, lng_err, lat_err = hilbert.decode_exactly( 29 | code, bits_per_char=bpc 30 | ) 31 | 32 | assert lng == pytest.approx(lng_code, abs=lng_err) 33 | assert lat == pytest.approx(lat_code, abs=lat_err) 34 | 35 | assert (lng_code, lat_code) == hilbert.decode(code, bits_per_char=bpc) 36 | 37 | 38 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 39 | def test_bench_encode(benchmark, bpc): 40 | prec = 60 // bpc 41 | lng, lat = rand_lng(), rand_lat() 42 | benchmark(hilbert.encode, lng, lat, precision=prec, bits_per_char=bpc) 43 | 44 | 45 | @pytest.mark.parametrize("bpc", (2, 4, 6)) 46 | def test_bench_decode(benchmark, bpc): 47 | prec = 60 // bpc 48 | lng, lat = rand_lng(), rand_lat() 49 | code = hilbert.encode(lng, lat, precision=prec, bits_per_char=bpc) 50 | benchmark(hilbert.decode_exactly, code, bits_per_char=bpc) 51 | 52 | 53 | def test_lvl_error(): 54 | # lvl 0 is whole world -> lng/lat error is half of max lng/lat 55 | assert (180, 90) == hilbert._lvl_error(0) 56 | 57 | # every level halves the error 58 | lng_err, lat_err = 180, 90 59 | for lvl in range(1, 30): 60 | lng_err /= 2 61 | lat_err /= 2 62 | assert (lng_err, lat_err) == hilbert._lvl_error(lvl) 63 | 64 | 65 | def test_coord2int(): 66 | # we want a dim x dim grid, i.e. we want dim cells in every direction and have coding points 0 ... dim-1 67 | # minimum dim is 1 => whole world in one coding point 68 | with pytest.raises(AssertionError): 69 | hilbert._coord2int(0, 0, 0) 70 | 71 | assert (0, 0) == hilbert._coord2int(rand_lng(), rand_lat(), 1) 72 | 73 | # lvl 2 is 4 cells: 2 in every direction 74 | assert (0, 0) == hilbert._coord2int(-180, -90, 2) 75 | assert (1, 0) == hilbert._coord2int(180, -90, 2) 76 | assert (0, 1) == hilbert._coord2int(-180, 90, 2) 77 | assert (1, 1) == hilbert._coord2int(180, 90, 2) 78 | 79 | # lvl 3 is 9 cells: 3 in every direction 80 | assert (0, 0) == hilbert._coord2int(-180, -90, 3) 81 | assert (1, 0) == hilbert._coord2int(0, -90, 3) 82 | assert (2, 0) == hilbert._coord2int(180, -90, 3) 83 | assert (0, 1) == hilbert._coord2int(-180, 0, 3) 84 | assert (0, 2) == hilbert._coord2int(-180, 90, 3) 85 | 86 | assert (1, 1) == hilbert._coord2int(0, 0, 3) 87 | assert (1, 2) == hilbert._coord2int(0, 90, 3) 88 | assert (2, 1) == hilbert._coord2int(180, 0, 3) 89 | assert (2, 2) == hilbert._coord2int(180, 90, 3) 90 | 91 | for dim in range(3, 200): 92 | for _i in range(100): 93 | x, y = hilbert._coord2int(rand_lng(), rand_lat(), dim) 94 | assert 0 <= x < dim 95 | assert 0 <= y < dim 96 | 97 | 98 | def test_int2coord(): 99 | with pytest.raises(AssertionError): 100 | hilbert._int2coord(0, 0, 0) 101 | # we always get lower left corner of coding cell 102 | # only one coding cell 103 | assert (-180, -90) == hilbert._int2coord(0, 0, 1) 104 | 105 | # lvl 2 is 4 cells: 2 in every direction 106 | assert (-180, -90) == hilbert._int2coord(0, 0, 2) 107 | assert (0, -90) == hilbert._int2coord(1, 0, 2) 108 | assert (-180, 0) == hilbert._int2coord(0, 1, 2) 109 | assert (0, 0) == hilbert._int2coord(1, 1, 2) 110 | 111 | 112 | def test_coord2int2coord(): 113 | for lvl in range(1, 30): 114 | lng_err, lat_err = hilbert._lvl_error(lvl) 115 | dim = 1 << lvl 116 | 117 | for _i in range(1000): 118 | lng, lat = rand_lng(), rand_lat() 119 | 120 | x, y = hilbert._coord2int(lng, lat, dim) 121 | 122 | lng_x, lat_y = hilbert._int2coord(x, y, dim) 123 | 124 | # we always get lower left corner of coding cell 125 | # hence add error and then we have +- error 126 | assert lng == pytest.approx(lng_x + lng_err, abs=lng_err) 127 | assert lat == pytest.approx(lat_y + lat_err, abs=lat_err) 128 | -------------------------------------------------------------------------------- /geohash_hilbert/_utils.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2017 - 2024 Tammo Ippen, tammo.ippen@posteo.de 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | from typing import Any, Literal 24 | 25 | from ._hilbert import decode, decode_exactly, encode 26 | from ._int2str import BitsPerChar, encode_int 27 | 28 | 29 | Directions = Literal[ 30 | "north", 31 | "north-east", 32 | "east", 33 | "south-east", 34 | "south", 35 | "south-west", 36 | "west", 37 | "north-west", 38 | ] 39 | 40 | 41 | def neighbours(code: str, bits_per_char: BitsPerChar = 6) -> dict[Directions, str]: 42 | """Get the neighbouring geohashes for `code`. 43 | 44 | Look for the north, north-east, east, south-east, south, south-west, west, 45 | north-west neighbours. If you are at the east/west edge of the grid 46 | (lng ∈ (-180, 180)), then it wraps around the globe and gets the corresponding 47 | neighbor. 48 | 49 | Parameters: 50 | code: str The geohash at the center. 51 | bits_per_char: int The number of bits per coding character. 52 | 53 | Returns: 54 | dict: geohashes in the neighborhood of `code`. Possible keys are 'north', 55 | 'north-east', 'east', 'south-east', 'south', 'south-west', 56 | 'west', 'north-west'. If the input code covers the north pole, then 57 | keys 'north', 'north-east', and 'north-west' are not present, and if 58 | the input code covers the south pole then keys 'south', 'south-west', 59 | and 'south-east' are not present. 60 | """ 61 | lng, lat, lng_err, lat_err = decode_exactly(code, bits_per_char) 62 | precision = len(code) 63 | 64 | north = lat + 2 * lat_err 65 | 66 | south = lat - 2 * lat_err 67 | 68 | east = lng + 2 * lng_err 69 | if east > 180: 70 | east -= 360 71 | 72 | west = lng - 2 * lng_err 73 | if west < -180: 74 | west += 360 75 | 76 | neighbours_dict: dict[Directions, str] = { 77 | "east": encode(east, lat, precision, bits_per_char), # noqa: E241 78 | "west": encode(west, lat, precision, bits_per_char), # noqa: E241 79 | } 80 | 81 | if north <= 90: # input cell not already at the north pole 82 | neighbours_dict.update( 83 | { 84 | "north": encode(lng, north, precision, bits_per_char), # noqa: E241 85 | "north-east": encode(east, north, precision, bits_per_char), # noqa: E241 86 | "north-west": encode(west, north, precision, bits_per_char), # noqa: E241 87 | } 88 | ) 89 | 90 | if south >= -90: # input cell not already at the south pole 91 | neighbours_dict.update( 92 | { 93 | "south": encode(lng, south, precision, bits_per_char), # noqa: E241 94 | "south-east": encode(east, south, precision, bits_per_char), # noqa: E241 95 | "south-west": encode(west, south, precision, bits_per_char), # noqa: E241 96 | } 97 | ) 98 | 99 | return neighbours_dict 100 | 101 | 102 | def rectangle(code: str, bits_per_char: BitsPerChar = 6) -> dict[str, Any]: 103 | """Builds a (geojson) rectangle from `code` 104 | 105 | The center of the rectangle decodes as the lng/lat for code and 106 | the rectangle corresponds to the error-margin, i.e. every lng/lat 107 | point within this rectangle will be encoded as `code`, given `precision == len(code)`. 108 | 109 | Parameters: 110 | code: str The geohash for which the rectangle should be build. 111 | bits_per_char: int The number of bits per coding character. 112 | 113 | Returns: 114 | dict: geojson `Feature` containing the rectangle as a `Polygon`. 115 | """ 116 | lng, lat, lng_err, lat_err = decode_exactly(code, bits_per_char) 117 | 118 | return { 119 | "type": "Feature", 120 | "properties": { 121 | "code": code, 122 | "lng": lng, 123 | "lat": lat, 124 | "lng_err": lng_err, 125 | "lat_err": lat_err, 126 | "bits_per_char": bits_per_char, 127 | }, 128 | "bbox": ( 129 | lng - lng_err, # bottom left 130 | lat - lat_err, 131 | lng + lng_err, # top right 132 | lat + lat_err, 133 | ), 134 | "geometry": { 135 | "type": "Polygon", 136 | "coordinates": [ 137 | [ 138 | (lng - lng_err, lat - lat_err), 139 | (lng + lng_err, lat - lat_err), 140 | (lng + lng_err, lat + lat_err), 141 | (lng - lng_err, lat + lat_err), 142 | (lng - lng_err, lat - lat_err), 143 | ] 144 | ], 145 | }, 146 | } 147 | 148 | 149 | def hilbert_curve(precision: int, bits_per_char: BitsPerChar = 6) -> dict[str, Any]: 150 | """Build the (geojson) `LineString` of the used hilbert-curve 151 | 152 | Builds the `LineString` of the used hilbert-curve given the `precision` and 153 | the `bits_per_char`. The number of bits to encode the geohash is equal to 154 | `precision * bits_per_char`, and for each level, you need 2 bits, hence 155 | the number of bits has to be even. The more bits are used, the more precise 156 | (and long) will the hilbert curve be, e.g. for geohashes of length 3 (precision) 157 | and 6 bits per character, there will be 18 bits used and the curve will 158 | consist of 2^18 = 262144 points. 159 | 160 | Parameters: 161 | precision: int The number of characters in a geohash. 162 | bits_per_char: int The number of bits per coding character. 163 | 164 | Returns: 165 | dict: geojson `Feature` containing the hilbert curve as a `LineString`. 166 | """ 167 | bits = precision * bits_per_char 168 | 169 | coords = [] 170 | for i in range(1 << bits): 171 | code = encode_int(i, bits_per_char).rjust(precision, "0") 172 | coords += [decode(code, bits_per_char)] 173 | 174 | return { 175 | "type": "Feature", 176 | "properties": {}, 177 | "geometry": {"type": "LineString", "coordinates": coords}, 178 | } 179 | -------------------------------------------------------------------------------- /geohash_hilbert/_hilbert.py: -------------------------------------------------------------------------------- 1 | # The MIT License 2 | # 3 | # Copyright (c) 2017 - 2024 Tammo Ippen, tammo.ippen@posteo.de 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in 13 | # all copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | # THE SOFTWARE. 22 | 23 | from math import floor 24 | 25 | from ._int2str import BitsPerChar, decode_int, encode_int 26 | 27 | try: 28 | from geohash_hilbert._hilbert_cython import hash2xy_cython, MAX_BITS, xy2hash_cython 29 | 30 | CYTHON_AVAILABLE = True 31 | except ImportError: 32 | CYTHON_AVAILABLE = False 33 | 34 | 35 | _LAT_INTERVAL = (-90.0, 90.0) 36 | _LNG_INTERVAL = (-180.0, 180.0) 37 | 38 | 39 | def encode( 40 | lng: float, lat: float, precision: int = 10, bits_per_char: BitsPerChar = 6 41 | ) -> str: 42 | """Encode a lng/lat position as a geohash using a hilbert curve 43 | 44 | This function encodes a lng/lat coordinate to a geohash of length `precision` 45 | on a corresponding a hilbert curve. Each character encodes `bits_per_char` bits 46 | per character (allowed are 2, 4 and 6 bits [default 6]). Hence, the geohash encodes 47 | the lng/lat coordinate using `precision` * `bits_per_char` bits. The number of 48 | bits devided by 2 give the level of the used hilbert curve, e.g. precision=10, bits_per_char=6 49 | (default values) use 60 bit and a level 30 hilbert curve to map the globe. 50 | 51 | Parameters: 52 | lng: float Longitude; between -180.0 and 180.0; WGS 84 53 | lat: float Latitude; between -90.0 and 90.0; WGS 84 54 | precision: int The number of characters in a geohash 55 | bits_per_char: int The number of bits per coding character 56 | 57 | Returns: 58 | str: geohash for lng/lat of length `precision` 59 | """ 60 | assert _LNG_INTERVAL[0] <= lng <= _LNG_INTERVAL[1] 61 | assert _LAT_INTERVAL[0] <= lat <= _LAT_INTERVAL[1] 62 | assert precision > 0 63 | assert bits_per_char in (2, 4, 6) 64 | 65 | bits = precision * bits_per_char 66 | level = bits >> 1 67 | dim = 1 << level 68 | 69 | x, y = _coord2int(lng, lat, dim) 70 | 71 | if CYTHON_AVAILABLE and bits <= MAX_BITS: 72 | code = xy2hash_cython(x, y, dim) 73 | else: 74 | code = _xy2hash(x, y, dim) 75 | 76 | return encode_int(code, bits_per_char).rjust(precision, "0") 77 | 78 | 79 | def decode(code: str, bits_per_char: BitsPerChar = 6) -> tuple[float, float]: 80 | """Decode a geohash on a hilbert curve as a lng/lat position 81 | 82 | Decodes the geohash `code` as a lng/lat position. It assumes, that 83 | the length of `code` corresponds to the precision! And that each character 84 | in `code` encodes `bits_per_char` bits. Do not mix geohashes with different 85 | `bits_per_char`! 86 | 87 | Parameters: 88 | code: str The geohash to decode. 89 | bits_per_char: int The number of bits per coding character 90 | 91 | Returns: 92 | Tuple[float, float]: (lng, lat) coordinate for the geohash. 93 | """ 94 | assert bits_per_char in (2, 4, 6) 95 | 96 | if len(code) == 0: 97 | return 0.0, 0.0 98 | 99 | lng, lat, _lng_err, _lat_err = decode_exactly(code, bits_per_char) 100 | return lng, lat 101 | 102 | 103 | def decode_exactly( 104 | code: str, bits_per_char: BitsPerChar = 6 105 | ) -> tuple[float, float, float, float]: 106 | """Decode a geohash on a hilbert curve as a lng/lat position with error-margins 107 | 108 | Decodes the geohash `code` as a lng/lat position with error-margins. It assumes, 109 | that the length of `code` corresponds to the precision! And that each character 110 | in `code` encodes `bits_per_char` bits. Do not mix geohashes with different 111 | `bits_per_char`! 112 | 113 | Parameters: 114 | code: str The geohash to decode. 115 | bits_per_char: int The number of bits per coding character 116 | 117 | Returns: 118 | Tuple[float, float, float, float]: (lng, lat, lng-error, lat-error) coordinate for the geohash. 119 | """ 120 | assert bits_per_char in (2, 4, 6) 121 | 122 | if len(code) == 0: 123 | return 0.0, 0.0, _LNG_INTERVAL[1], _LAT_INTERVAL[1] 124 | 125 | bits = len(code) * bits_per_char 126 | level = bits >> 1 127 | dim = 1 << level 128 | 129 | code_int = decode_int(code, bits_per_char) 130 | if CYTHON_AVAILABLE and bits <= MAX_BITS: 131 | x, y = hash2xy_cython(code_int, dim) 132 | else: 133 | x, y = _hash2xy(code_int, dim) 134 | 135 | lng, lat = _int2coord(x, y, dim) 136 | lng_err, lat_err = _lvl_error(level) # level of hilbert curve is bits / 2 137 | 138 | return lng + lng_err, lat + lat_err, lng_err, lat_err 139 | 140 | 141 | def _lvl_error(level: int) -> tuple[float, float]: 142 | """Get the lng/lat error for the hilbert curve with the given level 143 | 144 | On every level, the error of the hilbert curve is halved, e.g. 145 | - level 0 has lng error of +-180 (only one coding point is available: (0, 0)) 146 | - on level 1, there are 4 coding points: (-90, -45), (90, -45), (-90, 45), (90, 45) 147 | hence the lng error is +-90 148 | 149 | Parameters: 150 | level: int Level of the used hilbert curve 151 | 152 | Returns: 153 | Tuple[float, float]: (lng-error, lat-error) for the given level 154 | """ 155 | error = 1 / (1 << level) 156 | return 180 * error, 90 * error 157 | 158 | 159 | def _coord2int(lng: float, lat: float, dim: int) -> tuple[int, int]: 160 | """Convert lon, lat values into a dim x dim-grid coordinate system. 161 | 162 | Parameters: 163 | lng: float Longitude value of coordinate (-180.0, 180.0); corresponds to X axis 164 | lat: float Latitude value of coordinate (-90.0, 90.0); corresponds to Y axis 165 | dim: int Number of coding points each x, y value can take. 166 | Corresponds to 2^level of the hilbert curve. 167 | 168 | Returns: 169 | Tuple[int, int]: 170 | Lower left corner of corresponding dim x dim-grid box 171 | x x value of point [0, dim); corresponds to longitude 172 | y y value of point [0, dim); corresponds to latitude 173 | """ 174 | assert dim >= 1 175 | 176 | lat_y = (lat + _LAT_INTERVAL[1]) / 180.0 * dim # [0 ... dim) 177 | lng_x = (lng + _LNG_INTERVAL[1]) / 360.0 * dim # [0 ... dim) 178 | 179 | return min(dim - 1, int(floor(lng_x))), min(dim - 1, int(floor(lat_y))) 180 | 181 | 182 | def _int2coord(x: int, y: int, dim: int) -> tuple[float, float]: 183 | """Convert x, y values in dim x dim-grid coordinate system into lng, lat values. 184 | 185 | Parameters: 186 | x: int x value of point [0, dim); corresponds to longitude 187 | y: int y value of point [0, dim); corresponds to latitude 188 | dim: int Number of coding points each x, y value can take. 189 | Corresponds to 2^level of the hilbert curve. 190 | 191 | Returns: 192 | Tuple[float, float]: (lng, lat) 193 | lng longitude value of coordinate [-180.0, 180.0]; corresponds to X axis 194 | lat latitude value of coordinate [-90.0, 90.0]; corresponds to Y axis 195 | """ 196 | assert dim >= 1 197 | assert x < dim 198 | assert y < dim 199 | 200 | lng = x / dim * 360 - 180 201 | lat = y / dim * 180 - 90 202 | 203 | return lng, lat 204 | 205 | 206 | # only use python versions, when cython is not available 207 | def _xy2hash(x: int, y: int, dim: int) -> int: 208 | """Convert (x, y) to hashcode. 209 | 210 | Based on the implementation here: 211 | https://en.wikipedia.org/w/index.php?title=Hilbert_curve&oldid=797332503 212 | 213 | Pure python implementation. 214 | 215 | Parameters: 216 | x: int x value of point [0, dim) in dim x dim coord system 217 | y: int y value of point [0, dim) in dim x dim coord system 218 | dim: int Number of coding points each x, y value can take. 219 | Corresponds to 2^level of the hilbert curve. 220 | 221 | Returns: 222 | int: hashcode ∈ [0, dim**2) 223 | """ 224 | d = 0 225 | lvl = dim >> 1 226 | while lvl > 0: 227 | rx = int((x & lvl) > 0) 228 | ry = int((y & lvl) > 0) 229 | d += lvl * lvl * ((3 * rx) ^ ry) 230 | x, y = _rotate(lvl, x, y, rx, ry) 231 | lvl >>= 1 232 | return d 233 | 234 | 235 | def _hash2xy(hashcode: int, dim: int) -> tuple[int, int]: 236 | """Convert hashcode to (x, y). 237 | 238 | Based on the implementation here: 239 | https://en.wikipedia.org/w/index.php?title=Hilbert_curve&oldid=797332503 240 | 241 | Pure python implementation. 242 | 243 | Parameters: 244 | hashcode: int Hashcode to decode [0, dim**2) 245 | dim: int Number of coding points each x, y value can take. 246 | Corresponds to 2^level of the hilbert curve. 247 | 248 | Returns: 249 | Tuple[int, int]: (x, y) point in dim x dim-grid system 250 | """ 251 | assert hashcode <= dim * dim - 1 252 | x = y = 0 253 | lvl = 1 254 | while lvl < dim: 255 | rx = 1 & (hashcode >> 1) 256 | ry = 1 & (hashcode ^ rx) 257 | x, y = _rotate(lvl, x, y, rx, ry) 258 | x += lvl * rx 259 | y += lvl * ry 260 | hashcode >>= 2 261 | lvl <<= 1 262 | return x, y 263 | 264 | 265 | def _rotate(n: int, x: int, y: int, rx: int, ry: int) -> tuple[int, int]: 266 | """Rotate and flip a quadrant appropriately 267 | 268 | Based on the implementation here: 269 | https://en.wikipedia.org/w/index.php?title=Hilbert_curve&oldid=797332503 270 | 271 | """ 272 | if ry == 0: 273 | if rx == 1: 274 | x = n - 1 - x 275 | y = n - 1 - y 276 | return y, x 277 | return x, y 278 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | geohash-hilbert 2 | =============== 3 | 4 | [![CI](https://github.com/tammoippen/geohash-hilbert/actions/workflows/CI.yml/badge.svg?branch=master)](https://github.com/tammoippen/geohash-hilbert/actions/workflows/CI.yml) 5 | [![codecov](https://codecov.io/github/tammoippen/geohash-hilbert/graph/badge.svg?token=APxQWQkPyT)](https://codecov.io/github/tammoippen/geohash-hilbert) 6 | [![Tested CPython Versions](https://img.shields.io/badge/cpython-3.9%2C%203.10%2C%203.11%2C%203.12%2C%203.13-brightgreen.svg)](https://img.shields.io/badge/cpython-3.9%2C%203.10%2C%203.11%2C%203.12%2C%203.13-brightgreen.svg) 7 | [![Tested PyPy Versions](https://img.shields.io/badge/pypy-3.9%2C%203.10-brightgreen.svg)](https://img.shields.io/badge/pypy-3.9%2C%203.10%2C%203.10-brightgreen.svg) 8 | [![PyPi version](https://img.shields.io/pypi/v/geohash-hilbert.svg)](https://pypi.python.org/pypi/geohash-hilbert) 9 | [![PyPi license](https://img.shields.io/pypi/l/geohash-hilbert.svg)](https://pypi.python.org/pypi/geohash-hilbert) 10 | 11 | Geohash a lng/lat coordinate using hilbert space filling curves. 12 | 13 | ```python 14 | In [1]: import geohash_hilbert as ghh 15 | 16 | In [2]: ghh.encode(6.957036, 50.941291) 17 | Out[2]: 'Z7fe2GaIVO' 18 | 19 | In [3]: ghh.decode('Z7fe2GaIVO') 20 | Out[3]: (6.957036126405001, 50.941291032359004) 21 | 22 | In [4]: ghh.decode_exactly('Z7fe2GaIVO') 23 | Out[4]: 24 | (6.957036126405001, 50.941291032359004, # position 25 | 1.6763806343078613e-07, 8.381903171539307e-08) # errors 26 | 27 | In [5]: ghh.encode? 28 | Signature: 29 | ghh.encode( 30 | lng: float, 31 | lat: float, 32 | precision: int = 10, 33 | bits_per_char: Literal[2, 4, 6] = 6, 34 | ) -> str 35 | Docstring: 36 | Encode a lng/lat position as a geohash using a hilbert curve 37 | 38 | This function encodes a lng/lat coordinate to a geohash of length `precision` 39 | on a corresponding a hilbert curve. Each character encodes `bits_per_char` bits 40 | per character (allowed are 2, 4 and 6 bits [default 6]). Hence, the geohash encodes 41 | the lng/lat coordinate using `precision` * `bits_per_char` bits. The number of 42 | bits devided by 2 give the level of the used hilbert curve, e.g. precision=10, bits_per_char=6 43 | (default values) use 60 bit and a level 30 hilbert curve to map the globe. 44 | 45 | Parameters: 46 | lng: float Longitude; between -180.0 and 180.0; WGS 84 47 | lat: float Latitude; between -90.0 and 90.0; WGS 84 48 | precision: int The number of characters in a geohash 49 | bits_per_char: int The number of bits per coding character 50 | 51 | Returns: 52 | str: geohash for lng/lat of length `precision` 53 | File: .../geohash_hilbert/_hilbert.py 54 | Type: function 55 | 56 | 57 | In [7]: ghh.decode? 58 | Signature: ghh.decode(code: str, bits_per_char: Literal[2, 4, 6] = 6) -> tuple[float, float] 59 | Docstring: 60 | Decode a geohash on a hilbert curve as a lng/lat position 61 | 62 | Decodes the geohash `code` as a lng/lat position. It assumes, that 63 | the length of `code` corresponds to the precision! And that each character 64 | in `code` encodes `bits_per_char` bits. Do not mix geohashes with different 65 | `bits_per_char`! 66 | 67 | Parameters: 68 | code: str The geohash to decode. 69 | bits_per_char: int The number of bits per coding character 70 | 71 | Returns: 72 | Tuple[float, float]: (lng, lat) coordinate for the geohash. 73 | File: ~/repos/geohash-hilbert/geohash_hilbert/_hilbert.py 74 | Type: function 75 | 76 | 77 | In [8]: ghh.decode_exactly? 78 | Signature: ghh.decode_exactly(code: str, bits_per_char: Literal[2, 4, 6] = 6) -> tuple[float, float, float, float] 79 | Docstring: 80 | Decode a geohash on a hilbert curve as a lng/lat position with error-margins 81 | 82 | Decodes the geohash `code` as a lng/lat position with error-margins. It assumes, 83 | that the length of `code` corresponds to the precision! And that each character 84 | in `code` encodes `bits_per_char` bits. Do not mix geohashes with different 85 | `bits_per_char`! 86 | 87 | Parameters: 88 | code: str The geohash to decode. 89 | bits_per_char: int The number of bits per coding character 90 | 91 | Returns: 92 | Tuple[float, float, float, float]: (lng, lat, lng-error, lat-error) coordinate for the geohash. 93 | File: ~/repos/geohash-hilbert/geohash_hilbert/_hilbert.py 94 | Type: function 95 | ``` 96 | 97 | Compare to original [geohash](https://github.com/vinsci/geohash/) 98 | ------------------------------------------------------------------- 99 | 100 | This package is similar to the [geohash](https://github.com/vinsci/geohash/) or the [geohash2](https://github.com/dbarthe/geohash/) package, as it also provides a mechanism to encode (and decode) a longitude/latitude position (WGS 84) to a one dimensional [geohash](https://en.wikipedia.org/wiki/Geohash). But, where the former use [z-order](https://en.wikipedia.org/wiki/Z-order_curve) space filling curves, this package uses [hilbert](https://en.wikipedia.org/wiki/Hilbert_curve) curves. (The kernel for this package was adapted from [wiki](https://en.wikipedia.org/wiki/Hilbert_curve)). 101 | 102 | **Note**: The parameter (and returns) order changed from lat/lng in `geohash` to lng/lat. Apart from that this package is a drop-in replacement for the original `geohash`. 103 | 104 | Further, the string representation is changed (and modifieable) to compensate for the special requirements of the implementation: `geohash` uses a modified base32 representation, i.e. every character in the geohash encodes 5 bits. Even bits encode longitude and odd bits encode latitude. Every two full bits encode for one level of the z-order curve, e.g. the default precision of 12 use `12*5 = 60bit` to encode one latitude / longitude position using a level 30 z-order curve. The implementation also allows for 'half'-levels, e.g. precision 11 use `11*5 = 55bit` corresponds to a level 27.5 z-order curve. 105 | 106 | Geohash representation details 107 | ------------------------------ 108 | 109 | This implementation of the hilbert curve allows only full levels, hence we have 110 | support for base4 (2bit), base16 (4bit) and a custom base64 (6bit, the default) 111 | geohash representations. 112 | All keep the same ordering as their integer value by lexicographical order: 113 | 114 | - base4: each character is in `'0123'` 115 | - base16: each character is in `'0123456789abcdef'` 116 | - base64: each character is in `'0123456789@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz'` 117 | 118 | **Note**: Do not mix geohashes from the original `geohash` and this, and do not mix base4, base16 and base64 geohash representations. Decide for one representation and then stick to it. 119 | 120 | The different encodings also give a more fine grain control of the actual encoding precision and the geohash size (maximum lng/lat error around equator): 121 | 122 | ``` 123 | lvl | bits | error | base4 | base16 | base64 124 | ------------------------------------------------------------- 125 | 0 | 0 | 20015.087 km | prec 0 | prec 0 | prec 0 126 | 1 | 2 | 10007.543 km | prec 1 | | 127 | 2 | 4 | 5003.772 km | prec 2 | prec 1 | 128 | 3 | 6 | 2501.886 km | prec 3 | | prec 1 129 | 4 | 8 | 1250.943 km | prec 4 | prec 2 | 130 | 5 | 10 | 625.471 km | prec 5 | | 131 | 6 | 12 | 312.736 km | prec 6 | prec 3 | prec 2 132 | 7 | 14 | 156.368 km | prec 7 | | 133 | 8 | 16 | 78.184 km | prec 8 | prec 4 | 134 | 9 | 18 | 39.092 km | prec 9 | | prec 3 135 | 10 | 20 | 19.546 km | prec 10 | prec 5 | 136 | 11 | 22 | 9772.992 m | prec 11 | | 137 | 12 | 24 | 4886.496 m | prec 12 | prec 6 | prec 4 138 | 13 | 26 | 2443.248 m | prec 13 | | 139 | 14 | 28 | 1221.624 m | prec 14 | prec 7 | 140 | 15 | 30 | 610.812 m | prec 15 | | prec 5 141 | 16 | 32 | 305.406 m | prec 16 | prec 8 | 142 | 17 | 34 | 152.703 m | prec 17 | | 143 | 18 | 36 | 76.351 m | prec 18 | prec 9 | prec 6 144 | 19 | 38 | 38.176 m | prec 19 | | 145 | 20 | 40 | 19.088 m | prec 20 | prec 10 | 146 | 21 | 42 | 954.394 cm | prec 21 | | prec 7 147 | 22 | 44 | 477.197 cm | prec 22 | prec 11 | 148 | 23 | 46 | 238.598 cm | prec 23 | | 149 | 24 | 48 | 119.299 cm | prec 24 | prec 12 | prec 8 150 | 25 | 50 | 59.650 cm | prec 25 | | 151 | 26 | 52 | 29.825 cm | prec 26 | prec 13 | 152 | 27 | 54 | 14.912 cm | prec 27 | | prec 9 153 | 28 | 56 | 7.456 cm | prec 28 | prec 14 | 154 | 29 | 58 | 3.728 cm | prec 29 | | 155 | 30 | 60 | 1.864 cm | prec 30 | prec 15 | prec 10 156 | 31 | 62 | 0.932 cm | prec 31 | | 157 | 32 | 64 | 0.466 cm | prec 32 | prec 16 | 158 | ------------------------------------------------------------- 159 | ``` 160 | 161 | Further features 162 | ---------------- 163 | 164 | If cython is available during install, the cython kernel extension will be installed and used for geohash computations with 64bit or less (timings for MBP 2016, 2.6 GHz Intel Core i7, Python 3.6.2, Cython 0.26.1): 165 | 166 | ```python 167 | In [1]: import geohash_hilbert as ghh 168 | # Without cython ... 169 | In [2]: ghh._hilbert.CYTHON_AVAILABLE 170 | Out[2]: False 171 | 172 | In [3]: %timeit ghh.encode(6.957036, 50.941291, precision=10) 173 | 39.4 µs ± 614 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each) 174 | 175 | In [4]: %timeit ghh.encode(6.957036, 50.941291, precision=11) 176 | 43.4 µs ± 421 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each) 177 | ``` 178 | 179 | ```python 180 | In [1]: import geohash_hilbert as ghh 181 | # With cython ... 182 | In [2]: ghh._hilbert.CYTHON_AVAILABLE 183 | Out[2]: True 184 | # almost 6x faster 185 | In [3]: %timeit ghh.encode(6.957036, 50.941291, precision=10) 186 | 6.72 µs ± 57.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each) 187 | # more than 64bit will be computed with pure python function. 188 | In [4]: %timeit ghh.encode(6.957036, 50.941291, precision=11) 189 | 43.4 µs ± 375 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each) 190 | ``` 191 | 192 | Get the actual rectangle that is encoded by a geohash, i.e. position +- errors: 193 | 194 | ```python 195 | # returns a geojson Feature encoding the rectangle as a Polygon 196 | In [9]: ghh.rectangle('Z7fe2G') 197 | Out[9]: 198 | {'bbox': (6.955718994140625, 199 | 50.94085693359375, 200 | 6.95709228515625, 201 | 50.94154357910156), 202 | 'geometry': {'coordinates': [[(6.955718994140625, 50.94085693359375), 203 | (6.955718994140625, 50.94154357910156), 204 | (6.95709228515625, 50.94154357910156), 205 | (6.95709228515625, 50.94085693359375), 206 | (6.955718994140625, 50.94085693359375)]], 207 | 'type': 'Polygon'}, 208 | 'properties': {'bits_per_char': 6, 209 | 'code': 'Z7fe2G', 210 | 'lat': 50.941200256347656, 211 | 'lat_err': 0.00034332275390625, 212 | 'lng': 6.9564056396484375, 213 | 'lng_err': 0.0006866455078125}, 214 | 'type': 'Feature'} 215 | ``` 216 | 217 | [![](https://github.com/tammoippen/geohash-hilbert/raw/master/img/rectangle.png)](http://geojson.io/#data=data:application/json,%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2G%22%2C%22lng%22%3A6.9564056396484375%2C%22lat%22%3A50.941200256347656%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.955718994140625%2C50.94085693359375%2C6.95709228515625%2C50.94154357910156%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.955718994140625%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94085693359375%5D%5D%5D%7D%7D) 218 | 219 | Get the neighbouring geohashes: 220 | 221 | ```python 222 | In [10]: ghh.neighbours('Z7fe2G') 223 | Out[10]: 224 | {'east': 'Z7fe2T', 225 | 'north': 'Z7fe2H', 226 | 'north-east': 'Z7fe2S', 227 | 'north-west': 'Z7fe2I', 228 | 'south': 'Z7fe2B', 229 | 'south-east': 'Z7fe2A', 230 | 'south-west': 'Z7fe2E', 231 | 'west': 'Z7fe2F'} 232 | ``` 233 | [![](https://github.com/tammoippen/geohash-hilbert/raw/master/img/neighbors.png)](http://geojson.io/#data=data:application/json,%5B%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2H%22%2C%22lng%22%3A6.9564056396484375%2C%22lat%22%3A50.94188690185547%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.955718994140625%2C50.94154357910156%2C6.95709228515625%2C50.942230224609375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.955718994140625%2C50.94154357910156%5D%2C%5B6.955718994140625%2C50.942230224609375%5D%2C%5B6.95709228515625%2C50.942230224609375%5D%2C%5B6.95709228515625%2C50.94154357910156%5D%2C%5B6.955718994140625%2C50.94154357910156%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2S%22%2C%22lng%22%3A6.9577789306640625%2C%22lat%22%3A50.94188690185547%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.95709228515625%2C50.94154357910156%2C6.958465576171875%2C50.942230224609375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.95709228515625%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.942230224609375%5D%2C%5B6.958465576171875%2C50.942230224609375%5D%2C%5B6.958465576171875%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.94154357910156%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2I%22%2C%22lng%22%3A6.9550323486328125%2C%22lat%22%3A50.94188690185547%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.954345703125%2C50.94154357910156%2C6.955718994140625%2C50.942230224609375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.954345703125%2C50.94154357910156%5D%2C%5B6.954345703125%2C50.942230224609375%5D%2C%5B6.955718994140625%2C50.942230224609375%5D%2C%5B6.955718994140625%2C50.94154357910156%5D%2C%5B6.954345703125%2C50.94154357910156%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2T%22%2C%22lng%22%3A6.9577789306640625%2C%22lat%22%3A50.941200256347656%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.95709228515625%2C50.94085693359375%2C6.958465576171875%2C50.94154357910156%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.95709228515625%2C50.94085693359375%5D%2C%5B6.95709228515625%2C50.94154357910156%5D%2C%5B6.958465576171875%2C50.94154357910156%5D%2C%5B6.958465576171875%2C50.94085693359375%5D%2C%5B6.95709228515625%2C50.94085693359375%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2F%22%2C%22lng%22%3A6.9550323486328125%2C%22lat%22%3A50.941200256347656%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.954345703125%2C50.94085693359375%2C6.955718994140625%2C50.94154357910156%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.954345703125%2C50.94085693359375%5D%2C%5B6.954345703125%2C50.94154357910156%5D%2C%5B6.955718994140625%2C50.94154357910156%5D%2C%5B6.955718994140625%2C50.94085693359375%5D%2C%5B6.954345703125%2C50.94085693359375%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2B%22%2C%22lng%22%3A6.9564056396484375%2C%22lat%22%3A50.940513610839844%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.955718994140625%2C50.94017028808594%2C6.95709228515625%2C50.94085693359375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.955718994140625%2C50.94017028808594%5D%2C%5B6.955718994140625%2C50.94085693359375%5D%2C%5B6.95709228515625%2C50.94085693359375%5D%2C%5B6.95709228515625%2C50.94017028808594%5D%2C%5B6.955718994140625%2C50.94017028808594%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2A%22%2C%22lng%22%3A6.9577789306640625%2C%22lat%22%3A50.940513610839844%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.95709228515625%2C50.94017028808594%2C6.958465576171875%2C50.94085693359375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.95709228515625%2C50.94017028808594%5D%2C%5B6.95709228515625%2C50.94085693359375%5D%2C%5B6.958465576171875%2C50.94085693359375%5D%2C%5B6.958465576171875%2C50.94017028808594%5D%2C%5B6.95709228515625%2C50.94017028808594%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2E%22%2C%22lng%22%3A6.9550323486328125%2C%22lat%22%3A50.940513610839844%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.954345703125%2C50.94017028808594%2C6.955718994140625%2C50.94085693359375%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.954345703125%2C50.94017028808594%5D%2C%5B6.954345703125%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94017028808594%5D%2C%5B6.954345703125%2C50.94017028808594%5D%5D%5D%7D%7D%2C%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%22code%22%3A%22Z7fe2G%22%2C%22lng%22%3A6.9564056396484375%2C%22lat%22%3A50.941200256347656%2C%22lng_err%22%3A0.0006866455078125%2C%22lat_err%22%3A0.00034332275390625%2C%22bits_per_char%22%3A6%7D%2C%22bbox%22%3A%5B6.955718994140625%2C50.94085693359375%2C6.95709228515625%2C50.94154357910156%5D%2C%22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B6.955718994140625%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.94154357910156%5D%2C%5B6.95709228515625%2C50.94085693359375%5D%2C%5B6.955718994140625%2C50.94085693359375%5D%5D%5D%7D%7D%5D) 234 | 235 | Plot the Hilbert curve: 236 | 237 | ```python 238 | # returns a geojson Feature encoding the Hilbert curve as a LineString 239 | In [11]: ghh.hilbert_curve(1) # this is a level 3 Hilbert curve: 240 | # 1 char * 6 bits/char = 6 bits => level 3 241 | Out[11]: 242 | {'geometry': {'coordinates': [(-157.5, -78.75), 243 | (-157.5, -56.25), (-112.5, -56.25), (-112.5, -78.75), (-67.5, -78.75), (-22.5, -78.75), 244 | (-22.5, -56.25), (-67.5, -56.25), (-67.5, -33.75), (-22.5, -33.75), (-22.5, -11.25), 245 | (-67.5, -11.25), (-112.5, -11.25), (-112.5, -33.75), (-157.5, -33.75), (-157.5, -11.25), 246 | (-157.5, 11.25), (-112.5, 11.25), (-112.5, 33.75), (-157.5, 33.75), (-157.5, 56.25), 247 | (-157.5, 78.75), (-112.5, 78.75), (-112.5, 56.25), (-67.5, 56.25), (-67.5, 78.75), 248 | (-22.5, 78.75), (-22.5, 56.25), (-22.5, 33.75), (-67.5, 33.75), (-67.5, 11.25), 249 | (-22.5, 11.25), (22.5, 11.25), (67.5, 11.25), (67.5, 33.75), (22.5, 33.75), (22.5, 56.25), 250 | (22.5, 78.75), (67.5, 78.75), (67.5, 56.25), (112.5, 56.25), (112.5, 78.75), (157.5, 78.75), 251 | (157.5, 56.25), (157.5, 33.75), (112.5, 33.75), (112.5, 11.25), (157.5, 11.25), (157.5, -11.25), 252 | (157.5, -33.75), (112.5, -33.75), (112.5, -11.25), (67.5, -11.25), (22.5, -11.25), 253 | (22.5, -33.75), (67.5, -33.75), (67.5, -56.25), (22.5, -56.25), (22.5, -78.75), 254 | (67.5, -78.75), (112.5, -78.75), (112.5, -56.25), (157.5, -56.25), (157.5, -78.75)], 255 | 'type': 'LineString'}, 256 | 'properties': {}, 257 | 'type': 'Feature'} 258 | ``` 259 | [![](https://github.com/tammoippen/geohash-hilbert/raw/master/img/hilbert.png)](http://geojson.io/#data=data:application/json,%7B%22type%22%3A%22Feature%22%2C%22properties%22%3A%7B%7D%2C%22geometry%22%3A%7B%22type%22%3A%22LineString%22%2C%22coordinates%22%3A%5B%5B-157.5%2C-78.75%5D%2C%5B-157.5%2C-56.25%5D%2C%5B-112.5%2C-56.25%5D%2C%5B-112.5%2C-78.75%5D%2C%5B-67.5%2C-78.75%5D%2C%5B-22.5%2C-78.75%5D%2C%5B-22.5%2C-56.25%5D%2C%5B-67.5%2C-56.25%5D%2C%5B-67.5%2C-33.75%5D%2C%5B-22.5%2C-33.75%5D%2C%5B-22.5%2C-11.25%5D%2C%5B-67.5%2C-11.25%5D%2C%5B-112.5%2C-11.25%5D%2C%5B-112.5%2C-33.75%5D%2C%5B-157.5%2C-33.75%5D%2C%5B-157.5%2C-11.25%5D%2C%5B-157.5%2C11.25%5D%2C%5B-112.5%2C11.25%5D%2C%5B-112.5%2C33.75%5D%2C%5B-157.5%2C33.75%5D%2C%5B-157.5%2C56.25%5D%2C%5B-157.5%2C78.75%5D%2C%5B-112.5%2C78.75%5D%2C%5B-112.5%2C56.25%5D%2C%5B-67.5%2C56.25%5D%2C%5B-67.5%2C78.75%5D%2C%5B-22.5%2C78.75%5D%2C%5B-22.5%2C56.25%5D%2C%5B-22.5%2C33.75%5D%2C%5B-67.5%2C33.75%5D%2C%5B-67.5%2C11.25%5D%2C%5B-22.5%2C11.25%5D%2C%5B22.5%2C11.25%5D%2C%5B67.5%2C11.25%5D%2C%5B67.5%2C33.75%5D%2C%5B22.5%2C33.75%5D%2C%5B22.5%2C56.25%5D%2C%5B22.5%2C78.75%5D%2C%5B67.5%2C78.75%5D%2C%5B67.5%2C56.25%5D%2C%5B112.5%2C56.25%5D%2C%5B112.5%2C78.75%5D%2C%5B157.5%2C78.75%5D%2C%5B157.5%2C56.25%5D%2C%5B157.5%2C33.75%5D%2C%5B112.5%2C33.75%5D%2C%5B112.5%2C11.25%5D%2C%5B157.5%2C11.25%5D%2C%5B157.5%2C-11.25%5D%2C%5B157.5%2C-33.75%5D%2C%5B112.5%2C-33.75%5D%2C%5B112.5%2C-11.25%5D%2C%5B67.5%2C-11.25%5D%2C%5B22.5%2C-11.25%5D%2C%5B22.5%2C-33.75%5D%2C%5B67.5%2C-33.75%5D%2C%5B67.5%2C-56.25%5D%2C%5B22.5%2C-56.25%5D%2C%5B22.5%2C-78.75%5D%2C%5B67.5%2C-78.75%5D%2C%5B112.5%2C-78.75%5D%2C%5B112.5%2C-56.25%5D%2C%5B157.5%2C-56.25%5D%2C%5B157.5%2C-78.75%5D%5D%7D%7D) 260 | --------------------------------------------------------------------------------