├── .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 |
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 | [](https://colab.research.google.com/github/DahnJ/H3-Pandas/blob/master/notebook/00-intro.ipynb)
10 | [](https://mybinder.org/v2/gh/DahnJ/H3-Pandas/HEAD?filepath=%2Fnotebook%2F00-intro.ipynb)
11 | [](https://opensource.org/licenses/MIT)
12 | [](https://pip.pypa.io/en/stable/?badge=stable)
13 |
14 |
15 |
16 |
17 | ---
18 |
19 |
22 |
23 | ---
24 |
25 |
26 |
27 |
28 |
29 | ## Installation
30 | ### pip
31 | [](https://pypi.python.org/pypi/h3pandas)
32 | ```bash
33 | pip install h3pandas
34 | ```
35 |
36 | ### conda
37 | []()
38 | [](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 |
--------------------------------------------------------------------------------