├── .codespellrc ├── .coveragerc ├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── build-conda.sh ├── docs ├── Makefile ├── build-docs.sh ├── make.bat ├── requirements.txt └── source │ ├── _static │ ├── logo-small.png │ └── logo.png │ ├── conf.py │ ├── h3pandas.rst │ ├── index.rst │ ├── installation.rst │ └── notebook │ ├── 00-intro.ipynb │ └── 01-unified-data-layers.ipynb ├── environment-dev.yml ├── environment.yml ├── h3pandas ├── __init__.py ├── const.py ├── h3pandas.py └── util │ ├── __init__.py │ ├── decorator.py │ ├── functools.py │ └── shapely.py ├── meta-backup.yaml ├── notebook ├── 00-intro.ipynb └── 01-unified-data-layers.ipynb ├── pyproject.toml ├── run-tests.sh └── tests ├── __init__.py ├── test_h3pandas.py └── util ├── __init__.py ├── test_decorator.py └── test_shapely.py /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | skip = *.ipynb 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = h3_pandas 4 | omit = 5 | setup.py 6 | h3pandas/_version.py 7 | 8 | [report] 9 | 10 | [html] 11 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | h3pandas/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # What this PR does 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Set up Conda 16 | uses: conda-incubator/setup-miniconda@v2 17 | with: 18 | auto-update-conda: true 19 | activate-environment: myenv 20 | environment-file: environment.yml 21 | 22 | - name: Install development dependencies 23 | shell: bash -l {0} 24 | run: | 25 | conda env update --file environment-dev.yml 26 | 27 | - name: ruff check 28 | shell: bash -l {0} 29 | run: | 30 | ruff check . 31 | 32 | - name: pytest 33 | shell: bash -l {0} 34 | run: | 35 | pytest tests/ 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/pycharm,python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=pycharm,python 3 | 4 | .idea/ 5 | 6 | ### Python ### 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | pytestdebug.log 60 | 61 | # Translations 62 | *.mo 63 | *.pot 64 | 65 | # Django stuff: 66 | *.log 67 | local_settings.py 68 | db.sqlite3 69 | db.sqlite3-journal 70 | 71 | # Flask stuff: 72 | instance/ 73 | .webassets-cache 74 | 75 | # Scrapy stuff: 76 | .scrapy 77 | 78 | # Sphinx documentation 79 | docs/_build/ 80 | doc/_build/ 81 | 82 | # PyBuilder 83 | target/ 84 | 85 | # Jupyter Notebook 86 | .ipynb_checkpoints 87 | 88 | # IPython 89 | profile_default/ 90 | ipython_config.py 91 | 92 | # pyenv 93 | .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 103 | __pypackages__/ 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | pythonenv* 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # pytype static type analyzer 134 | .pytype/ 135 | 136 | # profiling data 137 | .prof 138 | 139 | # End of https://www.toptal.com/developers/gitignore/api/pycharm,python 140 | 141 | private 142 | .vscode 143 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.9.9 4 | hooks: 5 | - id: ruff 6 | - id: ruff-format 7 | 8 | - repo: https://github.com/codespell-project/codespell 9 | rev: v2.4.1 10 | hooks: 11 | - id: codespell 12 | args: ['--config', '.codespellrc'] 13 | language_version: python3 14 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # File: .readthedocs.yaml 2 | 3 | version: 2 4 | 5 | # Build from the docs/ directory with Sphinx 6 | sphinx: 7 | configuration: docs/source/conf.py 8 | 9 | # Explicitly set the version of Python and its requirements 10 | python: 11 | version: 3.7 12 | install: 13 | - requirements: docs/requirements.txt 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dahn 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | graft h3pandas 4 | recursive-include * *.py[co] 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ENV_NAME := h3-pandas 2 | ENVIRONMENT := environment.yml 3 | ENVIRONMENT_DEV := environment-dev.yml 4 | 5 | install: _install _update_dev _install_package_editable 6 | 7 | _install: 8 | mamba env create -n $(ENV_NAME) -f $(ENVIRONMENT) 9 | 10 | _update_dev: 11 | mamba env update -n $(ENV_NAME) -f $(ENVIRONMENT_DEV) 12 | 13 | _install_package_editable: 14 | mamba run -n $(ENV_NAME) python -m pip install -e . 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | H3 Logo 2 | 3 | 4 |   5 | 6 | # H3-Pandas ⬢ 🐼 7 | Integrates [H3](https://github.com/uber/h3-py) with [GeoPandas](https://github.com/geopandas/geopandas) 8 | and [Pandas](https://github.com/pandas-dev/pandas). 9 | [![image](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/DahnJ/H3-Pandas/blob/master/notebook/00-intro.ipynb) 10 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/DahnJ/H3-Pandas/HEAD?filepath=%2Fnotebook%2F00-intro.ipynb) 11 | [![image](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 12 | [![Documentation Status](https://readthedocs.org/projects/pip/badge/?version=stable)](https://pip.pypa.io/en/stable/?badge=stable) 13 | 14 |   15 | 16 | 17 | --- 18 | 19 |

20 | ⬢ Try it out ⬢ 21 |

22 | 23 | --- 24 |

25 | example usage 26 |

27 | 28 | 29 | ## Installation 30 | ### pip 31 | [![image](https://img.shields.io/pypi/v/h3pandas.svg)](https://pypi.python.org/pypi/h3pandas) 32 | ```bash 33 | pip install h3pandas 34 | ``` 35 | 36 | ### conda 37 | [![conda-version](https://anaconda.org/conda-forge/h3pandas/badges/version.svg)]() 38 | [![Anaconda-Server Badge](https://anaconda.org/conda-forge/h3pandas/badges/downloads.svg)](https://anaconda.org/conda-forge/h3pandas) 39 | ```bash 40 | conda install -c conda-forge h3pandas 41 | ``` 42 | 43 | ## Usage examples 44 | 45 | ### H3 API 46 | `h3pandas` automatically applies H3 functions to both Pandas Dataframes and GeoPandas Geodataframes 47 | 48 | ```python 49 | # Prepare data 50 | >>> import pandas as pd 51 | >>> import h3pandas 52 | >>> df = pd.DataFrame({'lat': [50, 51], 'lng': [14, 15]}) 53 | ``` 54 | 55 | ```python 56 | >>> resolution = 10 57 | >>> df = df.h3.geo_to_h3(resolution) 58 | >>> df 59 | 60 | | h3_10 | lat | lng | 61 | |:----------------|------:|------:| 62 | | 8a1e30973807fff | 50 | 14 | 63 | | 8a1e2659c2c7fff | 51 | 15 | 64 | 65 | >>> df = df.h3.h3_to_geo_boundary() 66 | >>> df 67 | 68 | | h3_10 | lat | lng | geometry | 69 | |:----------------|------:|------:|:----------------| 70 | | 8a1e30973807fff | 50 | 14 | POLYGON ((...)) | 71 | | 8a1e2659c2c7fff | 51 | 15 | POLYGON ((...)) | 72 | ``` 73 | 74 | ### H3-Pandas Extended API 75 | `h3pandas` also provides some extended functionality out-of-the-box, 76 | often simplifying common workflows into a single command. 77 | 78 | ```python 79 | # Set up data 80 | >>> import numpy as np 81 | >>> import pandas as pd 82 | >>> np.random.seed(1729) 83 | >>> df = pd.DataFrame({ 84 | >>> 'lat': np.random.uniform(50, 51, 100), 85 | >>> 'lng': np.random.uniform(14, 15, 100), 86 | >>> 'value': np.random.poisson(100, 100)}) 87 | >>> }) 88 | ``` 89 | 90 | ```python 91 | # Aggregate values by their location and sum 92 | >>> df = df.h3.geo_to_h3_aggregate(3) 93 | >>> df 94 | 95 | | h3_03 | value | geometry | 96 | |:----------------|--------:|:----------------| 97 | | 831e30fffffffff | 102 | POLYGON ((...)) | 98 | | 831e34fffffffff | 189 | POLYGON ((...)) | 99 | | 831e35fffffffff | 8744 | POLYGON ((...)) | 100 | | 831f1bfffffffff | 1040 | POLYGON ((...)) | 101 | 102 | # Aggregate to a lower H3 resolution 103 | >>> df.h3.h3_to_parent_aggregate(2) 104 | 105 | | h3_02 | value | geometry | 106 | |:----------------|--------:|:----------------| 107 | | 821e37fffffffff | 9035 | POLYGON ((...)) | 108 | | 821f1ffffffffff | 1040 | POLYGON ((...)) | 109 | ``` 110 | 111 | 112 | ### Further examples 113 | For more examples, see the 114 | [example notebooks](https://nbviewer.jupyter.org/github/DahnJ/H3-Pandas/tree/master/notebook/). 115 | 116 | ## API 117 | For a full API documentation and more usage examples, see the 118 | [documentation](https://h3-pandas.readthedocs.io/en/latest/). 119 | 120 | ## Development 121 | H3-Pandas cover the basics of the H3 API, but there are still many possible improvements. 122 | 123 | **Any suggestions and contributions are very welcome**! 124 | 125 | In particular, the next steps are: 126 | - [ ] Improvements & stability of the "Extended API", e.g. `k_ring_smoothing`. 127 | 128 | Additional possible directions 129 | - [ ] Allow for alternate h3-py APIs such as [memview_int](https://github.com/uber/h3-py#h3apimemview_int) 130 | - [ ] Performance improvements through [Cythonized h3-py](https://github.com/uber/h3-py/pull/147) 131 | - [ ] [Dask](https://github.com/dask/dask) integration through [dask-geopandas](https://github.com/geopandas/dask-geopandas) (experimental as of now) 132 | 133 | See [issues](https://github.com/DahnJ/H3-Pandas/issues) for more. 134 | -------------------------------------------------------------------------------- /build-conda.sh: -------------------------------------------------------------------------------- 1 | ##!/bin/bash -e 2 | # Based on script from Qiusheng Wu 3 | 4 | # Variables 5 | pkg='h3pandas' 6 | array=( 3.8 3.9 3.10 3.11) 7 | 8 | # Constants 9 | MINICONDA=$HOME/miniconda3 10 | 11 | #echo "Building conda package ..." 12 | cd $HOME 13 | grayskull pypi $pkg 14 | 15 | # update meta.yaml 16 | echo "Updating meta.yaml ..." 17 | sed -i 's/^\(\s\+- h3\)$/\1-py/g' $pkg/meta.yaml 18 | sed -i 's/^\(\s\+-\) your-github-id-here/\1 DahnJ/g' $pkg/meta.yaml 19 | 20 | # building conda packages 21 | for i in "${array[@]}" 22 | do 23 | echo "Building for Python $i" 24 | conda-build --python $i $pkg 25 | done 26 | 27 | # upload packages to conda 28 | find $MINICONDA/conda-bld/ -name *.tar.bz2 | while read file 29 | do 30 | echo $file 31 | anaconda upload $file 32 | done 33 | echo "Building conda package done!" 34 | 35 | rm $HOME/h3pandas -r 36 | conda build purge-all 37 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = h3pandas 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/build-docs.sh: -------------------------------------------------------------------------------- 1 | # /bin/bash 2 | make clean 3 | cp -r ../notebook/* source/notebook 4 | make html 5 | # rm -r source/notebook 6 | xdg-open build/html/index.html 7 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=h3pandas 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==3.5.4 2 | pydata-sphinx-theme==0.6.3 3 | numpydoc==1.1.0 4 | shapely==1.7.* 5 | h3==3.7.* 6 | geopandas==0.9.* 7 | pandas==1.2.* 8 | typing-extensions==3.10.* 9 | numpy==1.20.* 10 | nbsphinx 11 | -------------------------------------------------------------------------------- /docs/source/_static/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DahnJ/H3-Pandas/4c112ca143203e2e93be1042283ede6c9fe6de0c/docs/source/_static/logo-small.png -------------------------------------------------------------------------------- /docs/source/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DahnJ/H3-Pandas/4c112ca143203e2e93be1042283ede6c9fe6de0c/docs/source/_static/logo.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # h3pandas documentation build configuration file, created by 4 | # sphinx-quickstart on Fri May 21 18:04:15 2021. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | import sys 21 | 22 | sys.path.insert(0, os.path.abspath("../../")) 23 | 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | # 29 | # needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.coverage", 37 | "sphinx.ext.viewcode", 38 | "numpydoc", 39 | "nbsphinx", 40 | ] 41 | 42 | # numpydoc_show_class_members = False 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # The suffix(es) of source filenames. 48 | # You can specify multiple suffix as a list of string: 49 | # 50 | # source_suffix = ['.rst', '.md'] 51 | source_suffix = ".rst" 52 | 53 | # The master toctree document. 54 | master_doc = "index" 55 | 56 | # General information about the project. 57 | project = u"h3pandas" 58 | copyright = u"2021, Dahn" 59 | author = u"Dahn" 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = u"0.1" 67 | # The full version, including alpha/beta/rc tags. 68 | release = u"0.1" 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = None 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | # This patterns also effect to html_static_path and html_extra_path 80 | exclude_patterns = [] 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = "sphinx" 84 | 85 | # If true, `todo` and `todoList` produce output, else they produce nothing. 86 | todo_include_todos = False 87 | 88 | 89 | # -- Options for HTML output ---------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | # 94 | html_theme = "pydata_sphinx_theme" 95 | html_logo = "_static/logo-small.png" 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # 101 | # html_theme_options = { 102 | # "sidebar_hide_name": False, 103 | # } 104 | 105 | # Add any paths that contain custom static files (such as style sheets) here, 106 | # relative to this directory. They are copied after the builtin static files, 107 | # so a file named "default.css" will overwrite the builtin "default.css". 108 | html_static_path = ["_static"] 109 | 110 | # Custom sidebar templates, must be a dictionary that maps document names 111 | # to template names. 112 | # 113 | # This is required for the alabaster theme 114 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 115 | html_sidebars = { 116 | "**": [ 117 | "globaltoc.html", 118 | "relations.html", # needs 'show_related': True theme option to display 119 | "searchbox.html", 120 | ] 121 | } 122 | 123 | 124 | # -- Options for HTMLHelp output ------------------------------------------ 125 | 126 | # Output file base name for HTML help builder. 127 | htmlhelp_basename = "h3pandasdoc" 128 | 129 | 130 | # -- Options for LaTeX output --------------------------------------------- 131 | 132 | latex_elements = { 133 | # The paper size ('letterpaper' or 'a4paper'). 134 | # 135 | # 'papersize': 'letterpaper', 136 | # The font size ('10pt', '11pt' or '12pt'). 137 | # 138 | # 'pointsize': '10pt', 139 | # Additional stuff for the LaTeX preamble. 140 | # 141 | # 'preamble': '', 142 | # Latex figure (float) alignment 143 | # 144 | # 'figure_align': 'htbp', 145 | } 146 | 147 | # Grouping the document tree into LaTeX files. List of tuples 148 | # (source start file, target name, title, 149 | # author, documentclass [howto, manual, or own class]). 150 | latex_documents = [ 151 | (master_doc, "h3pandas.tex", u"h3pandas Documentation", u"Dahn", "manual"), 152 | ] 153 | 154 | 155 | # -- Options for manual page output --------------------------------------- 156 | 157 | # One entry per manual page. List of tuples 158 | # (source start file, name, description, authors, manual section). 159 | man_pages = [(master_doc, "h3pandas", u"h3pandas Documentation", [author], 1)] 160 | 161 | 162 | # -- Options for Texinfo output ------------------------------------------- 163 | 164 | # Grouping the document tree into Texinfo files. List of tuples 165 | # (source start file, target name, title, author, 166 | # dir menu entry, description, category) 167 | texinfo_documents = [ 168 | ( 169 | master_doc, 170 | "h3pandas", 171 | u"h3pandas Documentation", 172 | author, 173 | "h3pandas", 174 | "One line description of project.", 175 | "Miscellaneous", 176 | ), 177 | ] 178 | -------------------------------------------------------------------------------- /docs/source/h3pandas.rst: -------------------------------------------------------------------------------- 1 | .. _h3pandas: 2 | 3 | ========================= 4 | API reference 5 | ========================= 6 | 7 | .. automodule:: h3pandas.h3pandas 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. h3pandas documentation master file, created by 2 | sphinx-quickstart on Fri May 21 18:04:15 2021. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | 8 | ==================================== 9 | H3-Pandas 10 | ==================================== 11 | 12 | .. image:: _static/logo.png 13 | 14 | Welcome to the documentation of H3-Pandas! 15 | 16 | H3-Pandas integrates `H3`_, the hexagonal geospatial indexing system, 17 | with `Pandas`_ and `GeoPandas`_. 18 | 19 | 20 | Contents 21 | -------- 22 | .. toctree:: 23 | :titlesonly: 24 | 25 | installation 26 | h3pandas 27 | Notebook: Introduction 28 | Notebook: Unified Data Layers 29 | 30 | 31 | Example 32 | ------- 33 | 34 | .. image:: https://i.imgur.com/GZWsC8G.gif 35 | :width: 500px 36 | :align: center 37 | 38 | 39 | 40 | .. _H3: https://h3geo.org/ 41 | .. _Pandas: https://pandas.pydata.org/ 42 | .. _GeoPandas: https://geopandas.org/ 43 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | 4 | ============ 5 | Installation 6 | ============ 7 | pip 8 | --- 9 | .. code-block:: bash 10 | 11 | pip install h3pandas 12 | 13 | conda 14 | ----- 15 | .. code-block:: bash 16 | 17 | conda install -c conda-forge h3pandas 18 | 19 | -------------------------------------------------------------------------------- /environment-dev.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | # Test 5 | - ruff 6 | - pytest 7 | - pytest-cov 8 | - setuptools-scm 9 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | # Required 5 | - python>=3.9,<3.12 6 | - shapely 7 | - geopandas>=0.9.* 8 | - pandas 9 | - h3-py>=4 10 | # Notebooks 11 | - matplotlib 12 | # Pip 13 | - pip 14 | -------------------------------------------------------------------------------- /h3pandas/__init__.py: -------------------------------------------------------------------------------- 1 | from . import h3pandas # noqa: F401s 2 | 3 | from importlib.metadata import version, PackageNotFoundError 4 | 5 | try: 6 | __version__ = version("package-name") 7 | except PackageNotFoundError: 8 | # package is not installed 9 | pass 10 | -------------------------------------------------------------------------------- /h3pandas/const.py: -------------------------------------------------------------------------------- 1 | COLUMN_H3_POLYFILL = "h3_polyfill" 2 | COLUMN_H3_LINETRACE = "h3_linetrace" 3 | -------------------------------------------------------------------------------- /h3pandas/h3pandas.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Callable, Sequence, Any 2 | import warnings 3 | 4 | from typing import Literal 5 | 6 | import numpy as np 7 | import shapely 8 | import pandas as pd 9 | import geopandas as gpd 10 | 11 | import h3 12 | from pandas.core.frame import DataFrame 13 | from geopandas.geodataframe import GeoDataFrame 14 | 15 | from .const import COLUMN_H3_POLYFILL, COLUMN_H3_LINETRACE 16 | from .util.decorator import catch_invalid_h3_address, doc_standard 17 | from .util.functools import wrapped_partial 18 | from .util.shapely import cell_to_boundary_lng_lat, polyfill, linetrace, _switch_lat_lng 19 | 20 | AnyDataFrame = Union[DataFrame, GeoDataFrame] 21 | 22 | 23 | @pd.api.extensions.register_dataframe_accessor("h3") 24 | class H3Accessor: 25 | def __init__(self, df: DataFrame): 26 | self._df = df 27 | 28 | # H3 API 29 | # These methods simply mirror the H3 API and apply H3 functions to all rows 30 | 31 | def geo_to_h3( 32 | self, 33 | resolution: int, 34 | lat_col: str = "lat", 35 | lng_col: str = "lng", 36 | set_index: bool = True, 37 | ) -> AnyDataFrame: 38 | """Adds H3 index to (Geo)DataFrame. 39 | 40 | pd.DataFrame: uses `lat_col` and `lng_col` (default `lat` and `lng`) 41 | gpd.GeoDataFrame: uses `geometry` 42 | 43 | Assumes coordinates in epsg=4326. 44 | 45 | Parameters 46 | ---------- 47 | resolution : int 48 | H3 resolution 49 | lat_col : str 50 | Name of the latitude column (if used), default 'lat' 51 | lng_col : str 52 | Name of the longitude column (if used), default 'lng' 53 | set_index : bool 54 | If True, the columns with H3 addresses is set as index, default 'True' 55 | 56 | Returns 57 | ------- 58 | (Geo)DataFrame with H3 addresses added 59 | 60 | See Also 61 | -------- 62 | geo_to_h3_aggregate : Extended API method that aggregates points by H3 address 63 | 64 | Examples 65 | -------- 66 | >>> df = pd.DataFrame({'lat': [50, 51], 'lng':[14, 15]}) 67 | >>> df.h3.geo_to_h3(8) 68 | lat lng 69 | h3_08 70 | 881e309739fffff 50 14 71 | 881e2659c3fffff 51 15 72 | 73 | >>> df.h3.geo_to_h3(8, set_index=False) 74 | lat lng h3_08 75 | 0 50 14 881e309739fffff 76 | 1 51 15 881e2659c3fffff 77 | 78 | >>> gdf = gpd.GeoDataFrame({'val': [5, 1]}, 79 | >>> geometry=gpd.points_from_xy(x=[14, 15], y=(50, 51))) 80 | >>> gdf.h3.geo_to_h3(8) 81 | val geometry 82 | h3_08 83 | 881e309739fffff 5 POINT (14.00000 50.00000) 84 | 881e2659c3fffff 1 POINT (15.00000 51.00000) 85 | 86 | """ 87 | if isinstance(self._df, gpd.GeoDataFrame): 88 | lngs = self._df.geometry.x 89 | lats = self._df.geometry.y 90 | else: 91 | lngs = self._df[lng_col] 92 | lats = self._df[lat_col] 93 | 94 | h3addresses = [ 95 | h3.latlng_to_cell(lat, lng, resolution) for lat, lng in zip(lats, lngs) 96 | ] 97 | 98 | colname = self._format_resolution(resolution) 99 | assign_arg = {colname: h3addresses} 100 | df = self._df.assign(**assign_arg) 101 | if set_index: 102 | return df.set_index(colname) 103 | return df 104 | 105 | def h3_to_geo(self) -> GeoDataFrame: 106 | """Add `geometry` with centroid of each H3 address to the DataFrame. 107 | Assumes H3 index. 108 | 109 | Returns 110 | ------- 111 | GeoDataFrame with Point geometry 112 | 113 | Raises 114 | ------ 115 | ValueError 116 | When an invalid H3 address is encountered 117 | 118 | See Also 119 | -------- 120 | h3_to_geo_boundary : Adds a hexagonal cell 121 | 122 | Examples 123 | -------- 124 | >>> df = pd.DataFrame({'val': [5, 1]}, 125 | >>> index=['881e309739fffff', '881e2659c3fffff']) 126 | >>> df.h3.h3_to_geo() 127 | val geometry 128 | 881e309739fffff 5 POINT (14.00037 50.00055) 129 | 881e2659c3fffff 1 POINT (14.99715 51.00252) 130 | 131 | """ 132 | return self._apply_index_assign( 133 | h3.cell_to_latlng, 134 | "geometry", 135 | lambda x: _switch_lat_lng(shapely.geometry.Point(x)), 136 | lambda x: gpd.GeoDataFrame(x, crs="epsg:4326"), 137 | ) 138 | 139 | def h3_to_geo_boundary(self) -> GeoDataFrame: 140 | """Add `geometry` with H3 hexagons to the DataFrame. Assumes H3 index. 141 | 142 | Returns 143 | ------- 144 | GeoDataFrame with H3 geometry 145 | 146 | Raises 147 | ------ 148 | ValueError 149 | When an invalid H3 address is encountered 150 | 151 | Examples 152 | -------- 153 | >>> df = pd.DataFrame({'val': [5, 1]}, 154 | >>> index=['881e309739fffff', '881e2659c3fffff']) 155 | >>> df.h3.h3_to_geo_boundary() 156 | val geometry 157 | 881e309739fffff 5 POLYGON ((13.99527 50.00368, 13.99310 49.99929... 158 | 881e2659c3fffff 1 POLYGON ((14.99201 51.00565, 14.98973 51.00133... 159 | """ 160 | return self._apply_index_assign( 161 | wrapped_partial(cell_to_boundary_lng_lat), 162 | "geometry", 163 | finalizer=lambda x: gpd.GeoDataFrame(x, crs="epsg:4326"), 164 | ) 165 | 166 | @doc_standard("h3_resolution", "containing the resolution of each H3 address") 167 | def h3_get_resolution(self) -> AnyDataFrame: 168 | """ 169 | Examples 170 | -------- 171 | >>> df = pd.DataFrame({'val': [5, 1]}, 172 | >>> index=['881e309739fffff', '881e2659c3fffff']) 173 | >>> df.h3.h3_get_resolution() 174 | val h3_resolution 175 | 881e309739fffff 5 8 176 | 881e2659c3fffff 1 8 177 | """ 178 | return self._apply_index_assign(h3.get_resolution, "h3_resolution") 179 | 180 | @doc_standard("h3_base_cell", "containing the base cell of each H3 address") 181 | def h3_get_base_cell(self): 182 | """ 183 | Examples 184 | -------- 185 | >>> df = pd.DataFrame({'val': [5, 1]}, 186 | >>> index=['881e309739fffff', '881e2659c3fffff']) 187 | >>> df.h3.h3_get_base_cell() 188 | val h3_base_cell 189 | 881e309739fffff 5 15 190 | 881e2659c3fffff 1 15 191 | """ 192 | return self._apply_index_assign(h3.get_base_cell_number, "h3_base_cell") 193 | 194 | @doc_standard("h3_is_valid", "containing the validity of each H3 address") 195 | def h3_is_valid(self): 196 | """ 197 | Examples 198 | -------- 199 | >>> df = pd.DataFrame({'val': [5, 1]}, index=['881e309739fffff', 'INVALID']) 200 | >>> df.h3.h3_is_valid() 201 | val h3_is_valid 202 | 881e309739fffff 5 True 203 | INVALID 1 False 204 | """ 205 | return self._apply_index_assign(h3.is_valid_cell, "h3_is_valid") 206 | 207 | @doc_standard( 208 | "h3_k_ring", "containing a list H3 addresses within a distance of `k`" 209 | ) 210 | def k_ring(self, k: int = 1, explode: bool = False) -> AnyDataFrame: 211 | """ 212 | Parameters 213 | ---------- 214 | k : int 215 | the distance from the origin H3 address. Default k = 1 216 | explode : bool 217 | If True, will explode the resulting list vertically. 218 | All other columns' values are copied. 219 | Default: False 220 | 221 | See Also 222 | -------- 223 | k_ring_smoothing : Extended API method that distributes numeric values 224 | to the k-ring cells 225 | 226 | Examples 227 | -------- 228 | >>> df = pd.DataFrame({'val': [5, 1]}, 229 | >>> index=['881e309739fffff', '881e2659c3fffff']) 230 | >>> df.h3.k_ring(1) 231 | val h3_k_ring 232 | 881e309739fffff 5 [881e30973dfffff, 881e309703fffff, 881e309707f... 233 | 881e2659c3fffff 1 [881e2659ddfffff, 881e2659c3fffff, 881e2659cbf... 234 | 235 | >>> df.h3.k_ring(1, explode=True) 236 | val h3_k_ring 237 | 881e2659c3fffff 1 881e2659ddfffff 238 | 881e2659c3fffff 1 881e2659c3fffff 239 | 881e2659c3fffff 1 881e2659cbfffff 240 | 881e2659c3fffff 1 881e2659d5fffff 241 | 881e2659c3fffff 1 881e2659c7fffff 242 | 881e2659c3fffff 1 881e265989fffff 243 | 881e2659c3fffff 1 881e2659c1fffff 244 | 881e309739fffff 5 881e30973dfffff 245 | 881e309739fffff 5 881e309703fffff 246 | 881e309739fffff 5 881e309707fffff 247 | 881e309739fffff 5 881e30973bfffff 248 | 881e309739fffff 5 881e309715fffff 249 | 881e309739fffff 5 881e309739fffff 250 | 881e309739fffff 5 881e309731fffff 251 | """ 252 | func = wrapped_partial(h3.grid_disk, k=k) 253 | column_name = "h3_k_ring" 254 | if explode: 255 | return self._apply_index_explode(func, column_name, list) 256 | return self._apply_index_assign(func, column_name, list) 257 | 258 | @doc_standard( 259 | "h3_hex_ring", 260 | "containing a list H3 addresses forming a hollow hexagonal ring" 261 | "at a distance `k`", 262 | ) 263 | def hex_ring(self, k: int = 1, explode: bool = False) -> AnyDataFrame: 264 | """ 265 | Parameters 266 | ---------- 267 | k : int 268 | the distance from the origin H3 address. Default k = 1 269 | explode : bool 270 | If True, will explode the resulting list vertically. 271 | All other columns' values are copied. 272 | Default: False 273 | 274 | Examples 275 | -------- 276 | >>> df = pd.DataFrame({'val': [5, 1]}, 277 | >>> index=['881e309739fffff', '881e2659c3fffff']) 278 | >>> df.h3.hex_ring(1) 279 | val h3_hex_ring 280 | 881e309739fffff 5 [881e30973dfffff, 881e309703fffff, 881e309707f... 281 | 881e2659c3fffff 1 [881e2659ddfffff, 881e2659cbfffff, 881e2659d5f... 282 | >>> df.h3.hex_ring(1, explode=True) 283 | val h3_hex_ring 284 | 881e2659c3fffff 1 881e2659ddfffff 285 | 881e2659c3fffff 1 881e2659cbfffff 286 | 881e2659c3fffff 1 881e2659d5fffff 287 | 881e2659c3fffff 1 881e2659c7fffff 288 | 881e2659c3fffff 1 881e265989fffff 289 | 881e2659c3fffff 1 881e2659c1fffff 290 | 881e309739fffff 5 881e30973dfffff 291 | 881e309739fffff 5 881e309703fffff 292 | 881e309739fffff 5 881e309707fffff 293 | 881e309739fffff 5 881e30973bfffff 294 | 881e309739fffff 5 881e309715fffff 295 | 881e309739fffff 5 881e309731fffff 296 | """ 297 | func = wrapped_partial(h3.grid_ring, k=k) 298 | column_name = "h3_hex_ring" 299 | if explode: 300 | return self._apply_index_explode(func, column_name, list) 301 | return self._apply_index_assign(func, column_name, list) 302 | 303 | @doc_standard("h3_{resolution}", "containing the parent of each H3 address") 304 | def h3_to_parent(self, resolution: int = None) -> AnyDataFrame: 305 | """ 306 | Parameters 307 | ---------- 308 | resolution : int or None 309 | H3 resolution. If None, then returns the direct parent of each H3 cell. 310 | 311 | See Also 312 | -------- 313 | h3_to_parent_aggregate : Extended API method that aggregates cells by their 314 | parent cell 315 | 316 | Examples 317 | -------- 318 | >>> df = pd.DataFrame({'val': [5, 1]}, 319 | >>> index=['881e309739fffff', '881e2659c3fffff']) 320 | >>> df.h3.h3_to_parent(5) 321 | val h3_05 322 | 881e309739fffff 5 851e3097fffffff 323 | 881e2659c3fffff 1 851e265bfffffff 324 | """ 325 | # TODO: Test `h3_parent` case 326 | column = ( 327 | self._format_resolution(resolution) 328 | if resolution is not None 329 | else "h3_parent" 330 | ) 331 | return self._apply_index_assign( 332 | wrapped_partial(h3.cell_to_parent, res=resolution), column 333 | ) 334 | 335 | @doc_standard("h3_center_child", "containing the center child of each H3 address") 336 | def h3_to_center_child(self, resolution: int = None) -> AnyDataFrame: 337 | """ 338 | Parameters 339 | ---------- 340 | resolution : int or None 341 | H3 resolution. If none, then returns the child of resolution 342 | directly below that of each H3 cell 343 | 344 | Examples 345 | -------- 346 | >>> df = pd.DataFrame({'val': [5, 1]}, 347 | >>> index=['881e309739fffff', '881e2659c3fffff']) 348 | >>> df.h3.h3_to_center_child() 349 | val h3_center_child 350 | 881e309739fffff 5 891e3097383ffff 351 | 881e2659c3fffff 1 891e2659c23ffff 352 | """ 353 | return self._apply_index_assign( 354 | wrapped_partial(h3.cell_to_center_child, res=resolution), "h3_center_child" 355 | ) 356 | 357 | @doc_standard( 358 | COLUMN_H3_POLYFILL, 359 | "containing a list H3 addresses whose centroid falls into the Polygon", 360 | ) 361 | def polyfill(self, resolution: int, explode: bool = False) -> AnyDataFrame: 362 | """ 363 | Parameters 364 | ---------- 365 | resolution : int 366 | H3 resolution 367 | explode : bool 368 | If True, will explode the resulting list vertically. 369 | All other columns' values are copied. 370 | Default: False 371 | 372 | See Also 373 | -------- 374 | polyfill_resample : Extended API method that distributes the polygon's values 375 | to the H3 cells contained in it 376 | 377 | Examples 378 | -------- 379 | >>> from shapely.geometry import box 380 | >>> gdf = gpd.GeoDataFrame(geometry=[box(0, 0, 1, 1)]) 381 | >>> gdf.h3.polyfill(4) 382 | geometry h3_polyfill 383 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... [84754e3ffffffff, 84754c7ffffffff, 84754c5ffff... # noqa E501 384 | >>> gdf.h3.polyfill(4, explode=True) 385 | geometry h3_polyfill 386 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754e3ffffffff 387 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754c7ffffffff 388 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754c5ffffffff 389 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754ebffffffff 390 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754edffffffff 391 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754e1ffffffff 392 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 84754e9ffffffff 393 | 0 POLYGON ((1.00000 0.00000, 1.00000 1.00000, 0.... 8475413ffffffff 394 | """ 395 | 396 | def func(row): 397 | return list(polyfill(row.geometry, resolution)) 398 | 399 | result = self._df.apply(func, axis=1) 400 | 401 | if not explode: 402 | assign_args = {COLUMN_H3_POLYFILL: result} 403 | return self._df.assign(**assign_args) 404 | 405 | result = result.explode().to_frame(COLUMN_H3_POLYFILL) 406 | 407 | return self._df.join(result) 408 | 409 | @doc_standard("h3_cell_area", "containing the area of each H3 address") 410 | def cell_area( 411 | self, unit: Literal["km^2", "m^2", "rads^2"] = "km^2" 412 | ) -> AnyDataFrame: 413 | """ 414 | Parameters 415 | ---------- 416 | unit : str, options: 'km^2', 'm^2', or 'rads^2' 417 | Unit for area result. Default: 'km^2` 418 | 419 | Examples 420 | -------- 421 | >>> df = pd.DataFrame({'val': [5, 1]}, 422 | >>> index=['881e309739fffff', '881e2659c3fffff']) 423 | >>> df.h3.cell_area() 424 | val h3_cell_area 425 | 881e309739fffff 5 0.695651 426 | 881e2659c3fffff 1 0.684242 427 | """ 428 | return self._apply_index_assign( 429 | wrapped_partial(h3.cell_area, unit=unit), "h3_cell_area" 430 | ) 431 | 432 | # H3-Pandas Extended API 433 | # These methods extend the API to provide a convenient way to simplify workflows 434 | 435 | def geo_to_h3_aggregate( 436 | self, 437 | resolution: int, 438 | operation: Union[dict, str, Callable] = "sum", 439 | lat_col: str = "lat", 440 | lng_col: str = "lng", 441 | return_geometry: bool = True, 442 | ) -> DataFrame: 443 | """Adds H3 index to DataFrame, groups points with the same index 444 | and performs `operation`. 445 | 446 | pd.DataFrame: uses `lat_col` and `lng_col` (default `lat` and `lng`) 447 | gpd.GeoDataFrame: uses `geometry` 448 | 449 | Parameters 450 | ---------- 451 | resolution : int 452 | H3 resolution 453 | operation : Union[dict, str, Callable] 454 | Argument passed to DataFrame's `agg` method, default 'sum' 455 | lat_col : str 456 | Name of the latitude column (if used), default 'lat' 457 | lng_col : str 458 | Name of the longitude column (if used), default 'lng' 459 | return_geometry: bool 460 | (Optional) Whether to add a `geometry` column with the hexagonal cells. 461 | Default = True 462 | 463 | Returns 464 | ------- 465 | (Geo)DataFrame aggregated by H3 address into which each row's point falls 466 | 467 | See Also 468 | -------- 469 | geo_to_h3 : H3 API method upon which this function builds 470 | 471 | Examples 472 | -------- 473 | >>> df = pd.DataFrame({'lat': [50, 51], 'lng':[14, 15], 'val': [10, 1]}) 474 | >>> df.h3.geo_to_h3(1) 475 | lat lng val 476 | h3_01 477 | 811e3ffffffffff 50 14 10 478 | 811e3ffffffffff 51 15 1 479 | >>> df.h3.geo_to_h3_aggregate(1) 480 | val geometry 481 | h3_01 482 | 811e3ffffffffff 11 POLYGON ((12.34575 50.55428, 12.67732 46.40696... 483 | >>> df = pd.DataFrame({'lat': [50, 51], 'lng':[14, 15], 'val': [10, 1]}) 484 | >>> df.h3.geo_to_h3_aggregate(1, operation='mean') 485 | val geometry 486 | h3_01 487 | 811e3ffffffffff 5.5 POLYGON ((12.34575 50.55428, 12.67732 46.40696... 488 | >>> df.h3.geo_to_h3_aggregate(1, return_geometry=False) 489 | val 490 | h3_01 491 | 811e3ffffffffff 11 492 | """ 493 | grouped = pd.DataFrame( 494 | self.geo_to_h3(resolution, lat_col, lng_col, False) 495 | .drop(columns=[lat_col, lng_col, "geometry"], errors="ignore") 496 | .groupby(self._format_resolution(resolution)) 497 | .agg(operation) 498 | ) 499 | return grouped.h3.h3_to_geo_boundary() if return_geometry else grouped 500 | 501 | def h3_to_parent_aggregate( 502 | self, 503 | resolution: int, 504 | operation: Union[dict, str, Callable] = "sum", 505 | return_geometry: bool = True, 506 | ) -> GeoDataFrame: 507 | """Assigns parent cell to each row, groups by it and performs `operation`. 508 | Assumes H3 index. 509 | 510 | Parameters 511 | ---------- 512 | resolution : int 513 | H3 resolution 514 | operation : Union[dict, str, Callable] 515 | Argument passed to DataFrame's `agg` method, default 'sum' 516 | return_geometry: bool 517 | (Optional) Whether to add a `geometry` column with the hexagonal cells. 518 | Default = True 519 | 520 | Returns 521 | ------- 522 | (Geo)DataFrame aggregated by the parent of each H3 address 523 | 524 | Raises 525 | ------ 526 | ValueError 527 | When an invalid H3 address is encountered 528 | 529 | See Also 530 | -------- 531 | h3_to_parent : H3 API method upon which this function builds 532 | 533 | Examples 534 | -------- 535 | >>> df = pd.DataFrame({'val': [5, 1]}, 536 | >>> index=['881e309739fffff', '881e2659c3fffff']) 537 | >>> df.h3.h3_to_parent(1) 538 | val h3_01 539 | 881e309739fffff 5 811e3ffffffffff 540 | 881e2659c3fffff 1 811e3ffffffffff 541 | >>> df.h3.h3_to_parent_aggregate(1) 542 | val geometry 543 | h3_01 544 | 811e3ffffffffff 6 POLYGON ((12.34575 50.55428, 12.67732 46.40696... 545 | >>> df.h3.h3_to_parent_aggregate(1, operation='mean') 546 | val geometry 547 | h3_01 548 | 811e3ffffffffff 3 POLYGON ((12.34575 50.55428, 12.67732 46.40696... 549 | >>> df.h3.h3_to_parent_aggregate(1, return_geometry=False) 550 | val 551 | h3_01 552 | 811e3ffffffffff 6 553 | """ 554 | parent_h3addresses = [ 555 | catch_invalid_h3_address(h3.cell_to_parent)(h3address, resolution) 556 | for h3address in self._df.index 557 | ] 558 | h3_parent_column = self._format_resolution(resolution) 559 | kwargs_assign = {h3_parent_column: parent_h3addresses} 560 | grouped = ( 561 | self._df.assign(**kwargs_assign) 562 | .groupby(h3_parent_column)[[c for c in self._df.columns if c != "geometry"]] 563 | .agg(operation) 564 | ) 565 | 566 | return grouped.h3.h3_to_geo_boundary() if return_geometry else grouped 567 | 568 | # TODO: Needs to allow for handling relative values (e.g. percentage) 569 | # TODO: Will possibly fail in many cases (what are the existing columns?) 570 | # TODO: New cell behaviour 571 | def k_ring_smoothing( 572 | self, 573 | k: int = None, 574 | weights: Sequence[float] = None, 575 | return_geometry: bool = True, 576 | ) -> AnyDataFrame: 577 | """Experimental. Creates a k-ring around each input cell and distributes 578 | the cell's values. 579 | 580 | The values are distributed either 581 | - uniformly (by setting `k`) or 582 | - by weighing their values using `weights`. 583 | 584 | Only numeric columns are modified. 585 | 586 | Parameters 587 | ---------- 588 | k : int 589 | The distance from the origin H3 address 590 | weights : Sequence[float] 591 | Weighting of the values based on the distance from the origin. 592 | First weight corresponds to the origin. 593 | Values are be normalized to add up to 1. 594 | return_geometry: bool 595 | (Optional) Whether to add a `geometry` column with the hexagonal cells. 596 | Default = True 597 | 598 | Returns 599 | ------- 600 | (Geo)DataFrame with smoothed values 601 | 602 | See Also 603 | -------- 604 | k_ring : H3 API method upon which this method builds 605 | 606 | Examples 607 | -------- 608 | >>> df = pd.DataFrame({'val': [5, 1]}, 609 | >>> index=['881e309739fffff', '881e2659c3fffff']) 610 | >>> df.h3.k_ring_smoothing(1) 611 | val geometry 612 | h3_k_ring 613 | 881e265989fffff 0.142857 POLYGON ((14.99488 50.99821, 14.99260 50.99389... 614 | 881e2659c1fffff 0.142857 POLYGON ((14.97944 51.00758, 14.97717 51.00326... 615 | 881e2659c3fffff 0.142857 POLYGON ((14.99201 51.00565, 14.98973 51.00133... 616 | 881e2659c7fffff 0.142857 POLYGON ((14.98231 51.00014, 14.98004 50.99582... 617 | 881e2659cbfffff 0.142857 POLYGON ((14.98914 51.01308, 14.98687 51.00877... 618 | 881e2659d5fffff 0.142857 POLYGON ((15.00458 51.00371, 15.00230 50.99940... 619 | 881e2659ddfffff 0.142857 POLYGON ((15.00171 51.01115, 14.99943 51.00684... 620 | 881e309703fffff 0.714286 POLYGON ((13.99235 50.01119, 13.99017 50.00681... 621 | 881e309707fffff 0.714286 POLYGON ((13.98290 50.00555, 13.98072 50.00116... 622 | 881e309715fffff 0.714286 POLYGON ((14.00473 50.00932, 14.00255 50.00494... 623 | 881e309731fffff 0.714286 POLYGON ((13.99819 49.99617, 13.99602 49.99178... 624 | 881e309739fffff 0.714286 POLYGON ((13.99527 50.00368, 13.99310 49.99929... 625 | 881e30973bfffff 0.714286 POLYGON ((14.00765 50.00181, 14.00547 49.99742... 626 | 881e30973dfffff 0.714286 POLYGON ((13.98582 49.99803, 13.98364 49.99365... 627 | >>> df.h3.k_ring_smoothing(weights=[2, 1]) 628 | val geometry 629 | h3_hex_ring 630 | 881e265989fffff 0.125 POLYGON ((14.99488 50.99821, 14.99260 50.99389... 631 | 881e2659c1fffff 0.125 POLYGON ((14.97944 51.00758, 14.97717 51.00326... 632 | 881e2659c3fffff 0.250 POLYGON ((14.99201 51.00565, 14.98973 51.00133... 633 | 881e2659c7fffff 0.125 POLYGON ((14.98231 51.00014, 14.98004 50.99582... 634 | 881e2659cbfffff 0.125 POLYGON ((14.98914 51.01308, 14.98687 51.00877... 635 | 881e2659d5fffff 0.125 POLYGON ((15.00458 51.00371, 15.00230 50.99940... 636 | 881e2659ddfffff 0.125 POLYGON ((15.00171 51.01115, 14.99943 51.00684... 637 | 881e309703fffff 0.625 POLYGON ((13.99235 50.01119, 13.99017 50.00681... 638 | 881e309707fffff 0.625 POLYGON ((13.98290 50.00555, 13.98072 50.00116... 639 | 881e309715fffff 0.625 POLYGON ((14.00473 50.00932, 14.00255 50.00494... 640 | 881e309731fffff 0.625 POLYGON ((13.99819 49.99617, 13.99602 49.99178... 641 | 881e309739fffff 1.250 POLYGON ((13.99527 50.00368, 13.99310 49.99929... 642 | 881e30973bfffff 0.625 POLYGON ((14.00765 50.00181, 14.00547 49.99742... 643 | 881e30973dfffff 0.625 POLYGON ((13.98582 49.99803, 13.98364 49.99365... 644 | >>> df.h3.k_ring_smoothing(1, return_geometry=False) 645 | val 646 | h3_k_ring 647 | 881e265989fffff 0.142857 648 | 881e2659c1fffff 0.142857 649 | 881e2659c3fffff 0.142857 650 | 881e2659c7fffff 0.142857 651 | 881e2659cbfffff 0.142857 652 | 881e2659d5fffff 0.142857 653 | 881e2659ddfffff 0.142857 654 | 881e309703fffff 0.714286 655 | 881e309707fffff 0.714286 656 | 881e309715fffff 0.714286 657 | 881e309731fffff 0.714286 658 | 881e309739fffff 0.714286 659 | 881e30973bfffff 0.714286 660 | 881e30973dfffff 0.714286 661 | """ 662 | # Drop geometry if present 663 | df = self._df.drop(columns=["geometry"], errors="ignore") 664 | 665 | if sum([weights is None, k is None]) != 1: 666 | raise ValueError("Exactly one of `k` and `weights` must be set.") 667 | 668 | # If weights are all equal, use the computationally simpler option 669 | if (weights is not None) and (len(set(weights)) == 1): 670 | k = len(weights) - 1 671 | weights = None 672 | 673 | # Unweighted case 674 | if weights is None: 675 | result = pd.DataFrame( 676 | df.h3.k_ring(k, explode=True) 677 | .groupby("h3_k_ring") 678 | .sum() 679 | .divide((1 + 3 * k * (k + 1))) 680 | ) 681 | 682 | return result.h3.h3_to_geo_boundary() if return_geometry else result 683 | 684 | if len(weights) == 0: 685 | raise ValueError("Weights cannot be empty.") 686 | 687 | # Weighted case 688 | weights = np.array(weights) 689 | multipliers = np.array([1] + [i * 6 for i in range(1, len(weights))]) 690 | weights = weights / (weights * multipliers).sum() 691 | 692 | # This should be exploded hex ring 693 | def weighted_hex_ring(df, k, normalized_weight): 694 | return df.h3.hex_ring(k, explode=True).h3._multiply_numeric( 695 | normalized_weight 696 | ) 697 | 698 | result = ( 699 | pd.concat( 700 | [weighted_hex_ring(df, i, weights[i]) for i in range(len(weights))] 701 | ) 702 | .groupby("h3_hex_ring") 703 | .sum() 704 | ) 705 | 706 | return result.h3.h3_to_geo_boundary() if return_geometry else result 707 | 708 | def polyfill_resample( 709 | self, resolution: int, return_geometry: bool = True 710 | ) -> AnyDataFrame: 711 | """Experimental. Currently essentially polyfill(..., explode=True) that 712 | sets the H3 index and adds the H3 cell geometry. 713 | 714 | Parameters 715 | ---------- 716 | resolution : int 717 | H3 resolution 718 | return_geometry: bool 719 | (Optional) Whether to add a `geometry` column with the hexagonal cells. 720 | Default = True 721 | 722 | Returns 723 | ------- 724 | (Geo)DataFrame with H3 cells with centroids within the input polygons. 725 | 726 | See Also 727 | -------- 728 | polyfill : H3 API method upon which this method builds 729 | 730 | Examples 731 | -------- 732 | >>> from shapely.geometry import box 733 | >>> gdf = gpd.GeoDataFrame(geometry=[box(0, 0, 1, 1)]) 734 | >>> gdf.h3.polyfill_resample(4) 735 | index geometry 736 | h3_polyfill 737 | 84754e3ffffffff 0 POLYGON ((0.33404 -0.11975, 0.42911 0.07901, 0... 738 | 84754c7ffffffff 0 POLYGON ((0.92140 -0.03115, 1.01693 0.16862, 0... 739 | 84754c5ffffffff 0 POLYGON ((0.91569 0.33807, 1.01106 0.53747, 0.... 740 | 84754ebffffffff 0 POLYGON ((0.62438 0.10878, 0.71960 0.30787, 0.... 741 | 84754edffffffff 0 POLYGON ((0.32478 0.61394, 0.41951 0.81195, 0.... 742 | 84754e1ffffffff 0 POLYGON ((0.32940 0.24775, 0.42430 0.44615, 0.... 743 | 84754e9ffffffff 0 POLYGON ((0.61922 0.47649, 0.71427 0.67520, 0.... 744 | 8475413ffffffff 0 POLYGON ((0.91001 0.70597, 1.00521 0.90497, 0.... 745 | """ 746 | result = self._df.h3.polyfill(resolution, explode=True) 747 | uncovered_rows = result[COLUMN_H3_POLYFILL].isna() 748 | n_uncovered_rows = uncovered_rows.sum() 749 | if n_uncovered_rows > 0: 750 | warnings.warn( 751 | f"{n_uncovered_rows} rows did not generate a H3 cell." 752 | "Consider using a finer resolution." 753 | ) 754 | result = result.loc[~uncovered_rows] 755 | 756 | result = result.reset_index().set_index(COLUMN_H3_POLYFILL) 757 | 758 | return result.h3.h3_to_geo_boundary() if return_geometry else result 759 | 760 | def linetrace(self, resolution: int, explode: bool = False) -> AnyDataFrame: 761 | """Experimental. An H3 cell representation of a (Multi)LineString, 762 | which permits repeated cells, but not if they are repeated in 763 | immediate sequence. 764 | 765 | Parameters 766 | ---------- 767 | resolution : int 768 | H3 resolution 769 | explode : bool 770 | If True, will explode the resulting list vertically. 771 | All other columns' values are copied. 772 | Default: False 773 | 774 | Returns 775 | ------- 776 | (Geo)DataFrame with H3 cells with centroids within the input polygons. 777 | 778 | Examples 779 | -------- 780 | >>> from shapely.geometry import LineString 781 | >>> gdf = gpd.GeoDataFrame(geometry=[LineString([[0, 0], [1, 0], [1, 1]])]) 782 | >>> gdf.h3.linetrace(4) 783 | geometry h3_linetrace 784 | 0 LINESTRING (0.00000 0.00000, 1.00000 0.00000, ... [83754efffffffff, 83754cfffffffff, 837541fffff... # noqa E501 785 | >>> gdf.h3.linetrace(4, explode=True) 786 | geometry h3_linetrace 787 | 0 LINESTRING (0.00000 0.00000, 1.00000 0.00000, ... 83754efffffffff 788 | 0 LINESTRING (0.00000 0.00000, 1.00000 0.00000, ... 83754cfffffffff 789 | 0 LINESTRING (0.00000 0.00000, 1.00000 0.00000, ... 837541fffffffff 790 | 791 | """ 792 | 793 | def func(row): 794 | return list(linetrace(row.geometry, resolution)) 795 | 796 | df = self._df 797 | 798 | result = df.apply(func, axis=1) 799 | if not explode: 800 | assign_args = {COLUMN_H3_LINETRACE: result} 801 | return df.assign(**assign_args) 802 | 803 | result = result.explode().to_frame(COLUMN_H3_LINETRACE) 804 | return df.join(result) 805 | 806 | # Private methods 807 | 808 | def _apply_index_assign( 809 | self, 810 | func: Callable, 811 | column_name: str, 812 | processor: Callable = lambda x: x, 813 | finalizer: Callable = lambda x: x, 814 | ) -> Any: 815 | """Helper method. Applies `func` to index and assigns the result to `column`. 816 | 817 | Parameters 818 | ---------- 819 | func : Callable 820 | single-argument function to be applied to each H3 address 821 | column_name : str 822 | name of the resulting column 823 | processor : Callable 824 | (Optional) further processes the result of func. Default: identity 825 | finalizer : Callable 826 | (Optional) further processes the resulting dataframe. Default: identity 827 | 828 | Returns 829 | ------- 830 | Dataframe with column `column` containing the result of `func`. 831 | If using `finalizer`, can return anything the `finalizer` returns. 832 | """ 833 | func = catch_invalid_h3_address(func) 834 | result = [processor(func(h3address)) for h3address in self._df.index] 835 | assign_args = {column_name: result} 836 | return finalizer(self._df.assign(**assign_args)) 837 | 838 | def _apply_index_explode( 839 | self, 840 | func: Callable, 841 | column_name: str, 842 | processor: Callable = lambda x: x, 843 | finalizer: Callable = lambda x: x, 844 | ) -> Any: 845 | """Helper method. Applies a list-making `func` to index and performs 846 | a vertical explode. 847 | Any additional values are simply copied to all the rows. 848 | 849 | Parameters 850 | ---------- 851 | func : Callable 852 | single-argument function to be applied to each H3 address 853 | column_name : str 854 | name of the resulting column 855 | processor : Callable 856 | (Optional) further processes the result of func. Default: identity 857 | finalizer : Callable 858 | (Optional) further processes the resulting dataframe. Default: identity 859 | 860 | Returns 861 | ------- 862 | Dataframe with column `column` containing the result of `func`. 863 | If using `finalizer`, can return anything the `finalizer` returns. 864 | """ 865 | func = catch_invalid_h3_address(func) 866 | result = ( 867 | pd.DataFrame.from_dict( 868 | {h3address: processor(func(h3address)) for h3address in self._df.index}, 869 | orient="index", 870 | ) 871 | .stack() 872 | .to_frame(column_name) 873 | .reset_index(level=1, drop=True) 874 | ) 875 | result = self._df.join(result) 876 | return finalizer(result) 877 | 878 | # TODO: types, doc, .. 879 | def _multiply_numeric(self, value): 880 | columns_numeric = self._df.select_dtypes(include=["number"]).columns 881 | assign_args = { 882 | column: self._df[column].multiply(value) for column in columns_numeric 883 | } 884 | return self._df.assign(**assign_args) 885 | 886 | @staticmethod 887 | def _format_resolution(resolution: int) -> str: 888 | return f"h3_{str(resolution).zfill(2)}" 889 | -------------------------------------------------------------------------------- /h3pandas/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DahnJ/H3-Pandas/4c112ca143203e2e93be1042283ede6c9fe6de0c/h3pandas/util/__init__.py -------------------------------------------------------------------------------- /h3pandas/util/decorator.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Callable, Iterator 3 | 4 | 5 | def catch_invalid_h3_address(f: Callable) -> Callable: 6 | """Wrapper that catches potential invalid H3 addresses. 7 | 8 | Parameters 9 | ---------- 10 | f : Callable 11 | 12 | Returns 13 | ------- 14 | The return value of f, or a ValueError if f threw ValueError, TypeError, 15 | or H3CellError 16 | 17 | Raises 18 | ------ 19 | ValueError 20 | When an invalid H3 address is encountered 21 | """ 22 | 23 | @wraps(f) 24 | def safe_f(*args, **kwargs): 25 | try: 26 | return f(*args, **kwargs) 27 | except (TypeError, ValueError) as e: 28 | message = "H3 method raised an error. Is the H3 address correct?" 29 | message += f"\nCaller: {f.__name__}({_print_signature(*args, **kwargs)})" 30 | message += f"\nOriginal error: {repr(e)}" 31 | raise ValueError(message) 32 | 33 | return safe_f 34 | 35 | 36 | def sequential_deduplication(func: Iterator[str]) -> Iterator[str]: 37 | """ 38 | Decorator that doesn't permit two consecutive items of an iterator 39 | to be the same. 40 | 41 | Parameters 42 | ---------- 43 | f : Callable 44 | 45 | Returns 46 | ------- 47 | Yields from f, but won't yield two items in a row that are the same. 48 | """ 49 | 50 | def inner(*args): 51 | iterable = func(*args) 52 | last = None 53 | while (cell := next(iterable, None)) is not None: 54 | if cell != last: 55 | yield cell 56 | last = cell 57 | 58 | return inner 59 | 60 | 61 | # TODO: Test 62 | def doc_standard(column_name: str, description: str) -> Callable: 63 | """Wrapper to provide a standard apply-to-H3-index docstring""" 64 | 65 | def doc_decorator(f): 66 | @wraps(f) 67 | def doc_f(*args, **kwargs): 68 | return f(*args, **kwargs) 69 | 70 | parameters = f.__doc__ or "" 71 | 72 | doc = f"""Adds the column `{column_name}` {description}. Assumes H3 index. 73 | {parameters} 74 | Returns 75 | ------- 76 | Geo(DataFrame) with `{column_name}` column added 77 | 78 | Raises 79 | ------ 80 | ValueError 81 | When an invalid H3 address is encountered 82 | """ 83 | 84 | doc_f.__doc__ = doc 85 | return doc_f 86 | 87 | return doc_decorator 88 | 89 | 90 | def _print_signature(*args, **kwargs): 91 | signature = [] 92 | if args: 93 | signature.append(", ".join([repr(a) for a in args])) 94 | if kwargs: 95 | signature.append(", ".join({f"{repr(k)}={repr(v)}" for k, v in kwargs.items()})) 96 | 97 | return ", ".join(signature) 98 | -------------------------------------------------------------------------------- /h3pandas/util/functools.py: -------------------------------------------------------------------------------- 1 | from functools import partial, update_wrapper 2 | 3 | 4 | def wrapped_partial(func, *args, **kwargs): 5 | """Properly wrapped partial function""" 6 | partial_func = partial(func, *args, **kwargs) 7 | update_wrapper(partial_func, func) 8 | return partial_func 9 | -------------------------------------------------------------------------------- /h3pandas/util/shapely.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Set, Iterator 2 | from shapely.geometry import Polygon, MultiPolygon, LineString, MultiLineString 3 | from shapely.ops import transform 4 | import h3 5 | from .decorator import sequential_deduplication 6 | 7 | 8 | MultiPolyOrPoly = Union[Polygon, MultiPolygon] 9 | MultiLineOrLine = Union[LineString, MultiLineString] 10 | 11 | 12 | def polyfill(geometry: MultiPolyOrPoly, resolution: int) -> Set[str]: 13 | """h3.polyfill accepting a shapely (Multi)Polygon 14 | 15 | Parameters 16 | ---------- 17 | geometry : Polygon or Multipolygon 18 | Polygon to fill 19 | resolution : int 20 | H3 resolution of the filling cells 21 | 22 | Returns 23 | ------- 24 | Set of H3 addresses 25 | 26 | Raises 27 | ------ 28 | TypeError if geometry is not a Polygon or MultiPolygon 29 | """ 30 | if isinstance(geometry, (Polygon, MultiPolygon)): 31 | h3shape = h3.geo_to_h3shape(geometry) 32 | return set(h3.polygon_to_cells(h3shape, resolution)) 33 | else: 34 | raise TypeError(f"Unknown type {type(geometry)}") 35 | 36 | 37 | def cell_to_boundary_lng_lat(h3_address: str) -> MultiLineString: 38 | """h3.h3_to_geo_boundary equivalent for shapely 39 | 40 | Parameters 41 | ---------- 42 | h3_address : str 43 | H3 address to convert to a boundary 44 | 45 | Returns 46 | ------- 47 | MultiLineString representing the H3 cell boundary 48 | """ 49 | return _switch_lat_lng(Polygon(h3.cell_to_boundary(h3_address))) 50 | 51 | 52 | def _switch_lat_lng(geometry: MultiPolyOrPoly) -> MultiPolyOrPoly: 53 | """Switches the order of coordinates in a Polygon or MultiPolygon 54 | 55 | Parameters 56 | ---------- 57 | geometry : Polygon or Multipolygon 58 | Polygon to switch coordinates 59 | 60 | Returns 61 | ------- 62 | Polygon or Multipolygon with switched coordinates 63 | """ 64 | return transform(lambda x, y: (y, x), geometry) 65 | 66 | 67 | @sequential_deduplication 68 | def linetrace(geometry: MultiLineOrLine, resolution: int) -> Iterator[str]: 69 | """h3.polyfill equivalent for shapely (Multi)LineString 70 | Does not represent lines with duplicate sequential cells, 71 | but cells may repeat non-sequentially to represent 72 | self-intersections 73 | 74 | Parameters 75 | ---------- 76 | geometry : LineString or MultiLineString 77 | Line to trace with H3 cells 78 | resolution : int 79 | H3 resolution of the tracing cells 80 | 81 | Returns 82 | ------- 83 | Set of H3 addresses 84 | 85 | Raises 86 | ------ 87 | TypeError if geometry is not a LineString or a MultiLineString 88 | """ 89 | if isinstance(geometry, MultiLineString): 90 | # Recurse after getting component linestrings from the multiline 91 | for line in map(lambda geom: linetrace(geom, resolution), geometry.geoms): 92 | yield from line 93 | elif isinstance(geometry, LineString): 94 | coords = zip(geometry.coords, geometry.coords[1:]) 95 | while (vertex_pair := next(coords, None)) is not None: 96 | i, j = vertex_pair 97 | a = h3.latlng_to_cell(*i[::-1], resolution) 98 | b = h3.latlng_to_cell(*j[::-1], resolution) 99 | yield from h3.grid_path_cells(a, b) # inclusive of a and b 100 | else: 101 | raise TypeError(f"Unknown type {type(geometry)}") 102 | -------------------------------------------------------------------------------- /meta-backup.yaml: -------------------------------------------------------------------------------- 1 | {% set name = "h3pandas" %} 2 | {% set version = "0.1.3" %} 3 | 4 | 5 | package: 6 | name: {{ name|lower }} 7 | version: {{ version }} 8 | 9 | source: 10 | url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/h3pandas-{{ version }}.tar.gz 11 | sha256: 562922a2cded5b54c490d9abe869024f379cd82e539d4c5993aecd65c2f43c0f 12 | 13 | build: 14 | number: 0 15 | noarch: python 16 | script: {{ PYTHON }} -m pip install . -vv 17 | 18 | requirements: 19 | host: 20 | - pip 21 | - python >=3.6 22 | run: 23 | - geopandas 24 | - h3-py 25 | - numpy 26 | - pandas 27 | - python >=3.6 28 | - shapely 29 | - typing-extensions 30 | 31 | test: 32 | imports: 33 | - h3pandas 34 | - h3pandas.util 35 | commands: 36 | - pytest --pyargs h3pandas 37 | requires: 38 | - pytest 39 | 40 | about: 41 | home: https://github.com/DahnJ/H3-Pandas 42 | summary: Integration of H3 and GeoPandas 43 | license: MIT 44 | license_file: LICENSE 45 | doc_url: https://h3-pandas.readthedocs.io/en/latest/ 46 | dev_url: https://github.com/DahnJ/H3-Pandas 47 | 48 | extra: 49 | recipe-maintainers: 50 | - dahn 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=64", "wheel", "setuptools_scm[toml]>=8"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "h3pandas" 7 | dynamic = ["version"] 8 | description = "Integration of H3 and GeoPandas" 9 | license = { file = "LICENSE" } 10 | authors = [{ name = "Dahn", email = "dahnjahn@gmail.com" }] 11 | requires-python = ">=3.9" 12 | dependencies = [ 13 | "geopandas", 14 | "numpy", 15 | "pandas", 16 | "shapely", 17 | "h3>=4", 18 | "typing-extensions", 19 | ] 20 | 21 | [tool.setuptools_scm] 22 | 23 | [project.optional-dependencies] 24 | test = ["pytest", "pytest-cov", "ruff"] 25 | docs = ["sphinx", "numpydoc", "pytest-sphinx-theme", "typing-extensions"] 26 | 27 | [project.urls] 28 | Homepage = "https://github.com/DahnJ/H3-Pandas" 29 | Download = "https://github.com/DahnJ/H3-Pandas/releases" 30 | 31 | [tool.setuptools] 32 | zip-safe = false 33 | 34 | [tool.setuptools.packages.find] 35 | where = ["."] 36 | include = ["h3pandas*"] 37 | 38 | [tool.setuptools.dynamic] 39 | readme = {file = ["README.md"], content-type = "text/markdown"} 40 | long_description = {file = "README.md", content-type = "text/markdown"} 41 | 42 | [tool.codespell] 43 | skip = "*.ipynb" 44 | 45 | [tool.ruff] 46 | exclude = ["**/*.ipynb"] 47 | 48 | [tool.ruff.lint] 49 | ignore = ["E203"] 50 | 51 | [tool.ruff.lint.pycodestyle] 52 | max-line-length = 88 53 | 54 | [tool.pytest.ini_options] 55 | filterwarnings = ["ignore::UserWarning"] -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | pytest --cov-report html --cov h3pandas 3 | flake8 . 4 | # xdg-open htmlcov/h3pandas_h3pandas_py.html 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DahnJ/H3-Pandas/4c112ca143203e2e93be1042283ede6c9fe6de0c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_h3pandas.py: -------------------------------------------------------------------------------- 1 | from h3pandas import h3pandas # noqa: F401 2 | import pytest 3 | from shapely.geometry import Polygon, LineString, MultiLineString, box, Point 4 | import pandas as pd 5 | import geopandas as gpd 6 | from geopandas.testing import assert_geodataframe_equal 7 | 8 | from h3pandas.util.shapely import cell_to_boundary_lng_lat 9 | 10 | 11 | # TODO: Make sure methods are tested both for 12 | # DataFrame and GeoDataFrame (where applicable) 13 | # TODO: Test return_geometry functionality 14 | 15 | # Fixtures 16 | 17 | 18 | @pytest.fixture 19 | def basic_dataframe(): 20 | """DataFrame with lat and lng columns""" 21 | return pd.DataFrame({"lat": [50, 51], "lng": [14, 15]}) 22 | 23 | 24 | @pytest.fixture 25 | def basic_geodataframe(basic_dataframe): 26 | """GeoDataFrame with POINT geometry""" 27 | geometry = gpd.points_from_xy(basic_dataframe["lng"], basic_dataframe["lat"]) 28 | return gpd.GeoDataFrame(geometry=geometry, crs="epsg:4326") 29 | 30 | 31 | @pytest.fixture 32 | def basic_geodataframe_polygon(basic_geodataframe): 33 | geom = box(0, 0, 1, 1) 34 | return gpd.GeoDataFrame(geometry=[geom], crs="epsg:4326") 35 | 36 | 37 | @pytest.fixture 38 | def basic_geodataframe_linestring(): 39 | geom = LineString([(174.793092, -37.005372), (175.621138, -40.323142)]) 40 | return gpd.GeoDataFrame(geometry=[geom], crs="epsg:4326") 41 | 42 | 43 | @pytest.fixture 44 | # NB one of the LineString parts traverses the antimeridian 45 | def basic_geodataframe_multilinestring(basic_geodataframe): 46 | geom = MultiLineString( 47 | [ 48 | [[174.793092, -37.005372], [175.621138, -40.323142]], 49 | [ 50 | [168.222656, -45.79817], 51 | [171.914063, -34.307144], 52 | [178.769531, -37.926868], 53 | [183.515625, -43.992815], 54 | ], 55 | ] 56 | ) 57 | return gpd.GeoDataFrame(geometry=[geom], crs="epsg:4326") 58 | 59 | 60 | @pytest.fixture 61 | def basic_geodataframe_empty_linestring(): 62 | """GeoDataFrame with Empty geometry""" 63 | return gpd.GeoDataFrame(geometry=[LineString()], crs="epsg:4326") 64 | 65 | 66 | @pytest.fixture 67 | def basic_geodataframe_polygons(basic_geodataframe): 68 | geoms = [box(0, 0, 1, 1), box(0, 0, 2, 2)] 69 | return gpd.GeoDataFrame(geometry=geoms, crs="epsg:4326") 70 | 71 | 72 | @pytest.fixture 73 | def basic_dataframe_with_values(basic_dataframe): 74 | """DataFrame with lat and lng columns and values""" 75 | return basic_dataframe.assign(val=[2, 5]) 76 | 77 | 78 | @pytest.fixture 79 | def basic_geodataframe_with_values(basic_geodataframe): 80 | """GeoDataFrame with POINT geometry and values""" 81 | return basic_geodataframe.assign(val=[2, 5]) 82 | 83 | 84 | @pytest.fixture 85 | def indexed_dataframe(basic_dataframe): 86 | """DataFrame with lat, lng and resolution 9 H3 index""" 87 | return basic_dataframe.assign( 88 | h3_09=["891e3097383ffff", "891e2659c2fffff"] 89 | ).set_index("h3_09") 90 | 91 | 92 | @pytest.fixture 93 | def h3_dataframe_with_values(): 94 | """DataFrame with resolution 9 H3 index and values""" 95 | index = ["891f1d48177ffff", "891f1d48167ffff", "891f1d4810fffff"] 96 | return pd.DataFrame({"val": [1, 2, 5]}, index=index) 97 | 98 | 99 | @pytest.fixture 100 | def h3_geodataframe_with_values(h3_dataframe_with_values): 101 | """GeoDataFrame with resolution 9 H3 index, values, and Hexagon geometries""" 102 | geometry = [ 103 | Polygon(cell_to_boundary_lng_lat(h)) for h in h3_dataframe_with_values.index 104 | ] 105 | return gpd.GeoDataFrame( 106 | h3_dataframe_with_values, geometry=geometry, crs="epsg:4326" 107 | ) 108 | 109 | 110 | @pytest.fixture 111 | def h3_geodataframe_with_polyline_values(basic_geodataframe_linestring): 112 | return basic_geodataframe_linestring.assign(val=10) 113 | 114 | 115 | # Tests: H3 API 116 | class TestGeoToH3: 117 | def test_geo_to_h3(self, basic_dataframe): 118 | result = basic_dataframe.h3.geo_to_h3(9) 119 | expected = basic_dataframe.assign( 120 | h3_09=["891e3097383ffff", "891e2659c2fffff"] 121 | ).set_index("h3_09") 122 | 123 | pd.testing.assert_frame_equal(expected, result) 124 | 125 | def test_geo_to_h3_geo(self, basic_geodataframe): 126 | result = basic_geodataframe.h3.geo_to_h3(9) 127 | expected = basic_geodataframe.assign( 128 | h3_09=["891e3097383ffff", "891e2659c2fffff"] 129 | ).set_index("h3_09") 130 | 131 | pd.testing.assert_frame_equal(expected, result) 132 | 133 | def test_geo_to_h3_polygon(self, basic_geodataframe_polygon): 134 | with pytest.raises(ValueError): 135 | basic_geodataframe_polygon.h3.geo_to_h3(9) 136 | 137 | 138 | class TestH3ToGeo: 139 | def test_h3_to_geo(self, indexed_dataframe): 140 | lats = [50.000551554902586, 51.000121447274736] 141 | lngs = [14.000372151097624, 14.999768926738376] 142 | geometry = gpd.points_from_xy(x=lngs, y=lats, crs="epsg:4326") 143 | expected = gpd.GeoDataFrame(indexed_dataframe, geometry=geometry) 144 | result = indexed_dataframe.h3.h3_to_geo() 145 | assert_geodataframe_equal(expected, result, check_less_precise=True) 146 | 147 | def test_h3_to_geo_boundary(self, indexed_dataframe): 148 | h1 = ( 149 | (13.997875502962215, 50.00126530465277), 150 | (13.997981974191347, 49.99956539765703), 151 | (14.000478563108897, 49.99885162163456), 152 | (14.002868770645003, 49.99983773856239), 153 | (14.002762412857178, 50.00153765760209), 154 | (14.000265734090084, 50.00225144767143), 155 | (13.997875502962215, 50.00126530465277), 156 | ) 157 | h2 = ( 158 | (14.9972390328545, 51.00084372147122), 159 | (14.99732334029277, 50.99916437137475), 160 | (14.999853173220332, 50.99844207137708), 161 | (15.002298787294139, 50.99939910547163), 162 | (15.002214597747209, 51.00107846572982), 163 | (14.999684676233445, 51.00180078173323), 164 | (14.9972390328545, 51.00084372147122), 165 | ) 166 | geometry = [Polygon(h1), Polygon(h2)] 167 | 168 | result = indexed_dataframe.h3.h3_to_geo_boundary() 169 | expected = gpd.GeoDataFrame( 170 | indexed_dataframe, geometry=geometry, crs="epsg:4326" 171 | ) 172 | assert_geodataframe_equal(expected, result, check_less_precise=True) 173 | 174 | 175 | class TestH3ToGeoBoundary: 176 | def test_h3_to_geo_boundary_wrong_index(self, indexed_dataframe): 177 | indexed_dataframe.index = [str(indexed_dataframe.index[0])] + ["invalid"] 178 | with pytest.raises(ValueError): 179 | indexed_dataframe.h3.h3_to_geo_boundary() 180 | 181 | 182 | class TestH3ToParent: 183 | def test_h3_to_parent_level_1(self, h3_dataframe_with_values): 184 | h3_parent = "811f3ffffffffff" 185 | result = h3_dataframe_with_values.h3.h3_to_parent(1) 186 | expected = h3_dataframe_with_values.assign(h3_01=h3_parent) 187 | 188 | pd.testing.assert_frame_equal(expected, result) 189 | 190 | def test_h3_to_direct_parent(self, h3_dataframe_with_values): 191 | h3_parents = ["881f1d4817fffff", "881f1d4817fffff", "881f1d4811fffff"] 192 | result = h3_dataframe_with_values.h3.h3_to_parent() 193 | expected = h3_dataframe_with_values.assign(h3_parent=h3_parents) 194 | 195 | pd.testing.assert_frame_equal(expected, result) 196 | 197 | def test_h3_to_parent_level_0(self, h3_dataframe_with_values): 198 | h3_parent = "801ffffffffffff" 199 | result = h3_dataframe_with_values.h3.h3_to_parent(0) 200 | expected = h3_dataframe_with_values.assign(h3_00=h3_parent) 201 | 202 | pd.testing.assert_frame_equal(expected, result) 203 | 204 | 205 | class TestH3ToCenterChild: 206 | def test_h3_to_center_child(self, indexed_dataframe): 207 | expected = indexed_dataframe.assign( 208 | h3_center_child=["8a1e30973807fff", "8a1e2659c2c7fff"] 209 | ) 210 | result = indexed_dataframe.h3.h3_to_center_child() 211 | pd.testing.assert_frame_equal(expected, result) 212 | 213 | 214 | class TestPolyfill: 215 | def test_empty_polyfill(self, h3_geodataframe_with_values): 216 | expected = h3_geodataframe_with_values.assign( 217 | h3_polyfill=[list(), list(), list()] 218 | ) 219 | result = h3_geodataframe_with_values.h3.polyfill(1) 220 | assert_geodataframe_equal(expected, result) 221 | 222 | def test_polyfill(self, h3_geodataframe_with_values): 223 | expected_cells = [ 224 | { 225 | "8a1f1d481747fff", 226 | "8a1f1d48174ffff", 227 | "8a1f1d481757fff", 228 | "8a1f1d48175ffff", 229 | "8a1f1d481767fff", 230 | "8a1f1d48176ffff", 231 | "8a1f1d481777fff", 232 | }, 233 | { 234 | "8a1f1d481647fff", 235 | "8a1f1d48164ffff", 236 | "8a1f1d481657fff", 237 | "8a1f1d48165ffff", 238 | "8a1f1d481667fff", 239 | "8a1f1d48166ffff", 240 | "8a1f1d481677fff", 241 | }, 242 | { 243 | "8a1f1d4810c7fff", 244 | "8a1f1d4810cffff", 245 | "8a1f1d4810d7fff", 246 | "8a1f1d4810dffff", 247 | "8a1f1d4810e7fff", 248 | "8a1f1d4810effff", 249 | "8a1f1d4810f7fff", 250 | }, 251 | ] 252 | expected = h3_geodataframe_with_values.assign(h3_polyfill=expected_cells) 253 | result = h3_geodataframe_with_values.h3.polyfill(10) 254 | result["h3_polyfill"] = result["h3_polyfill"].apply( 255 | set 256 | ) # Convert to set for testing 257 | assert_geodataframe_equal(expected, result) 258 | 259 | def test_polyfill_explode(self, h3_geodataframe_with_values): 260 | expected_indices = set().union( 261 | *[ 262 | { 263 | "8a1f1d481747fff", 264 | "8a1f1d48174ffff", 265 | "8a1f1d481757fff", 266 | "8a1f1d48175ffff", 267 | "8a1f1d481767fff", 268 | "8a1f1d48176ffff", 269 | "8a1f1d481777fff", 270 | }, 271 | { 272 | "8a1f1d481647fff", 273 | "8a1f1d48164ffff", 274 | "8a1f1d481657fff", 275 | "8a1f1d48165ffff", 276 | "8a1f1d481667fff", 277 | "8a1f1d48166ffff", 278 | "8a1f1d481677fff", 279 | }, 280 | { 281 | "8a1f1d4810c7fff", 282 | "8a1f1d4810cffff", 283 | "8a1f1d4810d7fff", 284 | "8a1f1d4810dffff", 285 | "8a1f1d4810e7fff", 286 | "8a1f1d4810effff", 287 | "8a1f1d4810f7fff", 288 | }, 289 | ] 290 | ) 291 | result = h3_geodataframe_with_values.h3.polyfill(10, explode=True) 292 | assert len(result) == len(h3_geodataframe_with_values) * 7 293 | assert set(result["h3_polyfill"]) == expected_indices 294 | assert not result["val"].isna().any() 295 | 296 | def test_polyfill_explode_unequal_lengths(self, basic_geodataframe_polygons): 297 | expected_indices = { 298 | "83754efffffffff", 299 | "83756afffffffff", 300 | "83754efffffffff", 301 | "837541fffffffff", 302 | "83754cfffffffff", 303 | } 304 | result = basic_geodataframe_polygons.h3.polyfill(3, explode=True) 305 | assert len(result) == 5 306 | assert set(result["h3_polyfill"]) == expected_indices 307 | 308 | 309 | class TestLineTrace: 310 | def test_empty_linetrace(self, basic_geodataframe_empty_linestring): 311 | result = basic_geodataframe_empty_linestring.h3.linetrace(2) 312 | assert len(result.iloc[0]["h3_linetrace"]) == 0 313 | 314 | def test_linetrace(self, basic_geodataframe_linestring): 315 | result = basic_geodataframe_linestring.h3.linetrace(3) 316 | expected_indices = [ 317 | "83bb50fffffffff", 318 | "83bb54fffffffff", 319 | "83bb72fffffffff", 320 | "83bb0dfffffffff", 321 | "83bb2bfffffffff", 322 | ] 323 | assert len(result.iloc[0]["h3_linetrace"]) == 5 324 | assert list(result.iloc[0]["h3_linetrace"]) == expected_indices 325 | 326 | def test_linetrace_explode(self, basic_geodataframe_linestring): 327 | result = basic_geodataframe_linestring.h3.linetrace(3, explode=True) 328 | expected_indices = [ 329 | "83bb50fffffffff", 330 | "83bb54fffffffff", 331 | "83bb72fffffffff", 332 | "83bb0dfffffffff", 333 | "83bb2bfffffffff", 334 | ] 335 | assert result.shape == (5, 2) 336 | assert result.iloc[0]["h3_linetrace"] == expected_indices[0] 337 | assert result.iloc[-1]["h3_linetrace"] == expected_indices[-1] 338 | 339 | def test_linetrace_with_values(self, h3_geodataframe_with_polyline_values): 340 | result = h3_geodataframe_with_polyline_values.h3.linetrace(3) 341 | expected_indices = [ 342 | "83bb50fffffffff", 343 | "83bb54fffffffff", 344 | "83bb72fffffffff", 345 | "83bb0dfffffffff", 346 | "83bb2bfffffffff", 347 | ] 348 | assert result.shape == (1, 3) 349 | assert "val" in result.columns 350 | assert result.iloc[0]["val"] == 10 351 | assert len(result.iloc[0]["h3_linetrace"]) == 5 352 | assert list(result.iloc[0]["h3_linetrace"]) == expected_indices 353 | 354 | def test_linetrace_with_values_explode(self, h3_geodataframe_with_polyline_values): 355 | result = h3_geodataframe_with_polyline_values.h3.linetrace(3, explode=True) 356 | expected_indices = [ 357 | "83bb50fffffffff", 358 | "83bb54fffffffff", 359 | "83bb72fffffffff", 360 | "83bb0dfffffffff", 361 | "83bb2bfffffffff", 362 | ] 363 | assert result.shape == (5, 3) 364 | assert "val" in result.columns 365 | assert result.iloc[0]["val"] == 10 366 | assert result.iloc[0]["h3_linetrace"] == expected_indices[0] 367 | assert result.iloc[-1]["h3_linetrace"] == expected_indices[-1] 368 | assert not result["val"].isna().any() 369 | 370 | def test_linetrace_multiline(self, basic_geodataframe_multilinestring): 371 | result = basic_geodataframe_multilinestring.h3.linetrace(2) 372 | expected_indices = [ 373 | "82bb57fffffffff", 374 | "82bb0ffffffffff", 375 | "82da87fffffffff", 376 | "82da97fffffffff", 377 | "82bb67fffffffff", 378 | "82bb47fffffffff", 379 | "82bb5ffffffffff", 380 | "82bb57fffffffff", 381 | "82ba27fffffffff", 382 | "82bb1ffffffffff", 383 | "82bb07fffffffff", 384 | "82bb37fffffffff", 385 | ] 386 | assert len(result.iloc[0]["h3_linetrace"]) == 12 # 12 cells total 387 | assert list(result.iloc[0]["h3_linetrace"]) == expected_indices 388 | 389 | def test_linetrace_multiline_explode_index_parts( 390 | self, basic_geodataframe_multilinestring 391 | ): 392 | result = basic_geodataframe_multilinestring.explode( 393 | index_parts=True 394 | ).h3.linetrace(2, explode=True) 395 | expected_indices = [ 396 | ["82bb57fffffffff", "82bb0ffffffffff"], 397 | [ 398 | "82da87fffffffff", 399 | "82da97fffffffff", 400 | "82bb67fffffffff", 401 | "82bb47fffffffff", 402 | "82bb5ffffffffff", 403 | "82bb57fffffffff", 404 | "82ba27fffffffff", 405 | "82bb1ffffffffff", 406 | "82bb07fffffffff", 407 | "82bb37fffffffff", 408 | ], 409 | ] 410 | assert len(result["h3_linetrace"]) == 12 # 12 cells in total 411 | assert result.iloc[0]["h3_linetrace"] == expected_indices[0][0] 412 | assert result.iloc[-1]["h3_linetrace"] == expected_indices[-1][-1] 413 | 414 | def test_linetrace_multiline_index_parts_no_explode( 415 | self, basic_geodataframe_multilinestring 416 | ): 417 | result = basic_geodataframe_multilinestring.explode( 418 | index_parts=True 419 | ).h3.linetrace(2, explode=False) 420 | expected_indices = [ 421 | ["82bb57fffffffff", "82bb0ffffffffff"], 422 | [ 423 | "82da87fffffffff", 424 | "82da97fffffffff", 425 | "82bb67fffffffff", 426 | "82bb47fffffffff", 427 | "82bb5ffffffffff", 428 | "82bb57fffffffff", 429 | "82ba27fffffffff", 430 | "82bb1ffffffffff", 431 | "82bb07fffffffff", 432 | "82bb37fffffffff", 433 | ], 434 | ] 435 | assert len(result["h3_linetrace"]) == 2 # 2 parts 436 | assert len(result.iloc[0]["h3_linetrace"]) == 2 # 2 cells 437 | assert result.iloc[0]["h3_linetrace"] == expected_indices[0] 438 | assert len(result.iloc[-1]["h3_linetrace"]) == 10 # 10 cells 439 | assert result.iloc[-1]["h3_linetrace"] == expected_indices[-1] 440 | 441 | 442 | class TestCellArea: 443 | def test_cell_area(self, indexed_dataframe): 444 | expected = indexed_dataframe.assign( 445 | h3_cell_area=[0.09937867173389912, 0.09775508251476996] 446 | ) 447 | result = indexed_dataframe.h3.cell_area() 448 | pd.testing.assert_frame_equal(expected, result) 449 | 450 | 451 | class TestH3GetResolution: 452 | def test_h3_get_resolution(self, h3_dataframe_with_values): 453 | expected = h3_dataframe_with_values.assign(h3_resolution=9) 454 | result = h3_dataframe_with_values.h3.h3_get_resolution() 455 | pd.testing.assert_frame_equal(expected, result) 456 | 457 | def test_h3_get_resolution_index_only(self, h3_dataframe_with_values): 458 | del h3_dataframe_with_values["val"] 459 | expected = h3_dataframe_with_values.assign(h3_resolution=9) 460 | result = h3_dataframe_with_values.h3.h3_get_resolution() 461 | pd.testing.assert_frame_equal(expected, result) 462 | 463 | 464 | class TestH3GetBaseCell: 465 | def test_h3_get_base_cell(self, indexed_dataframe): 466 | expected = indexed_dataframe.assign(h3_base_cell=[15, 15]) 467 | result = indexed_dataframe.h3.h3_get_base_cell() 468 | pd.testing.assert_frame_equal(expected, result) 469 | 470 | 471 | class TestKRing: 472 | def test_h3_0_ring(self, indexed_dataframe): 473 | expected = indexed_dataframe.assign( 474 | h3_k_ring=[[h] for h in indexed_dataframe.index] 475 | ) 476 | result = indexed_dataframe.h3.k_ring(0) 477 | pd.testing.assert_frame_equal(expected, result) 478 | 479 | def test_h3_k_ring(self, indexed_dataframe): 480 | expected_indices = [ 481 | { 482 | "891e3097383ffff", 483 | "891e3097387ffff", 484 | "891e309738bffff", 485 | "891e309738fffff", 486 | "891e3097393ffff", 487 | "891e3097397ffff", 488 | "891e309739bffff", 489 | }, 490 | { 491 | "891e2659893ffff", 492 | "891e2659897ffff", 493 | "891e2659c23ffff", 494 | "891e2659c27ffff", 495 | "891e2659c2bffff", 496 | "891e2659c2fffff", 497 | "891e2659d5bffff", 498 | }, 499 | ] 500 | expected = indexed_dataframe.assign(h3_k_ring=expected_indices) 501 | result = indexed_dataframe.h3.k_ring() 502 | result["h3_k_ring"] = result["h3_k_ring"].apply( 503 | set 504 | ) # Convert to set for testing 505 | pd.testing.assert_frame_equal(expected, result) 506 | 507 | def test_h3_k_ring_explode(self, indexed_dataframe): 508 | expected_indices = set().union( 509 | *[ 510 | { 511 | "891e3097383ffff", 512 | "891e3097387ffff", 513 | "891e309738bffff", 514 | "891e309738fffff", 515 | "891e3097393ffff", 516 | "891e3097397ffff", 517 | "891e309739bffff", 518 | }, 519 | { 520 | "891e2659893ffff", 521 | "891e2659897ffff", 522 | "891e2659c23ffff", 523 | "891e2659c27ffff", 524 | "891e2659c2bffff", 525 | "891e2659c2fffff", 526 | "891e2659d5bffff", 527 | }, 528 | ] 529 | ) 530 | result = indexed_dataframe.h3.k_ring(explode=True) 531 | assert len(result) == len(indexed_dataframe) * 7 532 | assert set(result["h3_k_ring"]) == expected_indices 533 | assert not result["lat"].isna().any() 534 | 535 | 536 | class TestHexRing: 537 | def test_h3_0_hex_ring(self, indexed_dataframe): 538 | expected = indexed_dataframe.assign( 539 | h3_hex_ring=[[h] for h in indexed_dataframe.index] 540 | ) 541 | result = indexed_dataframe.h3.hex_ring(0) 542 | pd.testing.assert_frame_equal(expected, result) 543 | 544 | def test_h3_0_hex_ring_explode(self, indexed_dataframe): 545 | expected = indexed_dataframe.assign( 546 | h3_hex_ring=[h for h in indexed_dataframe.index] 547 | ) 548 | result = indexed_dataframe.h3.hex_ring(0, True) 549 | pd.testing.assert_frame_equal(expected, result) 550 | 551 | def test_h3_hex_ring(self, indexed_dataframe): 552 | expected_indices = [ 553 | { 554 | "891e3097387ffff", 555 | "891e309738bffff", 556 | "891e309738fffff", 557 | "891e3097393ffff", 558 | "891e3097397ffff", 559 | "891e309739bffff", 560 | }, 561 | { 562 | "891e2659893ffff", 563 | "891e2659897ffff", 564 | "891e2659c23ffff", 565 | "891e2659c27ffff", 566 | "891e2659c2bffff", 567 | "891e2659d5bffff", 568 | }, 569 | ] 570 | expected = indexed_dataframe.assign(h3_hex_ring=expected_indices) 571 | result = indexed_dataframe.h3.hex_ring() 572 | result["h3_hex_ring"] = result["h3_hex_ring"].apply( 573 | set 574 | ) # Convert to set for testing 575 | pd.testing.assert_frame_equal(expected, result) 576 | 577 | def test_h3_hex_ring_explode(self, indexed_dataframe): 578 | expected_indices = set().union( 579 | *[ 580 | { 581 | "891e3097387ffff", 582 | "891e309738bffff", 583 | "891e309738fffff", 584 | "891e3097393ffff", 585 | "891e3097397ffff", 586 | "891e309739bffff", 587 | }, 588 | { 589 | "891e2659893ffff", 590 | "891e2659897ffff", 591 | "891e2659c23ffff", 592 | "891e2659c27ffff", 593 | "891e2659c2bffff", 594 | "891e2659d5bffff", 595 | }, 596 | ] 597 | ) 598 | result = indexed_dataframe.h3.hex_ring(explode=True) 599 | assert len(result) == len(indexed_dataframe) * 6 600 | assert set(result["h3_hex_ring"]) == expected_indices 601 | assert not result["lat"].isna().any() 602 | 603 | 604 | class TestH3IsValid: 605 | def test_h3_is_valid(self, indexed_dataframe): 606 | indexed_dataframe.index = [str(indexed_dataframe.index[0])] + ["invalid"] 607 | expected = indexed_dataframe.assign(h3_is_valid=[True, False]) 608 | result = indexed_dataframe.h3.h3_is_valid() 609 | pd.testing.assert_frame_equal(expected, result) 610 | 611 | 612 | # Tests: Aggregate functions 613 | class TestGeoToH3Aggregate: 614 | def test_geo_to_h3_aggregate(self, basic_dataframe_with_values): 615 | result = basic_dataframe_with_values.h3.geo_to_h3_aggregate( 616 | 1, return_geometry=False 617 | ) 618 | expected = pd.DataFrame( 619 | {"h3_01": ["811e3ffffffffff"], "val": [2 + 5]} 620 | ).set_index("h3_01") 621 | 622 | pd.testing.assert_frame_equal(expected, result) 623 | 624 | def test_geo_to_h3_aggregate_geo(self, basic_geodataframe_with_values): 625 | result = basic_geodataframe_with_values.h3.geo_to_h3_aggregate( 626 | 1, return_geometry=False 627 | ) 628 | expected = pd.DataFrame( 629 | {"h3_01": ["811e3ffffffffff"], "val": [2 + 5]} 630 | ).set_index("h3_01") 631 | 632 | pd.testing.assert_frame_equal(expected, result) 633 | 634 | 635 | class TestH3ToParentAggregate: 636 | def test_h3_to_parent_aggregate(self, h3_geodataframe_with_values): 637 | result = h3_geodataframe_with_values.h3.h3_to_parent_aggregate(8) 638 | # TODO: Why does Pandas not preserve the order of groups here? 639 | index = pd.Index(["881f1d4811fffff", "881f1d4817fffff"], name="h3_08") 640 | geometry = [Polygon(cell_to_boundary_lng_lat(h)) for h in index] 641 | expected = gpd.GeoDataFrame( 642 | {"val": [5, 3]}, geometry=geometry, index=index, crs="epsg:4326" 643 | ) 644 | 645 | assert_geodataframe_equal(expected, result) 646 | 647 | def test_h3_to_parent_aggregate_no_geometry(self, h3_dataframe_with_values): 648 | index = pd.Index(["881f1d4811fffff", "881f1d4817fffff"], name="h3_08") 649 | expected = pd.DataFrame({"val": [5, 3]}, index=index) 650 | result = h3_dataframe_with_values.h3.h3_to_parent_aggregate( 651 | 8, return_geometry=False 652 | ) 653 | pd.testing.assert_frame_equal(expected, result) 654 | 655 | 656 | class TestKRingSmoothing: 657 | def test_h3_k_ring_smoothing_k_vs_weighting(self, h3_dataframe_with_values): 658 | result_k = h3_dataframe_with_values.h3.k_ring_smoothing(2) 659 | result_weighted = h3_dataframe_with_values.h3.k_ring_smoothing( 660 | weights=[1, 1, 1] 661 | ) 662 | pd.testing.assert_frame_equal(result_k, result_weighted) 663 | 664 | def test_h3_k_ring_smoothing_0_ring(self, h3_dataframe_with_values): 665 | expected = h3_dataframe_with_values.copy().sort_index().astype({"val": float}) 666 | expected.index = expected.index.rename("h3_k_ring") 667 | result = h3_dataframe_with_values.h3.k_ring_smoothing(0, return_geometry=False) 668 | pd.testing.assert_frame_equal(expected, result) 669 | 670 | def test_h3_k_ring_smoothing_0_ring_weights(self, h3_dataframe_with_values): 671 | expected = h3_dataframe_with_values.copy().sort_index().astype({"val": float}) 672 | expected.index = expected.index.rename("h3_k_ring") 673 | result = h3_dataframe_with_values.h3.k_ring_smoothing( 674 | weights=[1], return_geometry=False 675 | ) 676 | pd.testing.assert_frame_equal(expected, result) 677 | 678 | def test_h3_k_ring_smoothing_2_ring(self, h3_dataframe_with_values): 679 | data = h3_dataframe_with_values.iloc[:1] 680 | expected = set([1 / 19]) 681 | result = set(data.h3.k_ring_smoothing(2)["val"]) 682 | assert expected == result 683 | 684 | def test_h3_k_ring_smoothing_1_ring_weighted(self, h3_dataframe_with_values): 685 | data = h3_dataframe_with_values.iloc[:1] 686 | expected = set([1 / 4, 1 / 8]) 687 | result = set(data.h3.k_ring_smoothing(weights=[2, 1])["val"]) 688 | assert expected == result 689 | 690 | def test_does_not_fail_if_geometry_present(self, h3_geodataframe_with_values): 691 | h3_geodataframe_with_values["geometry"] = [Point(0, 0)] * len( 692 | h3_geodataframe_with_values 693 | ) 694 | h3_geodataframe_with_values.h3.k_ring_smoothing(1) 695 | 696 | 697 | class TestPolyfillResample: 698 | def test_polyfill_resample(self, h3_geodataframe_with_values): 699 | expected_indices = set().union( 700 | *[ 701 | { 702 | "8a1f1d481747fff", 703 | "8a1f1d48174ffff", 704 | "8a1f1d481757fff", 705 | "8a1f1d48175ffff", 706 | "8a1f1d481767fff", 707 | "8a1f1d48176ffff", 708 | "8a1f1d481777fff", 709 | }, 710 | { 711 | "8a1f1d481647fff", 712 | "8a1f1d48164ffff", 713 | "8a1f1d481657fff", 714 | "8a1f1d48165ffff", 715 | "8a1f1d481667fff", 716 | "8a1f1d48166ffff", 717 | "8a1f1d481677fff", 718 | }, 719 | { 720 | "8a1f1d4810c7fff", 721 | "8a1f1d4810cffff", 722 | "8a1f1d4810d7fff", 723 | "8a1f1d4810dffff", 724 | "8a1f1d4810e7fff", 725 | "8a1f1d4810effff", 726 | "8a1f1d4810f7fff", 727 | }, 728 | ] 729 | ) 730 | expected_values = set([1, 2, 5]) 731 | result = h3_geodataframe_with_values.h3.polyfill_resample( 732 | 10, return_geometry=False 733 | ) 734 | assert len(result) == len(h3_geodataframe_with_values) * 7 735 | assert set(result.index) == expected_indices 736 | assert set(result["val"]) == expected_values 737 | assert not result["val"].isna().any() 738 | 739 | def test_polyfill_resample_uncovered_rows(self, basic_geodataframe_polygons): 740 | basic_geodataframe_polygons.iloc[1] = box(0, 0, 3, 3) 741 | with pytest.warns(UserWarning): 742 | result = basic_geodataframe_polygons.h3.polyfill_resample(2) 743 | 744 | assert len(result) == 2 745 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DahnJ/H3-Pandas/4c112ca143203e2e93be1042283ede6c9fe6de0c/tests/util/__init__.py -------------------------------------------------------------------------------- /tests/util/test_decorator.py: -------------------------------------------------------------------------------- 1 | import h3 2 | import pytest 3 | 4 | from h3pandas.util.decorator import catch_invalid_h3_address, sequential_deduplication 5 | 6 | 7 | class TestCatchInvalidH3Address: 8 | def test_catch_invalid_h3_address(self): 9 | @catch_invalid_h3_address 10 | def safe_h3_to_parent(h3_address): 11 | return h3.cell_to_parent(h3_address, 1) 12 | 13 | with pytest.raises(ValueError): 14 | safe_h3_to_parent("a") # Originally ValueError 15 | 16 | with pytest.raises(ValueError): 17 | safe_h3_to_parent(1) # Originally TypeError 18 | 19 | with pytest.raises(ValueError): 20 | safe_h3_to_parent("891f1d48177fff1") # Originally H3CellError 21 | 22 | 23 | class TestSequentialDeduplication: 24 | def test_catch_sequential_duplicate_h3_addresses(self): 25 | @sequential_deduplication 26 | def function_taking_iterator(iterator): 27 | yield from iterator 28 | 29 | _input = [1, 1, 2, 3, 3, 4, 5, 4, 3, 3, 2, 1, 1] 30 | result = function_taking_iterator(_input) 31 | assert list(result) == [1, 2, 3, 4, 5, 4, 3, 2, 1] 32 | -------------------------------------------------------------------------------- /tests/util/test_shapely.py: -------------------------------------------------------------------------------- 1 | from shapely.geometry import Polygon, MultiPolygon, LineString, MultiLineString 2 | import pytest 3 | from h3pandas.util.shapely import polyfill, linetrace 4 | 5 | 6 | @pytest.fixture 7 | def polygon(): 8 | return Polygon([(18, 48), (18, 49), (19, 49), (19, 48)]) 9 | 10 | 11 | @pytest.fixture 12 | def polygon_b(): 13 | return Polygon([(11, 54), (11, 56), (12, 56), (12, 54)]) 14 | 15 | 16 | @pytest.fixture 17 | def polygon_with_hole(): 18 | return Polygon( 19 | [(18, 48), (19, 48), (19, 49), (18, 49)], 20 | [[(18.2, 48.4), (18.6, 48.4), (18.6, 48.8), (18.2, 48.8)]], 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def multipolygon(polygon, polygon_b): 26 | return MultiPolygon([polygon, polygon_b]) 27 | 28 | 29 | @pytest.fixture 30 | def line(): 31 | return LineString([(0, 0), (1, 0), (1, 1)]) 32 | 33 | 34 | @pytest.fixture 35 | def multiline(): 36 | return MultiLineString([[(0, 0), (1, 0), (1, 1)], [(1, 1), (0, 1), (0, 0)]]) 37 | 38 | 39 | class TestPolyfill: 40 | def test_polyfill_polygon(self, polygon): 41 | expected = set(["811e3ffffffffff"]) 42 | result = polyfill(polygon, 1) 43 | assert expected == result 44 | 45 | def test_polyfill_multipolygon(self, multipolygon): 46 | expected = set(["811e3ffffffffff", "811f3ffffffffff"]) 47 | result = polyfill(multipolygon, 1) 48 | assert expected == result 49 | 50 | def test_polyfill_polygon_with_hole(self, polygon_with_hole): 51 | expected = set() 52 | result = polyfill(polygon_with_hole, 1) 53 | assert expected == result 54 | 55 | def test_polyfill_wrong_type(self, line): 56 | with pytest.raises(TypeError, match=".*Unknown type.*"): 57 | polyfill(line, 1) 58 | 59 | 60 | class TestLineTrace: 61 | def test_linetrace_linestring(self, line): 62 | expected = ["81757ffffffffff"] 63 | result = list(linetrace(line, 1)) 64 | assert expected == result 65 | 66 | expected2 = ["82754ffffffffff", "827547fffffffff"] 67 | result2 = list(linetrace(line, 2)) 68 | assert expected2 == result2 69 | 70 | def test_linetrace_multilinestring(self, multiline): 71 | expected = ["81757ffffffffff"] 72 | result = list(linetrace(multiline, 1)) 73 | assert expected == result 74 | 75 | # Lists not sets, repeated items are expected, just not in sequence 76 | expected2 = ["82754ffffffffff", "827547fffffffff", "82754ffffffffff"] 77 | result2 = list(linetrace(multiline, 2)) 78 | assert expected2 == result2 79 | --------------------------------------------------------------------------------