From 831d50bbe36bddd3ca789c2500b7c9c74779e0d8 Mon Sep 17 00:00:00 2001 From: AJ Friend Date: Sat, 30 Dec 2023 15:16:02 -0800 Subject: [PATCH] GeoJSON functions (#301) * polygons_to_cells * Helpers for cells_to_geojson and geojson_to_cells * ValueError * Remove gj (#316) * Remove geojson logic from cell_to_boundary and directed_edge_to_boundary * fix a warning about return values in tests from pytest * Remove geo_json logic from cells_to_multi_polygon * remove some unused tests * add back in polygons_to_cells * actions/setup-python@v4.5.0 * Solution copied from https://github.com/actions/setup-python/issues/544 * playing around with polygons * make a directory * bah * start migrating `h3.Polygon` to `H3Poly` * migrate most * finish migrating * H3MultiPoly repr * shape_to_cells and cells_to_shape * tests: polygon_to_cells to shape_to_cells * finish migrating tests * update util.py and example * notes * bah * fix lint * fix * remove X_to_shape * run tests * run other CIs too * don't use f-string * test update * change errors per review * remove unused per review * fix lint * test run * fix exclude logic in tests * " to ' in yaml * move conversion logic to geo_to_cells function * linting * few more tests * lint * clean up * complete tests * group geojson/polygon tests into a folder * move MockGeoInterface into the file that uses it * move test_input_format to its own file, since it has a lot of helper functions not used elsewhere * test module description * rename _polygon.py to _h3shape.py * make H3Shape an ABC * shape -> h3shape * geos to polys * rename geo files to latlng * docstrings * __init__.py * lint * notebooks * more interface tests * notebooks * merp * clean up geo_to_h3shape() * simplify sydney coords * simplify far east test * simplify sf coords * tuples * geo interface tests * more interface tests * clean up maine lat/lngs * messed up lat/lng order in tests. this mistake gets made all the time! * expand geo_to_h3shape test * plotting with contextily and geoviews * reprs for H3Shape objects should work better with geopandas * different representations of the same information. which is best? * us "loopcode" in H3Poly and H3MultiPoly reprs * set tight=True as default * can't use f-strings in python 3.5 * notebooks * basic plotting * plotting * plotting * tutorial notebook * cleaning up * clean up geoviews example * remove geoviews example * todos * reworking tutorial order * notebook dump * cleaner * move tutorial * Update src/h3/api/_api_template.py Co-authored-by: Jongbin Jung * Update src/h3/api/_api_template.py Co-authored-by: Isaac Brodsky * Update src/h3/api/_api_template.py Co-authored-by: Jongbin Jung * setting loops * bug and lint --------- Co-authored-by: Isaac Brodsky Co-authored-by: Jongbin Jung --- .flake8 | 2 + .github/workflows/coverage-lint.yml | 2 +- .github/workflows/tests.yml | 4 +- .github/workflows/wheels.yml | 26 +- docs/polygon_tutorial.ipynb | 936 +++++++++++++++++++++++ makefile | 2 +- requirements-dev.txt | 8 + src/h3/__init__.py | 1 - src/h3/_cy/CMakeLists.txt | 6 +- src/h3/_cy/__init__.py | 3 +- src/h3/_cy/{geo.pxd => latlng.pxd} | 0 src/h3/_cy/{geo.pyx => latlng.pyx} | 34 +- src/h3/_cy/to_multipoly.pyx | 17 +- src/h3/_h3shape.py | 311 ++++++++ src/h3/_polygon.py | 56 -- src/h3/api/_api_template.py | 109 ++- src/h3/api/basic_int/__init__.py | 10 +- src/h3/api/basic_int/_public_api.py | 6 +- src/h3/api/basic_str/__init__.py | 10 +- src/h3/api/memview_int/__init__.py | 10 +- src/h3/api/numpy_int/__init__.py | 10 +- tests/cython_example.pyx | 2 +- tests/polyfill/test_h3.py | 379 +++++++++ tests/polyfill/test_polyfill.py | 161 ++++ tests/polyfill/test_polyfill_ordering.py | 96 +++ tests/polyfill/test_polygon_class.py | 37 + tests/polyfill/test_to_multipoly.py | 43 ++ tests/test_cells_and_edges.py | 42 +- tests/test_h3.py | 290 +------ tests/test_polyfill.py | 240 ------ tests/test_polygon_class.py | 27 - tests/test_to_multipoly.py | 32 - 32 files changed, 2157 insertions(+), 755 deletions(-) create mode 100644 docs/polygon_tutorial.ipynb create mode 100644 requirements-dev.txt rename src/h3/_cy/{geo.pxd => latlng.pxd} (100%) rename src/h3/_cy/{geo.pyx => latlng.pyx} (92%) create mode 100644 src/h3/_h3shape.py delete mode 100644 src/h3/_polygon.py create mode 100644 tests/polyfill/test_h3.py create mode 100644 tests/polyfill/test_polyfill.py create mode 100644 tests/polyfill/test_polyfill_ordering.py create mode 100644 tests/polyfill/test_polygon_class.py create mode 100644 tests/polyfill/test_to_multipoly.py delete mode 100644 tests/test_polyfill.py delete mode 100644 tests/test_polygon_class.py delete mode 100644 tests/test_to_multipoly.py diff --git a/.flake8 b/.flake8 index fedf9fd9..3f950dd9 100644 --- a/.flake8 +++ b/.flake8 @@ -14,3 +14,5 @@ ignore = E241, # E731 do not assign a lambda expression, use a def E731, + # https://stackoverflow.com/questions/67942075/w504-line-break-after-binary-operator + W503,W504 diff --git a/.github/workflows/coverage-lint.yml b/.github/workflows/coverage-lint.yml index 0280c0cb..878239a5 100644 --- a/.github/workflows/coverage-lint.yml +++ b/.github/workflows/coverage-lint.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: ['*'] jobs: tests: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f694e47b..a5b4fec9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: ['*'] jobs: tests: @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-python@v4.5.0 with: - python-version: "${{ matrix.python-version }}" + python-version: '${{ matrix.python-version }}' ## Start Windows stuff - uses: ilammy/msvc-dev-cmd@v1.12.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index babcea26..5d4bc60c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + branches: ['*'] types: # Opened, synchronize, and reopened are the default types # We add ready_for_review to additionally trigger when converting from draft to non-draft @@ -19,7 +19,7 @@ on: jobs: make_sdist: - name: "SDist: ${{ matrix.os }}" + name: 'SDist: ${{ matrix.os }}' if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ${{ matrix.os }} @@ -57,41 +57,41 @@ jobs: path: ./dist make_cibw_v2_wheels: - name: "cibuildwheel v2: ${{ matrix.name }}" + name: 'cibuildwheel v2: ${{ matrix.name }}' if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} runs-on: ${{ matrix.os }} strategy: matrix: include: - os: macos-latest - build: "cp*-macosx*" + build: 'cp*-macosx*' name: macOS - os: windows-latest - build: "cp3*-win_amd64" + build: 'cp3*-win_amd64' name: Windows 64-bit - os: ubuntu-20.04 - build: "cp*-manylinux_x86_64" + build: 'cp*-manylinux_x86_64' name: Linux Intel glibc 64-bit - os: ubuntu-20.04 - build: "cp*-musllinux_x86_64" + build: 'cp*-musllinux_x86_64' name: Linux Intel musl 64-bit - os: ubuntu-20.04 - build: "cp36-manylinux_aarch64" + build: 'cp36-manylinux_aarch64' name: Linux Aarch64 3.6 - os: ubuntu-20.04 - build: "cp37-manylinux_aarch64" + build: 'cp37-manylinux_aarch64' name: Linux Aarch64 3.7 - os: ubuntu-20.04 - build: "cp38-manylinux_aarch64" + build: 'cp38-manylinux_aarch64' name: Linux Aarch64 3.8 - os: ubuntu-20.04 - build: "cp39-manylinux_aarch64" + build: 'cp39-manylinux_aarch64' name: Linux Aarch64 3.9 - os: ubuntu-20.04 - build: "cp310-manylinux_aarch64" + build: 'cp310-manylinux_aarch64' name: Linux Aarch64 3.10 - os: ubuntu-20.04 - build: "cp311-manylinux_aarch64" + build: 'cp311-manylinux_aarch64' name: Linux Aarch64 3.11 steps: diff --git a/docs/polygon_tutorial.ipynb b/docs/polygon_tutorial.ipynb new file mode 100644 index 00000000..fde0ce21 --- /dev/null +++ b/docs/polygon_tutorial.ipynb @@ -0,0 +1,936 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "03ee1f11-7a60-4761-aaf7-673ea04e7dca", + "metadata": {}, + "source": [ + "# H3 and (Multi)Polygons\n", + "\n", + "`h3-py` can convert between sets of cells and GeoJSON-like polygon and multipolygon shapes.\n", + "\n", + "We use the abstract base class `H3Shape` and its concrete child classes `H3Poly` and `H3MultiPoly`\n", + "to represent these shapes. \n", + "Any references or function names that use \"H3Shape\" will apply to both `H3Poly` and `H3MultiPoly` objects.\n", + "\n", + "`h3-py` is also compatible with Python objects that implement `__geo_interface__` (https://gist.github.com/sgillies/2217756),\n", + "making it easy to interface with other Python geospatial libraries.\n", + "We'll refer to any such object as a \"geo\" or \"geo object\".\n", + "\n", + "`H3Poly` and `H3MultiPoly` both implement `__geo_interface__` (making them geo objects themselves),\n", + "and can be created from other geo objects, like Shapely's `Polygon` or `MultiPolygon` objects that occur\n", + "when using `geopandas`.\n", + "\n", + "Below, we'll explain the `h3-py` API for working with these shapes, and how the library interacts\n", + "with other Python geospatial libraries.\n", + "\n", + "To start, we'll import relevant libraries, and define some plotting helper functions to visualize the shapes we're dealing with." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0f5454f0-f65e-491c-b534-f1ef08320263", + "metadata": {}, + "outputs": [], + "source": [ + "import h3\n", + "\n", + "import geopandas\n", + "import geodatasets\n", + "import contextily as cx\n", + "import matplotlib.pyplot as plt\n", + "\n", + "def plot_df(df, column=None, ax=None):\n", + " 'Plot based on the `geometry` column of a GeoPandas dataframe'\n", + " df = df.copy()\n", + " df = df.to_crs(epsg=3857) # web mercator\n", + "\n", + " if ax is None:\n", + " fig, ax = plt.subplots(figsize=(8,8))\n", + " ax.get_xaxis().set_visible(False)\n", + " ax.get_yaxis().set_visible(False)\n", + " \n", + " df.plot(\n", + " ax=ax,\n", + " alpha=0.5, edgecolor='k',\n", + " column=column, categorical=True,\n", + " legend=True, legend_kwds={'loc': 'upper left'}, \n", + " )\n", + " cx.add_basemap(ax, crs=df.crs, source=cx.providers.CartoDB.Positron)\n", + "\n", + "def plot_shape(shape, ax=None):\n", + " df = geopandas.GeoDataFrame({'geometry': [shape]}, crs='EPSG:4326')\n", + " plot_df(df, ax=ax)\n", + "\n", + "def plot_cells(cells, ax=None):\n", + " shape = h3.cells_to_h3shape(cells)\n", + " plot_shape(shape, ax=ax)\n", + "\n", + "def plot_shape_and_cells(shape, res=9):\n", + " fig, axs = plt.subplots(1,2, figsize=(10,5), sharex=True, sharey=True)\n", + " plot_shape(shape, ax=axs[0])\n", + " plot_cells(h3.h3shape_to_cells(shape, res), ax=axs[1])\n", + " fig.tight_layout()" + ] + }, + { + "cell_type": "markdown", + "id": "c5a41e2b-0c9e-45f5-8005-51af2ef8af32", + "metadata": {}, + "source": [ + "# H3Poly\n", + "\n", + "We can create a simple `H3Poly` object by providing a list of the **latitude/longitude pairs** that describe its exterior.\n", + "Optionally, holes can be added to the polygon by appending additional lat/lng lists do describe them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e9e3e2a-a4cf-43df-a498-ee7d15a8cc4b", + "metadata": {}, + "outputs": [], + "source": [ + "outer = [\n", + " (37.804, -122.412),\n", + " (37.778, -122.507),\n", + " (37.733, -122.501)\n", + "]\n", + "\n", + "poly = h3.H3Poly(outer)\n", + "print(poly)\n", + "plot_shape(poly)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6872b341-afc9-4405-b27a-b5f2b8a19847", + "metadata": {}, + "outputs": [], + "source": [ + "hole1 = [\n", + " (37.782, -122.449),\n", + " (37.779, -122.465),\n", + " (37.788, -122.454),\n", + "]\n", + "\n", + "poly = h3.H3Poly(outer, hole1)\n", + "print(poly)\n", + "plot_shape(poly)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2d7ae0fb-fa96-46fe-8d06-7778575642dd", + "metadata": {}, + "outputs": [], + "source": [ + "hole2 = [\n", + " (37.771, -122.484),\n", + " (37.761, -122.481),\n", + " (37.758, -122.494),\n", + " (37.769, -122.496),\n", + "]\n", + "\n", + "poly = h3.H3Poly(outer, hole1, hole2)\n", + "print(poly)\n", + "plot_shape(poly)" + ] + }, + { + "cell_type": "markdown", + "id": "62317cfd-8a0e-4c8a-b899-131acdec4305", + "metadata": {}, + "source": [ + "## String representation and attributes\n", + "\n", + "The `H3Poly` string representation given by its `__repr__` shows the number of vertices in the outer loop of the polygon, followed\n", + "by the number of vertices in each hole.\n", + "\n", + "A representation like\n", + "\n", + "```\n", + "\n", + "```\n", + "\n", + "denotes a polygon whose outer boundary consists of 3 vertices and which has 2 holes,\n", + "with 3 and 4 vertices respectively.\n", + "\n", + "We can access the coordinates describing the polygon through the attributes:\n", + "\n", + "- `H3Poly.outer` gives the list of lat/lng points making up the outer loop of the polygon.\n", + "- `H3Poly.holes` gives each of the lists of lat/lng points making up the holes of the polygon." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97c8aa44-12fb-4c47-9254-d931522575ed", + "metadata": {}, + "outputs": [], + "source": [ + "poly = h3.H3Poly(outer, hole1, hole2)\n", + "poly" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "150ae04c-bae2-4b7e-88d0-f5fcf0835e7b", + "metadata": {}, + "outputs": [], + "source": [ + "poly.outer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "84061e68-60f3-4df4-a279-a0630af7dcc2", + "metadata": {}, + "outputs": [], + "source": [ + "poly.holes" + ] + }, + { + "cell_type": "markdown", + "id": "ae175854-6770-44aa-b796-8d9390f7c856", + "metadata": {}, + "source": [ + "## `__geo_interface__`\n", + "\n", + "`H3Poly.__geo_interface__` gives a GeoJSON representation of the polygon as described in https://gist.github.com/sgillies/2217756\n", + "\n", + "**Note the differences in this representation**: Points are given in **lng/lat** order (but the `H3Poly` constructor expects **lat/lng** order), and the last vertex repeats the first." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb79c742-27aa-481c-971b-28c100109efe", + "metadata": {}, + "outputs": [], + "source": [ + "d = poly.__geo_interface__\n", + "d" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fc1b0ae8-bcce-4e5f-ad88-57e3797518f4", + "metadata": {}, + "outputs": [], + "source": [ + "print(poly.outer)\n", + "print(poly.holes)" + ] + }, + { + "cell_type": "markdown", + "id": "8f567fae-9f4e-472f-afcd-bd6f7bb32cba", + "metadata": {}, + "source": [ + "We can create an `H3Poly` object from a GeoJSON-like dictionary or an object that implements `__geo_interface__` using `h3.geo_to_h3shape()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42d7d580-2bba-4cd3-8d13-2434323e4081", + "metadata": {}, + "outputs": [], + "source": [ + "h3.geo_to_h3shape(d)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c70b6fa3-d85d-420e-a295-a75a32bf3dad", + "metadata": {}, + "outputs": [], + "source": [ + "class MockGeo:\n", + " def __init__(self, d):\n", + " self.d = d\n", + "\n", + " @property\n", + " def __geo_interface__(self):\n", + " return self.d\n", + "\n", + "geo = MockGeo(d)\n", + "h3.geo_to_h3shape(geo)" + ] + }, + { + "cell_type": "markdown", + "id": "0ab82119-9d9e-465d-94b5-61083e9d9df3", + "metadata": {}, + "source": [ + "Also note that `H3Poly.__geo_interface__` is equivalent to calling `h3.h3shape_to_geo()` on an `H3Poly` object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fd9bd77d-ed1e-46bb-991a-f0cfc10f634b", + "metadata": {}, + "outputs": [], + "source": [ + "poly.__geo_interface__ == h3.h3shape_to_geo(poly)" + ] + }, + { + "cell_type": "markdown", + "id": "55c31b56-c65d-4e06-bf60-edf96bb57e93", + "metadata": {}, + "source": [ + "## Polygon to cells\n", + "\n", + "We can get all the H3 cells whose centroids fall within an `H3Poly` by using `h3.h3shape_to_cells()` and specifying the resolution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c310200-30f2-4b24-a967-d705ffc05bd6", + "metadata": {}, + "outputs": [], + "source": [ + "h3.h3shape_to_cells(poly, res=7)" + ] + }, + { + "cell_type": "markdown", + "id": "ec0d8ac7-ba97-46c4-8740-3a12a00e318d", + "metadata": {}, + "source": [ + "We'll use a helper function to show a few different resolutions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ac7cd51-1c7e-4509-94b4-92e59fd22c2a", + "metadata": {}, + "outputs": [], + "source": [ + "plot_shape_and_cells(poly, 7)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0330d60-0adc-46e7-9689-7be1ba957dfc", + "metadata": {}, + "outputs": [], + "source": [ + "plot_shape_and_cells(poly, 9)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1fd40b0a-34fd-4ac7-9c3a-c367e5041cd8", + "metadata": {}, + "outputs": [], + "source": [ + "plot_shape_and_cells(poly, 10)" + ] + }, + { + "cell_type": "markdown", + "id": "faa6a726-2adc-40cc-9e1e-5d4ee66a08ea", + "metadata": {}, + "source": [ + "## H3 Polygons don't need to follow the right-hand rule\n", + "\n", + "`H3Poly` objects do not need to follow the \"right-hand rule\", unlike GeoJSON Polygons. \n", + "The right-hand rule requires that vertices in outer loops are listed in counterclockwise\n", + "order and holes are listed in clockwise order.\n", + "`h3-py` accepts loops in any order and will usually interpret them as the user intended, for example,\n", + "converting to sets of cells. However, `h3-py` won't re-order your loops to\n", + "conform to the right-hand rule, so be careful if you're using `__geo_interface__` to plot them.\n", + "\n", + "Obeying the right-hand rule is only a concern when creating `H3Poly` objects from external input; `H3Poly` or `H3MultiPoly`\n", + "objects created through `h3.cells_to_shape()` **will respect the right-hand rule**.\n", + "\n", + "For example, if we reverse the order of one of the holes in our example polygon above,\n", + "the hole won't be rendered correctly, but the conversion to cells will remain unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34ef8624-c852-419d-9e98-e9ec70ec6bc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Respects right-hand rule\n", + "poly = h3.H3Poly(outer, hole1, hole2)\n", + "plot_shape_and_cells(poly, res=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e52ae550-e34d-406b-9c82-b283fd34a170", + "metadata": {}, + "outputs": [], + "source": [ + "# Does not respect right-hand-rule; second hole is reversed\n", + "# Conversion to cells still works, tho!\n", + "poly = h3.H3Poly(outer, hole1[::-1], hole2)\n", + "plot_shape_and_cells(poly, res=10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f1940071-eb32-44bc-b07c-2a2eef14b21e", + "metadata": {}, + "outputs": [], + "source": [ + "# Does not respect right-hand-rule; outer loop and second hole are both reversed\n", + "# Conversion to cells still works, tho!\n", + "poly = h3.H3Poly(outer[::-1], hole1[::-1], hole2)\n", + "plot_shape_and_cells(poly, res=10)" + ] + }, + { + "cell_type": "markdown", + "id": "b0b13d4a-a376-4a8f-b314-898d9cf94c59", + "metadata": {}, + "source": [ + "# H3MultiPoly\n", + "\n", + "An `H3MultiPoly` can be created from `H3Poly` objects. The string representation of the `H3MultiPoly`\n", + "gives the number of vertices in the outer loop of each `H3Poly`, along with the number of vertices\n", + "in each hole (if there are any).\n", + "\n", + "For example `` represents an `H3MultiPoly` consisting of two `H3Poly` polygons:\n", + "\n", + "- the first polygon has 3 outer vertices and no holes\n", + "- the second polygon has 4 outer vertices and 1 hole with 5 vertices" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16b0b4b2-2cfb-461d-bd69-511c87e208a3", + "metadata": {}, + "outputs": [], + "source": [ + "poly1 = h3.H3Poly([(37.804, -122.412), (37.778, -122.507), (37.733, -122.501)])\n", + "poly2 = h3.H3Poly(\n", + " [(37.803, -122.408), (37.736, -122.491), (37.738, -122.380), (37.787, -122.39)],\n", + " [(37.760, -122.441), (37.772, -122.427), (37.773, -122.404), (37.758, -122.401), (37.745, -122.428)]\n", + ")\n", + "mpoly = h3.H3MultiPoly(poly1, poly2)\n", + "\n", + "print(poly1)\n", + "print(poly2)\n", + "print(mpoly)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f2faf991-9d3b-4e0a-8fd4-8c31e19fd07b", + "metadata": {}, + "outputs": [], + "source": [ + "plot_shape(mpoly)" + ] + }, + { + "cell_type": "markdown", + "id": "850912ae-85f1-470c-aeea-1140f549fc95", + "metadata": {}, + "source": [ + "## MultiPolygon to cells\n", + "\n", + "`h3.h3shape_to_cells()` works on both `H3MultiPoly` and `H3Poly` objects (both are subclasses of `H3Shape`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "944564c0-8198-49ae-81de-cf980dc4b348", + "metadata": {}, + "outputs": [], + "source": [ + "cells = h3.h3shape_to_cells(mpoly, res=9)\n", + "plot_cells(cells)" + ] + }, + { + "cell_type": "markdown", + "id": "f3712d99-cbe2-4f42-80e3-0acf3fcb8e25", + "metadata": {}, + "source": [ + "## H3MultiPoly affordances\n", + "\n", + "- Calling `len()` on an `H3MultiPoly` gives the number of polygons\n", + "- You can iterate through a `H3MultiPoly`, with the elements being the underlying `H3Poly`s" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "217d6e5a-bb8b-4923-b311-0f84ebf48872", + "metadata": {}, + "outputs": [], + "source": [ + "len(mpoly)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "03f6cd31-b18f-4fe7-b259-78741f12c95d", + "metadata": {}, + "outputs": [], + "source": [ + "for p in mpoly:\n", + " print(p)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4eca4cec-2881-4ed1-a65c-3aea38ac9c63", + "metadata": {}, + "outputs": [], + "source": [ + "list(mpoly)" + ] + }, + { + "cell_type": "markdown", + "id": "2fd0a70c-d0cf-487c-b492-6b459dc13a30", + "metadata": {}, + "source": [ + "## `__geo_interface__`\n", + "\n", + "`H3MultiPoly` implements `__geo_interface__`, and `H3MultiPoly` objects can also be created through `h3.geo_to_h3shape()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2731474c-b3f8-4438-8932-62af21f8d6cf", + "metadata": {}, + "outputs": [], + "source": [ + "d = mpoly.__geo_interface__\n", + "d" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b5cc947e-0672-492d-a4bf-f303afbb5cfa", + "metadata": {}, + "outputs": [], + "source": [ + "geo = MockGeo(d)\n", + "h3.geo_to_h3shape(geo)" + ] + }, + { + "cell_type": "markdown", + "id": "a2b71ee2-deed-4336-8b2c-2ab740555188", + "metadata": {}, + "source": [ + "# Cells to H3Poly or H3MultiPoly\n", + "\n", + "If you have a set of H3 cells that you would like to visualize, you may want to convert them to `H3Poly` or `H3MultiPoly` objects using `h3.cells_to_h3shape()` and then use `__geo_interface__` to get their GeoJSON representation. Or you could\n", + "use `h3.cells_to_geo()` to get the GeoJSON dictionary directly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a2305120-adf4-4cee-9e2a-58e793e55cfb", + "metadata": {}, + "outputs": [], + "source": [ + "h = h3.latlng_to_cell(37.804, -122.412, res=9)\n", + "cells = h3.grid_ring(h, 2)\n", + "cells" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3e242238-af2c-45a3-acd0-8daf3773a2bd", + "metadata": {}, + "outputs": [], + "source": [ + "plot_cells(cells)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9d124ff-4e49-4e34-a6be-4a49bc00bcd9", + "metadata": {}, + "outputs": [], + "source": [ + "h3.cells_to_h3shape(cells)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3ca5d18-a78a-4bc6-80d9-d98099da924d", + "metadata": {}, + "outputs": [], + "source": [ + "str(h3.cells_to_geo(cells))[:200]" + ] + }, + { + "cell_type": "markdown", + "id": "0ae6d480-4807-4ca1-ac20-4095a20ded31", + "metadata": {}, + "source": [ + "# API Summary\n", + "\n", + "```\n", + "\"Geo object\" <-> H3Shape <-> H3 cells\n", + "```\n", + "\n", + "\"Geo objects\" are any Python object that implements `__geo_interface__`. This is a widely used\n", + "standard in geospatial Python and is used by libraries like `geopandas` and Shapely.\n", + "\n", + "`H3Shape` is an abstract class implemented by `H3Poly` and `H3MultiPoly`, each of which\n", + "implement `__geo_interface__` and can be created from external \"Geo objects\":\n", + "\n", + "- `geo_to_h3shape()`\n", + "- `h3shape_to_geo()`\n", + "\n", + "`H3Shape` objects can be converted to and from sets of H3 cells:\n", + "\n", + "- `h3shape_to_cells()`\n", + "- `cells_to_h3shape()`\n", + "\n", + "For convience, we provide functions that hide the intermediate `H3Shape` representation:\n", + "\n", + "- `geo_to_cells()`\n", + "- `cells_to_geo()`\n", + "\n", + "## Equivalance notes\n", + "\n", + "Note that if an object `a` is an `H3Shape`\n", + "then the following two expressions are equivalent:\n", + "\n", + "- `h3shape_to_geo(a)`\n", + "- `a.__geo_interface__`\n", + "\n", + "Also note that if `a` is an `H3Shape`, then `a` \"passes through\" `geo_to_h3shape()`:\n", + "\n", + "```python\n", + "geo_to_h3shape(a) == a\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "5838f6c8-aacb-4813-be7c-756c87945476", + "metadata": {}, + "source": [ + "# Interfacing with GeoPandas and other libraries\n", + "\n", + "The `__geo_interface__` compatibility allows `h3-py` to work with `geopandas` and other geospatial libraries easily.\n", + "\n", + "To demonstrate, we start with a GeoPandas `GeoDataFrame` describing the five New York City boroughs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a872e2a1-dd51-4262-bc98-800500121bc0", + "metadata": {}, + "outputs": [], + "source": [ + "df = geopandas.read_file(geodatasets.get_path('nybb'))\n", + "df" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bbca69dc-1722-4be4-a1bb-329d7e77ec83", + "metadata": {}, + "outputs": [], + "source": [ + "plot_df(df, column='BoroName')" + ] + }, + { + "cell_type": "markdown", + "id": "a060b933-8f32-402b-ac61-7cd04dcf70df", + "metadata": {}, + "source": [ + "## Use a compatible CRS before applying H3 functions\n", + "\n", + "The function `h3.geo_to_cells(geo, res)` takes some \"geo object\" that implements `__geo_interface__` (https://gist.github.com/sgillies/2217756) — like a Shapely Polygon or MultiPolygon — and converts it to the set of cells whose centroids are contained in `geo`.\n", + "\n", + "**Common Pitfall**: Be careful about what CRS you are using. `h3-py` expects coordinates as latitude-longitude pairs. The CRS of the current dataframe (EPSG:2263) gives coordinates in feet! You should first convert the data to something compatible. A common choice is **EPSG:4326/WGS84**. You'll get incorrect results if you apply `h3.geo_to_cells` without converting." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "83fced81-89f1-4795-84b8-c852c8c3f938", + "metadata": {}, + "outputs": [], + "source": [ + "# Original CRS\n", + "df.crs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dfaf3b8a-2e53-4adf-87a5-ef9fe616df32", + "metadata": {}, + "outputs": [], + "source": [ + "df.geometry[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "00e1026a-0a0c-448f-9223-8ce6d4d5a9a3", + "metadata": {}, + "outputs": [], + "source": [ + "# Converting to EPSG:4326/WGS84\n", + "df = df.to_crs(epsg=4326)\n", + "df.crs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81071108-3c13-4e10-8608-892ed3742054", + "metadata": {}, + "outputs": [], + "source": [ + "# Note that the `geometry` column now has coordinates in degrees\n", + "df" + ] + }, + { + "cell_type": "markdown", + "id": "01ca62b0-61f3-4204-bb0f-a14c9859cd4d", + "metadata": {}, + "source": [ + "## Converting a geo to H3 cells\n", + "\n", + "First, we select one of the boroughs and get the Shapely `MultiPolygon` describing it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0be200e1-2536-4430-90ef-38dc72a75f9f", + "metadata": {}, + "outputs": [], + "source": [ + "geo = df.geometry[0]\n", + "type(geo)" + ] + }, + { + "cell_type": "markdown", + "id": "0a3c3c9c-b547-4a69-9030-fb88f486c4aa", + "metadata": {}, + "source": [ + "Note that Shapely `MultiPolygon` objects implement `__geo_interface__`, so we can use `h3.geo_to_cells()` to get the associated set of H3 cells at various resolutions." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1a12743-b5e2-4cd1-a7fd-848e920fa928", + "metadata": {}, + "outputs": [], + "source": [ + "h3.geo_to_cells(geo, res=6)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27d24ca5-123f-4462-b37d-bcba6acc4f64", + "metadata": {}, + "outputs": [], + "source": [ + "plot_cells(h3.geo_to_cells(geo, res=9))" + ] + }, + { + "cell_type": "markdown", + "id": "e00d088e-47b9-4bca-a8b3-d22dab747be3", + "metadata": {}, + "source": [ + "## Converting all geos in a GeoDataFrame to cells\n", + "\n", + "We can apply `geo_to_cells()` to each of the Shapely geometries in the `df.geometry` column." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f9901cc3-cd8d-4543-a8fb-98178010700e", + "metadata": {}, + "outputs": [], + "source": [ + "cell_column = df.geometry.apply(lambda x: h3.geo_to_cells(x, res=8))\n", + "cell_column" + ] + }, + { + "cell_type": "markdown", + "id": "502efd48-2c0b-4860-8796-7ea2e4e88687", + "metadata": {}, + "source": [ + "## Converting cells to \"geo objects\"\n", + "\n", + "If we assign `df.geometry = cell_column` we'll get an error because the `geometry` column of a `geopandas.GeoDataFrame` must contain valid geometry objects.\n", + "We can obtain compatible objects by converting the cells to `H3Shape` by applying `h3.cells_to_h3shape()`.\n", + "\n", + "(Note that, unfortunately, Pandas has some logic to identify iterable members of a series and then renders a tuple of the elements, rather than our preferred `H3MultiPoly.__repr__` representation.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c5cb00c-9796-4356-8e2a-8dc50c86103c", + "metadata": {}, + "outputs": [], + "source": [ + "shape_column = cell_column.apply(h3.cells_to_h3shape)\n", + "shape_column" + ] + }, + { + "cell_type": "markdown", + "id": "1965d53f-2bba-4840-a627-1bbf55405be3", + "metadata": {}, + "source": [ + "Note that the column now consists of `H3Poly` and `H3MultiPoly` objects." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9b3eedce-2570-4708-992e-e6d739eecc2f", + "metadata": {}, + "outputs": [], + "source": [ + "shape_column[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "014bc63d-5cfa-4a65-95e6-390ddddd1703", + "metadata": {}, + "outputs": [], + "source": [ + "shape_column[1]" + ] + }, + { + "cell_type": "markdown", + "id": "6ae6afb2-8267-492a-836e-cf371df91e89", + "metadata": {}, + "source": [ + "Now, if we assign `df.geometry = shape_column`, our `H3Shape` objects will automatically be converted to Shapely Polygon and MultiPolygon objects via `__geo_interface__`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c5cd4cf-226b-405f-9c36-7f1ccc09c342", + "metadata": {}, + "outputs": [], + "source": [ + "df.geometry = shape_column\n", + "df.geometry" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2608ce79-f2c5-4dbc-bf22-a28255343aea", + "metadata": {}, + "outputs": [], + "source": [ + "type(df.geometry[0])" + ] + }, + { + "cell_type": "markdown", + "id": "d568b20e-88cb-4269-99ce-dc96a73d93d1", + "metadata": {}, + "source": [ + "## Visualizing H3 cells\n", + "\n", + "We took some Shapely geometry objects, converted them to H3 cells, and converted those back to Shapely geometry objects in a `geopandas.GeoDataFrame`. \n", + "\n", + "We can visualize the results with our helper function." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63ab3ec1-49c2-482d-8b84-154dd8196eda", + "metadata": {}, + "outputs": [], + "source": [ + "plot_df(df, column='BoroName')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd012d79-a653-4d5d-bb30-408031301015", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/makefile b/makefile index cf718603..a2f59da8 100644 --- a/makefile +++ b/makefile @@ -45,5 +45,5 @@ lint: ./env/bin/pylint --disable=all --enable=import-error tests/ lab: - ./env/bin/pip install jupyterlab + ./env/bin/pip install -r requirements-dev.txt ./env/bin/jupyter lab diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..c6ffe776 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +jupyterlab +jupyterlab-geojson +geopandas +geodatasets +matplotlib +contextily +cartopy +geoviews diff --git a/src/h3/__init__.py b/src/h3/__init__.py index 366ab02a..4f7cb32f 100644 --- a/src/h3/__init__.py +++ b/src/h3/__init__.py @@ -2,7 +2,6 @@ from .api.basic_str import * from ._version import __version__ -from ._polygon import Polygon from ._cy import ( UnknownH3ErrorCode, diff --git a/src/h3/_cy/CMakeLists.txt b/src/h3/_cy/CMakeLists.txt index 3cf59d59..5d392d2c 100644 --- a/src/h3/_cy/CMakeLists.txt +++ b/src/h3/_cy/CMakeLists.txt @@ -18,7 +18,7 @@ endmacro() # GLOB pattern is recommended against # https://cmake.org/cmake/help/v3.14/command/file.html?highlight=file#filesystem add_cython_file(util) -add_cython_file(geo) +add_cython_file(latlng) add_cython_file(cells) add_cython_file(edges) add_cython_file(to_multipoly) @@ -33,8 +33,8 @@ install( cells.pyx edges.pxd edges.pyx - geo.pxd - geo.pyx + latlng.pxd + latlng.pyx h3lib.pxd util.pxd util.pyx diff --git a/src/h3/_cy/__init__.py b/src/h3/_cy/__init__.py index b3647aa4..c208711e 100644 --- a/src/h3/_cy/__init__.py +++ b/src/h3/_cy/__init__.py @@ -50,10 +50,11 @@ edge_length, ) -from .geo import ( +from .latlng import ( latlng_to_cell, cell_to_latlng, polygon_to_cells, + polygons_to_cells, cell_to_boundary, directed_edge_to_boundary, great_circle_distance, diff --git a/src/h3/_cy/geo.pxd b/src/h3/_cy/latlng.pxd similarity index 100% rename from src/h3/_cy/geo.pxd rename to src/h3/_cy/latlng.pxd diff --git a/src/h3/_cy/geo.pyx b/src/h3/_cy/latlng.pyx similarity index 92% rename from src/h3/_cy/geo.pyx rename to src/h3/_cy/latlng.pyx index a606c418..520b950b 100644 --- a/src/h3/_cy/geo.pyx +++ b/src/h3/_cy/latlng.pyx @@ -172,7 +172,27 @@ def polygon_to_cells(outer, int res, holes=None): return mv -def cell_to_boundary(H3int h, bool geo_json=False): +def polygons_to_cells(polygons, int res): + mvs = [ + polygon_to_cells(outer=poly.outer, res=res, holes=poly.holes) + for poly in polygons + ] + + n = sum(map(len, mvs)) + hmm = H3MemoryManager(n) + + # probably super inefficient, but it is working! + # tood: move this to C + k = 0 + for mv in mvs: + for v in mv: + hmm.ptr[k] = v + k += 1 + + return hmm.to_mv() + + +def cell_to_boundary(H3int h): """Compose an array of geo-coordinates that outlines a hexagonal cell""" cdef: h3lib.CellBoundary gb @@ -186,15 +206,10 @@ def cell_to_boundary(H3int h, bool geo_json=False): for i in range(gb.num_verts) ) - if geo_json: - #lat/lng -> lng/lat and last point same as first - verts += (verts[0],) - verts = tuple(v[::-1] for v in verts) - return verts -def directed_edge_to_boundary(H3int edge, bool geo_json=False): +def directed_edge_to_boundary(H3int edge): """ Returns the CellBoundary containing the coordinates of the edge """ cdef: @@ -210,11 +225,6 @@ def directed_edge_to_boundary(H3int edge, bool geo_json=False): for i in range(gb.num_verts) ) - if geo_json: - #lat/lng -> lng/lat and last point same as first - verts += (verts[0],) - verts = tuple(v[::-1] for v in verts) - return verts diff --git a/src/h3/_cy/to_multipoly.pyx b/src/h3/_cy/to_multipoly.pyx index d19bda82..db29baa5 100644 --- a/src/h3/_cy/to_multipoly.pyx +++ b/src/h3/_cy/to_multipoly.pyx @@ -50,26 +50,11 @@ def _to_multi_polygon(const H3int[:] cells): return out -def _geojson_loop(loop): - """ Swap lat/lng order and close loop. - """ - loop = [e[::-1] for e in loop] - loop += [loop[0]] - - return loop - - -def cells_to_multi_polygon(const H3int[:] cells, geo_json=False): +def cells_to_multi_polygon(const H3int[:] cells): # todo: gotta be a more elegant way to handle these... if len(cells) == 0: return [] multipoly = _to_multi_polygon(cells) - if geo_json: - multipoly = [ - [_geojson_loop(loop) for loop in poly] - for poly in multipoly - ] - return multipoly diff --git a/src/h3/_h3shape.py b/src/h3/_h3shape.py new file mode 100644 index 00000000..353887f1 --- /dev/null +++ b/src/h3/_h3shape.py @@ -0,0 +1,311 @@ +from abc import ABCMeta, abstractmethod + + +class H3Shape(metaclass=ABCMeta): + @property + @abstractmethod + def __geo_interface__(self): + """ https://github.com/pytest-dev/pytest-cov/issues/428 """ + + +class H3Poly(H3Shape): + """ + Container for loops of lat/lng points describing a polygon. + + Attributes + ---------- + outer : list[tuple[float, float]] + List of lat/lng points describing the outer loop of the polygon + + holes : list[list[tuple[float, float]]] + List of loops of lat/lng points describing the holes of the polygon + + Examples + -------- + + A polygon with a single outer ring consisting of 4 points, having no holes: + + >>> H3Poly( + ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], + ... ) + + + The same polygon, but with one hole consisting of 3 points: + + >>> H3Poly( + ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], + ... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)], + ... ) + + + The same as above, but with one additional hole, made up of 5 points: + + >>> H3Poly( + ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], + ... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)], + ... [(37.71, -122.43), (37.71, -122.37), (37.73, -122.37), (37.75, -122.41), + ... (37.73, -122.43)], + ... ) + + """ + def __init__(self, outer, *holes): + loops = [outer] + list(holes) + for loop in loops: + if len(loop) in (1, 2): + raise ValueError('Non-empty H3Poly loops need at least 3 points.') + + self.outer = tuple(_open_ring(outer)) + self.holes = tuple( + _open_ring(hole) + for hole in holes + ) + + def __repr__(self): + return ''.format(self.loopcode) + + def __len__(self): + """ + Should this be the number of points in the outer loop, + the number of holes (or +1 for the outer loop)? + """ + raise NotImplementedError('No clear definition of length for H3Poly.') + + @property + def loopcode(self): + """ Short code for describing the length of the outer loop and each hole + + Example: `[382/(18, 6, 6)]` indicates an outer loop of 382 points, + along with 3 holes with 18, 6, and 6 points, respectively. + + Example: `[15]` indicates an outer loop of 15 points and no holes. + """ + outer = len(self.outer) + holes = tuple(map(len, self.holes)) + + outer = str(outer) + + if holes: + out = outer + '/' + str(holes) + else: + out = outer + + return '[' + out + ']' + + @property + def __geo_interface__(self): + ll2 = _polygon_to_LL2(self) + gj_dict = _LL2_to_geojson_dict(ll2) + + return gj_dict + + +class H3MultiPoly(H3Shape): + def __init__(self, *polys): + self.polys = tuple(polys) + + for p in self.polys: + if not isinstance(p, H3Poly): + raise ValueError('H3MultiPoly requires each input to be an H3Poly object, instead got: ' + str(p)) # noqa + + def __repr__(self): + out = [p.loopcode for p in self.polys] + out = ', '.join(out) + out = ''.format(out) + return out + + def __iter__(self): + return iter(self.polys) + + def __len__(self): + """ Give the number of polygons in this multi-polygon. + """ + + """ + TODO: Pandas series or dataframe representation changes depending + on if __len__ is defined. + + I'd prefer the one that states `H3MultiPoly`. It seems like Pandas is assuming + an iterable is best-described by its elements when choosing the representation. + + when __len__ *IS NOT* defined: + + 0 + 1 , , ) + 1 (, , , , , , <... + """ + return len(self.polys) + + def __getitem__(self, index): + return self.polys[index] + + @property + def __geo_interface__(self): + ll3 = _mpoly_to_LL3(self) + gj_dict = _LL3_to_geojson_dict(ll3) + + return gj_dict + + +""" +Helpers for cells_to_geojson and geojson_to_cells. + +Dealing with GeoJSON Polygons and MultiPolygons can be confusing because +there are so many nested lists. To help keep track, we use the following +symbols to denote different levels of nesting. + +LL0: lat/lng or lng/lat pair +LL1: list of LL0s +LL2: list of LL1s (i.e., a polygon with holes) +LL3: list of LL2s (i.e., several polygons with holes) + + +## TODO + +- Allow user to specify "container" in `cells_to_geojson`. + - That is, they may want a MultiPolygon even if the output fits in a Polygon + - 'auto', Polygon, MultiPolygon, FeatureCollection, GeometryCollection, ... +""" + + +def _mpoly_to_LL3(mpoly): + ll3 = tuple( + _polygon_to_LL2(poly) + for poly in mpoly + ) + + return ll3 + + +def _LL3_to_mpoly(ll3): + polys = [ + _LL2_to_polygon(ll2) + for ll2 in ll3 + ] + + mpoly = H3MultiPoly(*polys) + + return mpoly + + +def _polygon_to_LL2(h3poly): + ll2 = [h3poly.outer] + list(h3poly.holes) + ll2 = tuple( + _close_ring(_swap_latlng(ll1)) + for ll1 in ll2 + ) + + return ll2 + + +def _LL2_to_polygon(ll2): + ll2 = [ + _swap_latlng(ll1) + for ll1 in ll2 + ] + h3poly = H3Poly(*ll2) + + return h3poly + + +def _LL2_to_geojson_dict(ll2): + gj_dict = { + 'type': 'Polygon', + 'coordinates': ll2, + } + + return gj_dict + + +def _LL3_to_geojson_dict(ll3): + gj_dict = { + 'type': 'MultiPolygon', + 'coordinates': ll3, + } + + return gj_dict + + +def _swap_latlng(ll1): + ll1 = tuple( + (b, a) for a, b in ll1 + ) + return ll1 + + +def _close_ring(ll1): + """ + Idempotent + """ + if ll1 and (ll1[0] != ll1[-1]): + ll1 = tuple(ll1) + (ll1[0],) + + return ll1 + + +def _open_ring(ll1): + """ + Idempotent + """ + if ll1 and (ll1[0] == ll1[-1]): + ll1 = ll1[:-1] + + return ll1 + + +def geo_to_h3shape(geo): + """ + Translate from __geo_interface__ to H3Shape. + + `geo` either implements `__geo_interface__` or is a dict matching the format + + Returns + ------- + H3Shape + """ + + # geo can be dict, a __geo_interface__, a string, H3Poly or H3MultiPoly + if isinstance(geo, H3Shape): + return geo + + if hasattr(geo, '__geo_interface__'): + # get dict + geo = geo.__geo_interface__ + + assert isinstance(geo, dict) # todo: remove + + t = geo['type'] + coord = geo['coordinates'] + + if t == 'Polygon': + ll2 = coord + h3shape = _LL2_to_polygon(ll2) + elif t == 'MultiPolygon': + ll3 = coord + h3shape = _LL3_to_mpoly(ll3) + else: + raise ValueError('Unrecognized type: ' + str(t)) + + return h3shape + + +def h3shape_to_geo(h3shape): + """ + Translate from H3Shape to a __geo_interface__ dict. + + `h3shape` should be either H3Poly or H3MultiPoly + + Returns + ------- + dict + """ + return h3shape.__geo_interface__ diff --git a/src/h3/_polygon.py b/src/h3/_polygon.py deleted file mode 100644 index c36c5699..00000000 --- a/src/h3/_polygon.py +++ /dev/null @@ -1,56 +0,0 @@ -class Polygon: - """ - Container for loops of lat/lng points describing a polygon. - - Attributes - ---------- - outer : list[tuple[float, float]] - List of lat/lng points describing the outer loop of the Polygon - - holes : list[list[tuple[float, float]]] - List of loops of lat/lng points describing the holes of the Polygon - - Examples - -------- - - A polygon with a single outer ring consisting of 4 points, having no holes: - - >>> h3.Polygon( - ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], - ... ) - - - The same polygon, but with one hole consisting of 3 points: - - >>> h3.Polygon( - ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], - ... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)], - ... ) - - - The same as above, but with one additional hole, made up of 5 points: - - >>> h3.Polygon( - ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), (37.82, -122.54)], - ... [(37.76, -122.51), (37.76, -122.44), (37.81, -122.51)], - ... [(37.71, -122.43), (37.71, -122.37), (37.73, -122.37), (37.75, -122.41), - ... (37.73, -122.43)], - ... ) - - - Notes - ----- - - - TODO: Add GeoJSON translation support. - """ - def __init__(self, outer, *holes): - self.outer = outer - self.holes = holes - - def __repr__(self): - s = ''.format( - len(self.outer), - tuple(map(len, self.holes)), - ) - - return s diff --git a/src/h3/api/_api_template.py b/src/h3/api/_api_template.py index ea490922..1b7fd0d8 100644 --- a/src/h3/api/_api_template.py +++ b/src/h3/api/_api_template.py @@ -38,7 +38,12 @@ """ from .. import _cy -from .._polygon import Polygon +from .._h3shape import ( + H3Poly, + H3MultiPoly, + geo_to_h3shape, + h3shape_to_geo, +) class _API_FUNCTIONS(object): @@ -272,25 +277,19 @@ def grid_distance(self, h1, h2): return d - def cell_to_boundary(self, h, geo_json=False): + def cell_to_boundary(self, h): """ Return tuple of lat/lng pairs describing the cell boundary. Parameters ---------- h : H3Cell - geo_json : bool, optional - If ``True``, return output in GeoJson format: - lng/lat pairs (opposite order), and - have the last pair be the same as the first. - If ``False`` (default), return lat/lng pairs, with the last - pair distinct from the first. Returns ------- - tuple of (float, float) tuples + tuple of (lat, lng) tuples """ - return _cy.cell_to_boundary(self._in_scalar(h), geo_json) + return _cy.cell_to_boundary(self._in_scalar(h)) def grid_disk(self, h, k=1): """ @@ -397,27 +396,29 @@ def uncompact_cells(self, cells, res): return self._out_unordered(hu) - def polygon_to_cells(self, polygon, res): + def h3shape_to_cells(self, h3shape, res): """ Return the set of H3 cells at a given resolution whose center points - are contained within a `h3.Polygon` + are contained within an `H3Poly` or `H3MultiPoly`. Parameters ---------- - Polygon : h3.Polygon - A polygon described by an outer ring and optional holes - + h3shape : H3shape res : int Resolution of the output cells + Returns + ------- + unordered collection of H3Cell + Examples -------- - >>> poly = h3.Polygon( + >>> poly = H3Poly( ... [(37.68, -122.54), (37.68, -122.34), (37.82, -122.34), ... (37.82, -122.54)], ... ) - >>> h3.polygon_to_cells(poly, 6) + >>> h3.h3shape_to_cells(poly, 6) {'862830807ffffff', '862830827ffffff', '86283082fffffff', @@ -426,43 +427,79 @@ def polygon_to_cells(self, polygon, res): '862830957ffffff', '86283095fffffff'} """ - mv = _cy.polygon_to_cells(polygon.outer, res, holes=polygon.holes) - return self._out_unordered(mv) + # todo: not sure if i want this dispatch logic here. maybe in the objects? + if isinstance(h3shape, H3Poly): + poly = h3shape + mv = _cy.polygon_to_cells(poly.outer, res, holes=poly.holes) + elif isinstance(h3shape, H3MultiPoly): + mpoly = h3shape + mv = _cy.polygons_to_cells(mpoly.polys, res) + else: + raise ValueError('Unrecognized type: ' + str(type(h3shape))) - # def polygons_to_cells(self, polygons, res): - # # todo: have to figure out how to concat memoryviews cleanly - # # or some other approach - # pass + return self._out_unordered(mv) - def cells_to_polygons(self, cells): + def cells_to_h3shape(self, cells, tight=True): """ - Return a list of h3.Polygon objects describing the area - covered by a set of H3 cells. + Return a H3MultiPoly describing the area covered by a set of H3 cells. Parameters ---------- cells : iterable of H3 cells + tight : bool + If True, return H3Poly if possible. If False, always return H3MultiPoly Returns ------- - list[h3.Polygon] - List of h3.Polygon objects + H3Poly | H3MultiPoly Examples -------- >>> cells = ['8428309ffffffff', '842830dffffffff'] - >>> h3.cells_to_polygons(cells) - [] - + >>> h3.cells_to_h3shape(cells, tight=True) + + >>> h3.cells_to_h3shape(cells, tight=False) + """ cells = self._in_collection(cells) - geos = _cy.cells_to_multi_polygon(cells) + mpoly = _cy.cells_to_multi_polygon(cells) + + polys = [H3Poly(*poly) for poly in mpoly] + out = H3MultiPoly(*polys) + + if tight and len(out) == 1: + out = out[0] - polys = [Polygon(*geo) for geo in geos] + return out - return polys + def geo_to_cells(self, geo, res): + """ Convert from __geo_interface__ to cells. + + Parameters + ---------- + geo : an object implementing `__geo_interface__` or a dictionary in that format. + Both H3Poly and H3MultiPoly implement the interface. + res : int + Resolution of desired output cells. + """ + h3shape = geo_to_h3shape(geo) + return self.h3shape_to_cells(h3shape, res) + + def cells_to_geo(self, cells, tight=True): + """ + Parameters + ---------- + cells : iterable of H3 Cells + + Returns + ------- + dict + in `__geo_interface__` format + """ + h3shape = self.cells_to_h3shape(cells, tight=tight) + return h3shape_to_geo(h3shape) def is_pentagon(self, h): """ @@ -625,8 +662,8 @@ def origin_to_directed_edges(self, origin): return self._out_unordered(mv) - def directed_edge_to_boundary(self, edge, geo_json=False): - return _cy.directed_edge_to_boundary(self._in_scalar(edge), geo_json=geo_json) + def directed_edge_to_boundary(self, edge): + return _cy.directed_edge_to_boundary(self._in_scalar(edge)) def grid_path_cells(self, start, end): """ diff --git a/src/h3/api/basic_int/__init__.py b/src/h3/api/basic_int/__init__.py index 977b40bd..a2974611 100644 --- a/src/h3/api/basic_int/__init__.py +++ b/src/h3/api/basic_int/__init__.py @@ -1 +1,9 @@ -from ._public_api import * # noqa +# flake8: noqa +from ._public_api import * +from ..._h3shape import ( + H3MultiPoly, + H3Poly, + H3Shape, + geo_to_h3shape, + h3shape_to_geo, +) diff --git a/src/h3/api/basic_int/_public_api.py b/src/h3/api/basic_int/_public_api.py index 6f1b5527..8e87df1b 100644 --- a/src/h3/api/basic_int/_public_api.py +++ b/src/h3/api/basic_int/_public_api.py @@ -47,8 +47,10 @@ compact_cells = _b.compact_cells uncompact_cells = _b.uncompact_cells -polygon_to_cells = _b.polygon_to_cells -cells_to_polygons = _b.cells_to_polygons +h3shape_to_cells = _b.h3shape_to_cells +cells_to_h3shape = _b.cells_to_h3shape +cells_to_geo = _b.cells_to_geo +geo_to_cells = _b.geo_to_cells are_neighbor_cells = _b.are_neighbor_cells cells_to_directed_edge = _b.cells_to_directed_edge diff --git a/src/h3/api/basic_str/__init__.py b/src/h3/api/basic_str/__init__.py index 977b40bd..a2974611 100644 --- a/src/h3/api/basic_str/__init__.py +++ b/src/h3/api/basic_str/__init__.py @@ -1 +1,9 @@ -from ._public_api import * # noqa +# flake8: noqa +from ._public_api import * +from ..._h3shape import ( + H3MultiPoly, + H3Poly, + H3Shape, + geo_to_h3shape, + h3shape_to_geo, +) diff --git a/src/h3/api/memview_int/__init__.py b/src/h3/api/memview_int/__init__.py index 977b40bd..a2974611 100644 --- a/src/h3/api/memview_int/__init__.py +++ b/src/h3/api/memview_int/__init__.py @@ -1 +1,9 @@ -from ._public_api import * # noqa +# flake8: noqa +from ._public_api import * +from ..._h3shape import ( + H3MultiPoly, + H3Poly, + H3Shape, + geo_to_h3shape, + h3shape_to_geo, +) diff --git a/src/h3/api/numpy_int/__init__.py b/src/h3/api/numpy_int/__init__.py index 977b40bd..a2974611 100644 --- a/src/h3/api/numpy_int/__init__.py +++ b/src/h3/api/numpy_int/__init__.py @@ -1 +1,9 @@ -from ._public_api import * # noqa +# flake8: noqa +from ._public_api import * +from ..._h3shape import ( + H3MultiPoly, + H3Poly, + H3Shape, + geo_to_h3shape, + h3shape_to_geo, +) diff --git a/tests/cython_example.pyx b/tests/cython_example.pyx index 8e58d2c8..0185f760 100644 --- a/tests/cython_example.pyx +++ b/tests/cython_example.pyx @@ -2,7 +2,7 @@ from cython cimport boundscheck, wraparound from h3._cy.h3lib cimport H3int -from h3._cy.geo cimport latlng_to_cell +from h3._cy.latlng cimport latlng_to_cell @boundscheck(False) @wraparound(False) diff --git a/tests/polyfill/test_h3.py b/tests/polyfill/test_h3.py new file mode 100644 index 00000000..c38ff5f8 --- /dev/null +++ b/tests/polyfill/test_h3.py @@ -0,0 +1,379 @@ +import h3 +import pytest + + +class MockGeoInterface: + def __init__(self, dictionary): + assert isinstance(dictionary, dict) + self.dictionary = dictionary + + @property + def __geo_interface__(self): + return self.dictionary + + +def latlng_open(): + return [ + [37.813, -122.408], + [37.707, -122.512], + [37.815, -122.479], + ] + + +def latlng_closed(): + loop = latlng_open() + return loop + [loop[0]] + + +def swap_latlng(ll1): + ll1 = tuple( + (b, a) for a, b in ll1 + ) + return ll1 + + +def lnglat_open(): + return swap_latlng(latlng_open()) + + +def lnglat_closed(): + return swap_latlng(latlng_closed()) + + +def get_mocked(loop): + geo = MockGeoInterface({ + 'type': 'Polygon', + 'coordinates': [loop] + }) + + return geo + + +sf_7x7 = [ + (37.813319, -122.408987), + (37.786630, -122.380544), + (37.719806, -122.354474), + (37.707613, -122.512344), + (37.783587, -122.524719), + (37.815157, -122.479877), +] + +sf_hole1 = [ + (37.786980, -122.447120), + (37.766410, -122.459078), + (37.771068, -122.413710), +] + +sf_hole2 = [ + (37.747976, -122.490025), + (37.731550, -122.503758), + (37.725440, -122.452603), +] + + +def test_geo_interface(): + poly = h3.H3Poly(sf_hole1) + mpoly = h3.H3MultiPoly(poly) + + assert poly.__geo_interface__['type'] == 'Polygon' + assert mpoly.__geo_interface__['type'] == 'MultiPolygon' + + assert ( + poly.__geo_interface__['coordinates'] + == + mpoly.__geo_interface__['coordinates'][0] + ) + + +def test_shape_repr(): + poly = h3.H3Poly(sf_hole1) + mpoly = h3.H3MultiPoly(poly) + + assert ( + '' + == str(mpoly) + == repr(mpoly) + ) + + +def test_polyfill(): + poly = h3.H3Poly(sf_7x7) + out = h3.h3shape_to_cells(poly, res=9) + + assert len(out) == 1253 + assert '89283080527ffff' in out + assert '89283095edbffff' in out + + +def test_polyfill_with_hole(): + poly = h3.H3Poly(sf_7x7, sf_hole1) + + out = h3.h3shape_to_cells(poly, res=9) + assert len(out) == 1214 + + foo = lambda x: h3.h3shape_to_cells(h3.H3Poly(x), 9) + # todo: foo = lambda x: h3.H3Poly(x).to_cells(9) + assert out == foo(sf_7x7) - foo(sf_hole1) + + +def test_polyfill_with_two_holes(): + + poly = h3.H3Poly(sf_7x7, sf_hole1, sf_hole2) + out = h3.h3shape_to_cells(poly, 9) + assert len(out) == 1172 + + foo = lambda x: h3.h3shape_to_cells(h3.H3Poly(x), 9) + assert out == foo(sf_7x7) - (foo(sf_hole1) | foo(sf_hole2)) + + +def test_polyfill_geo_json_compliant(): + geo = get_mocked(lnglat_open()).__geo_interface__ + out = h3.geo_to_cells(geo, 9) + assert len(out) > 300 + + +def test_polyfill_geo_interface_compliant(): + geo = get_mocked(lnglat_open()) + out = h3.geo_to_cells(geo, 9) + assert len(out) > 300 + + +def test_poly_opens_loop(): + loop = lnglat_closed() + poly = h3.H3Poly(loop) + + assert loop[0] == loop[-1] + assert len(poly.outer) == len(loop) - 1 + + +def test_geo_to_h3shape(): + h3shapes = [ + h3.geo_to_h3shape(get_mocked(lnglat_open())), + h3.geo_to_h3shape(get_mocked(lnglat_closed())), + h3.H3Poly(latlng_open()), + h3.H3Poly(latlng_closed()), + ] + + for s in h3shapes: + assert len(h3.h3shape_to_cells(s, 8)) == 48 + + expected = { + 'type': 'Polygon', + 'coordinates': (( + (-122.408, 37.813), + (-122.512, 37.707), + (-122.479, 37.815), + (-122.408, 37.813), + ),) + } + + for shape in h3shapes: + assert shape.__geo_interface__ == expected + + mpolys = map(h3.H3MultiPoly, h3shapes) + + multi_expected = { + 'type': 'MultiPolygon', + 'coordinates': ((( + (-122.408, 37.813), + (-122.512, 37.707), + (-122.479, 37.815), + (-122.408, 37.813), + ),),) + } + + for mp in mpolys: + assert mp.__geo_interface__ == multi_expected + + +def test_geo_to_h3shape_passthrough(): + poly = h3.H3Poly(latlng_open()) + mpoly = h3.H3MultiPoly(poly) + + for shape in [poly, mpoly]: + assert h3.geo_to_h3shape(shape) is shape + + +def test_polyfill_down_under(): + sydney = [ + (-33.8556, 151.1979), + (-33.8520, 151.2075), + (-33.8580, 151.2247), + (-33.8582, 151.2255), + (-33.8564, 151.2353), + (-33.8594, 151.2348), + (-33.8641, 151.2335), + (-33.8716, 151.2332), + (-33.8877, 151.2240), + (-33.8874, 151.2194), + (-33.8870, 151.2189), + (-33.8863, 151.2181), + (-33.8851, 151.2158), + (-33.8852, 151.2157), + (-33.8851, 151.2141), + (-33.8847, 151.2116), + (-33.8835, 151.2083), + (-33.8828, 151.2080), + (-33.8816, 151.2059), + (-33.8828, 151.2044), + (-33.8839, 151.2028), + (-33.8839, 151.2023), + (-33.8842, 151.2011), + (-33.8843, 151.1986), + (-33.8842, 151.1986), + (-33.8773, 151.1948), + (-33.8741, 151.1923), + (-33.8697, 151.1851), + (-33.8625, 151.1903), + (-33.8613, 151.1987), + (-33.8556, 151.1979), + ] + + poly = h3.H3Poly(sydney) + out = h3.h3shape_to_cells(poly, 9) + assert len(out) == 92 + assert '89be0e34207ffff' in out + assert '89be0e35ddbffff' in out + + +def test_polyfill_far_east(): + geo = [ + (41.925781, 142.864838), + (42.299659, 142.864838), + (42.299659, 143.415527), + (41.925781, 143.415527), + (41.925781, 142.864838), + ] + + poly = h3.H3Poly(geo) + out = h3.h3shape_to_cells(poly, 9) + assert len(out) == 18507 + assert '892e18d16c3ffff' in out + assert '892e1ebb5a7ffff' in out + + +def test_polyfill_southern_tip(): + geo = [ + (-55.416544, -67.642822), + (-54.354956, -67.642822), + (-54.354956, -64.742432), + (-55.416544, -64.742432), + (-55.416544, -67.642822), + ] + + poly = h3.H3Poly(geo) + out = h3.h3shape_to_cells(poly, 9) + assert len(out) == 223247 + assert '89df4000003ffff' in out + assert '89df4636b27ffff' in out + + +def test_polyfill_null_island(): + geo = [ + (-3, -3), + (+3, -3), + (+3, +3), + (-3, +3), + (-3, -3), + ] + + poly = h3.H3Poly(geo) + out = h3.h3shape_to_cells(poly, 4) + assert len(out) == 345 + assert '847421bffffffff' in out + assert '84825ddffffffff' in out + + +def test_cells_to_h3shape_empty(): + mpoly = h3.cells_to_h3shape([]) + assert list(mpoly) == [] + + +def test_cells_to_h3shape_single(): + h = '89283082837ffff' + cells = {h} + + mpoly = h3.cells_to_h3shape(cells, tight=False) + assert len(mpoly) == 1 + poly = mpoly[0] + + vertices = h3.cell_to_boundary(h) + expected_poly = h3.H3Poly(vertices) + + assert set(poly.outer) == set(expected_poly.outer) + assert poly.holes == expected_poly.holes == () + + +def test_cells_to_h3shape_contiguous(): + a = '89283082837ffff' + b = '89283082833ffff' + assert h3.are_neighbor_cells(a, b) + + mpoly = h3.cells_to_h3shape([a, b], tight=False) + assert len(mpoly) == 1 + poly = mpoly[0] + + assert len(poly.outer) == 10 + assert poly.holes == () + + verts_a = h3.cell_to_boundary(a) + verts_b = h3.cell_to_boundary(b) + assert set(poly.outer) == set(verts_a) | set(verts_b) + + +def test_cells_to_h3shape_non_contiguous(): + a = '89283082837ffff' + b = '8928308280fffff' + assert not h3.are_neighbor_cells(a, b) + + mpoly = h3.cells_to_h3shape([a, b]) + assert len(mpoly) == 2 + + assert all(poly.holes == () for poly in mpoly) + assert all(len(poly.outer) == 6 for poly in mpoly) + + verts_a = h3.cell_to_boundary(a) + verts_b = h3.cell_to_boundary(b) + + verts_both = set.union(*[set(poly.outer) for poly in mpoly]) + assert verts_both == set(verts_a) | set(verts_b) + + +def test_cells_to_h3shape_hole(): + # Six hexagons in a ring around a hole + cells = [ + '892830828c7ffff', '892830828d7ffff', '8928308289bffff', + '89283082813ffff', '8928308288fffff', '89283082883ffff', + ] + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 1 + poly = mpoly[0] + + assert len(poly.holes) == 1 + assert len(poly.holes[0]) == 6 + assert len(poly.outer) == 6 * 3 + + +def test_cells_to_h3shape_2grid_disk(): + h = '8930062838bffff' + cells = h3.grid_disk(h, 2) + mpoly = h3.cells_to_h3shape(cells, tight=False) + + assert len(mpoly) == 1 + poly = mpoly[0] + + assert len(poly.holes) == 0 + assert len(poly.outer) == 6 * (2 * 2 + 1) + + +def test_multipoly_checks(): + + with pytest.raises(ValueError): + h3.H3MultiPoly('foo') + + with pytest.raises(ValueError): + h3.H3MultiPoly(1) + + with pytest.raises(ValueError): + h3.H3MultiPoly([[(1, 2), (3, 4)]]) diff --git a/tests/polyfill/test_polyfill.py b/tests/polyfill/test_polyfill.py new file mode 100644 index 00000000..7b059eb7 --- /dev/null +++ b/tests/polyfill/test_polyfill.py @@ -0,0 +1,161 @@ +import h3 +import pytest + +from h3 import H3ResDomainError + + +def get_us_box_coords(): + + # big center chunk of the US in lat/lng order + outer = [ + [42.68, -110.61], + [32.17, -109.02], + [31.57, -94.26], + [42.94, -89.38], + [42.68, -110.61] + ] + + hole1 = [ + [39.77, -105.07], + [34.81, -104.72], + [34.77, -98.39], + [40.14, -96.72], + [39.77, -105.07] + ] + + hole2 = [ + [41.37, -98.61], + [40.04, -91.80], + [42.32, -91.80], + [41.37, -98.61] + ] + + return outer, hole1, hole2 + + +def test_h3shape_to_cells(): + + # approximate lat/lngs for State of Maine + maine = [ + (45.137, -67.137), + (44.810, -66.965), + (44.325, -68.033), + (43.980, -69.060), + (43.684, -70.116), + (43.090, -70.646), + (43.080, -70.751), + (43.220, -70.798), + (43.368, -70.982), + (43.466, -70.944), + (45.305, -71.085), + (45.460, -70.660), + (45.915, -70.305), + (46.693, -70.000), + (47.448, -69.237), + (47.185, -68.905), + (47.355, -68.234), + (47.066, -67.790), + (45.703, -67.791), + (45.137, -67.137), + ] + + # a very rough hexagonal approximation to the State of Maine + expected = { + '832b13fffffffff', + '832b18fffffffff', + '832b1afffffffff', + '832b1efffffffff', + '832ba9fffffffff', + '832badfffffffff' + } + + poly = h3.H3Poly(maine) + out = h3.h3shape_to_cells(poly, 3) + + assert out == expected + + +def test_h3shape_to_cells2(): + lnglat, _, _ = get_us_box_coords() + + poly = h3.H3Poly(lnglat) + out = h3.h3shape_to_cells(poly, 5) + + assert len(out) == 7063 + + +# todo: we can generate segfaults with malformed input data to polyfill +# need to test for this and avoid segfault +# def test_polyfill_segfault(): +# pass + + +def test_h3shape_to_cells_holes(): + + outer, hole1, hole2 = get_us_box_coords() + + assert 7063 == len( + h3.h3shape_to_cells(h3.H3Poly(outer), 5) + ) + + for res in 1, 2, 3, 4, 5: + cells_all = h3.h3shape_to_cells(h3.H3Poly(outer), res) + cells_holes = h3.h3shape_to_cells(h3.H3Poly(outer, hole1, hole2), res=res) + + cells_1 = h3.h3shape_to_cells(h3.H3Poly(hole1), res) + cells_2 = h3.h3shape_to_cells(h3.H3Poly(hole2), res) + + assert len(cells_all) == len(cells_holes) + len(cells_1) + len(cells_2) + assert cells_all == set.union(cells_holes, cells_1, cells_2) + + +def test_resolution(): + poly = h3.H3Poly([]) + + assert h3.h3shape_to_cells(poly, 0) == set() + assert h3.h3shape_to_cells(poly, 15) == set() + + with pytest.raises(H3ResDomainError): + h3.h3shape_to_cells(poly, -1) + + with pytest.raises(H3ResDomainError): + h3.h3shape_to_cells(poly, 16) + + +def test_invalid_polygon(): + """ + We were previously seeing segfaults on data like + this because we weren't raising errors inside + some `cdef` functions. + """ + with pytest.raises(TypeError): + poly = h3.H3Poly([1, 2, 3]) + h3.h3shape_to_cells(poly, 4) + + with pytest.raises(ValueError): + poly = h3.H3Poly([[1, 2, 3]]) + h3.h3shape_to_cells(poly, 4) + + +def test_bad_geo_input(): + with pytest.raises(ValueError): + h3.h3shape_to_cells('not a shape', 9) + + with pytest.raises(ValueError): + h3.geo_to_cells({'type': 'not a shape', 'coordinates': None}, 9) + + +def test_cells_to_geo(): + h = '89754e64993ffff' + res = h3.get_resolution(h) + + geo = h3.cells_to_geo([h], tight=False) + coord = geo['coordinates'] + + assert geo['type'] == 'MultiPolygon' # todo: TBD + assert len(coord) == 1 + coord = coord[0] + assert len(coord[0]) == 7 + assert coord[0][0] == coord[0][-1] + + assert h3.geo_to_cells(geo, res) == {h} diff --git a/tests/polyfill/test_polyfill_ordering.py b/tests/polyfill/test_polyfill_ordering.py new file mode 100644 index 00000000..0206138b --- /dev/null +++ b/tests/polyfill/test_polyfill_ordering.py @@ -0,0 +1,96 @@ +""" Test that `h3shape_to_cells` can take in polygon inputs +where the LinearRings may or may not follow the right hand rule, +and they may or may not be closed loops (where the last element +is equal to the first). + +Test all permutations of these rules on polygons with +0, 1, and 2 holes. Ensure that for any polygon, each LinearRing +may follow a different subset of rules. +""" + +import h3 +import itertools + + +def reverse(loop): + return list(reversed(loop)) + + +def drop_last(loop): + return loop[:-1] + + +def toggle_map(func, poly): + """ Return all permutations of `func` being applied or not + to each element of `poly` + + returns iterable of length 2**len(poly) + """ + mapped = (list(func(loop)) for loop in poly) + + return itertools.product(*zip(poly, mapped)) + + +def chain_toggle_map(func, seq): + seq = (toggle_map(func, p) for p in seq) + seq = itertools.chain(*seq) + + return seq + + +def input_permutations(geo, res=5): + g = [geo] + g = chain_toggle_map(drop_last, g) + g = chain_toggle_map(reverse, g) + + for p in g: + poly = h3.H3Poly(*p) + cells = h3.h3shape_to_cells(poly, res=res) + yield cells + + +def get_us_box_coords(): + + # big center chunk of the US in lat/lng order + outer = [ + [42.68, -110.61], + [32.17, -109.02], + [31.57, -94.26], + [42.94, -89.38], + [42.68, -110.61] + ] + + hole1 = [ + [39.77, -105.07], + [34.81, -104.72], + [34.77, -98.39], + [40.14, -96.72], + [39.77, -105.07] + ] + + hole2 = [ + [41.37, -98.61], + [40.04, -91.80], + [42.32, -91.80], + [41.37, -98.61] + ] + + return outer, hole1, hole2 + + +def test_input_format(): + geo = get_us_box_coords() + + assert len(geo) == 3 + + # two holes + for cells in input_permutations(geo[:3]): + assert len(cells) == 5437 + + # one hole + for cells in input_permutations(geo[:2]): + assert len(cells) == 5726 + + # zero holes + for cells in input_permutations(geo[:1]): + assert len(cells) == 7063 diff --git a/tests/polyfill/test_polygon_class.py b/tests/polyfill/test_polygon_class.py new file mode 100644 index 00000000..721eb752 --- /dev/null +++ b/tests/polyfill/test_polygon_class.py @@ -0,0 +1,37 @@ +import h3 +import pytest + + +def test_repr(): + a = '8928308280fffff' + b = h3.grid_ring(a, 5).pop() + + cells1 = h3.grid_ring(b, 2) | {a} + cells2 = cells1 | {b} + + mpoly1 = h3.cells_to_h3shape(cells1) + mpoly2 = h3.cells_to_h3shape(cells2) + + # unfortunately output order is nondeterministic + mpoly1 = sorted(map(str, mpoly1)) + mpoly2 = sorted(map(str, mpoly2)) + + assert mpoly1 == [ + '', + '', + ] + + assert mpoly2 == [ + '', + '', + '', + ] + + +def test_h3poly_len(): + cells = {'8928308280fffff'} + + poly = h3.cells_to_h3shape(cells, tight=True) + + with pytest.raises(NotImplementedError): + len(poly) diff --git a/tests/polyfill/test_to_multipoly.py b/tests/polyfill/test_to_multipoly.py new file mode 100644 index 00000000..e613d4df --- /dev/null +++ b/tests/polyfill/test_to_multipoly.py @@ -0,0 +1,43 @@ +import h3 + + +def test_cells_to_h3shape(): + h = '8928308280fffff' + cells = h3.grid_disk(h, 1) + + mpoly = h3.cells_to_h3shape(cells, tight=False) + poly = mpoly[0] + + poly2 = h3.H3Poly(poly.outer, *poly.holes) + out = h3.h3shape_to_cells(poly2, 9) + + assert out == cells + + +def test_cells_to_h3shape_tight(): + h = '8928308280fffff' + cells = h3.grid_disk(h, 1) + + poly = h3.cells_to_h3shape(cells, tight=True) + poly2 = h3.H3Poly(poly.outer, *poly.holes) + out = h3.h3shape_to_cells(poly2, 9) + + assert out == cells + + +def test_2_polys(): + h = '8928308280fffff' + cells = h3.grid_ring(h, 2) + cells = cells | {h} + # cells should be a center hex, and the 2-ring around it + # (with the 1-ring being absent) + + mpoly = h3.cells_to_h3shape(cells) + + out = [ + h3.h3shape_to_cells(poly, 9) + for poly in mpoly + ] + + assert set.union(*out) == cells + assert set(map(len, out)) == {1, 12} diff --git a/tests/test_cells_and_edges.py b/tests/test_cells_and_edges.py index 47465695..c82c6ab2 100644 --- a/tests/test_cells_and_edges.py +++ b/tests/test_cells_and_edges.py @@ -47,21 +47,6 @@ def test3(): assert approx2(out, expected) -def test4(): - expected = ( - (-122.41719971841658, 37.775197782893386), - (-122.41612835779264, 37.77688044840226), - (-122.4173879761762, 37.778385004930925), - (-122.41971895414807, 37.77820687262238), - (-122.42079024541877, 37.77652420699321), - (-122.4195306280734, 37.775019673792606), - (-122.41719971841658, 37.775197782893386) - ) - - out = h3.cell_to_boundary('8928308280fffff', geo_json=True) - assert approx2(out, expected) - - def test_grid_disk_distance(): with pytest.raises(H3DomainError): h3.grid_disk('8928308280fffff', -10) @@ -228,10 +213,9 @@ def test_distance_error(): h3.grid_distance(h1, h2) -def test_compact_cells(): - +def get_maine_cells(): # lat/lngs for State of Maine - maine = h3.Polygon([ + poly = h3.H3Poly([ (45.137451890638886, -67.13734351262877), (44.8097, -66.96466), (44.3252, -68.03252), @@ -255,23 +239,25 @@ def test_compact_cells(): ]) res = 5 - h_uncomp = h3.polygon_to_cells(maine, res=res) - h_comp = h3.compact_cells(h_uncomp) - - expected = {'852b114ffffffff', '852b189bfffffff', '852b1163fffffff', '842ba9bffffffff', '842bad3ffffffff', '852ba9cffffffff', '842badbffffffff', '852b1e8bfffffff', '852a346ffffffff', '842b1e3ffffffff', '852b116ffffffff', '842b185ffffffff', '852b1bdbfffffff', '852bad47fffffff', '852ba9c3fffffff', '852b106bfffffff', '852a30d3fffffff', '842b1edffffffff', '852b12a7fffffff', '852b1027fffffff', '842baddffffffff', '852a349bfffffff', '852b1227fffffff', '852a3473fffffff', '852b117bfffffff', '842ba99ffffffff', '852a341bfffffff', '852ba9d3fffffff', '852b1067fffffff', '852a3463fffffff', '852baca7fffffff', '852b116bfffffff', '852b1c6bfffffff', '852a3493fffffff', '852ba9dbfffffff', '852b180bfffffff', '842bad7ffffffff', '852b1063fffffff', '842ba93ffffffff', '852a3693fffffff', '852ba977fffffff', '852b1e9bfffffff', '852bad53fffffff', '852b100ffffffff', '852b102bfffffff', '852a3413fffffff', '852ba8b7fffffff', '852bad43fffffff', '852b1c6ffffffff', '852a340bfffffff', '852b103bfffffff', '852b1813fffffff', '852b12affffffff', '842a34dffffffff', '852b1873fffffff', '852b106ffffffff', '852b115bfffffff', '852baca3fffffff', '852b114bfffffff', '852b1143fffffff', '852a348bfffffff', '852a30d7fffffff', '852b181bfffffff', '842a345ffffffff', '852b1e8ffffffff', '852b1883fffffff', '852b1147fffffff', '852a3483fffffff', '852b12a3fffffff', '852a346bfffffff', '852ba9d7fffffff', '842b18dffffffff', '852b188bfffffff', '852a36a7fffffff', '852bacb3fffffff', '852b187bfffffff', '852bacb7fffffff', '842b1ebffffffff', '842b1e5ffffffff', '852ba8a7fffffff', '842bad9ffffffff', '852a36b7fffffff', '852a347bfffffff', '832b13fffffffff', '852ba9c7fffffff', '832b1afffffffff', '842ba91ffffffff', '852bad57fffffff', '852ba8affffffff', '852b1803fffffff', '842b1e7ffffffff', '852bad4ffffffff', '852b102ffffffff', '852b1077fffffff', '852b1237fffffff', '852b1153fffffff', '852a3697fffffff', '852a36b3fffffff', '842bad1ffffffff', '842b1e1ffffffff', '852b186bfffffff', '852b1023fffffff'} # noqa + cells_uncomp = h3.h3shape_to_cells(poly, res=res) - assert h_comp == expected + # the expected result from h3.compact_cells(cells_uncomp) + cells_comp = {'852b114ffffffff', '852b189bfffffff', '852b1163fffffff', '842ba9bffffffff', '842bad3ffffffff', '852ba9cffffffff', '842badbffffffff', '852b1e8bfffffff', '852a346ffffffff', '842b1e3ffffffff', '852b116ffffffff', '842b185ffffffff', '852b1bdbfffffff', '852bad47fffffff', '852ba9c3fffffff', '852b106bfffffff', '852a30d3fffffff', '842b1edffffffff', '852b12a7fffffff', '852b1027fffffff', '842baddffffffff', '852a349bfffffff', '852b1227fffffff', '852a3473fffffff', '852b117bfffffff', '842ba99ffffffff', '852a341bfffffff', '852ba9d3fffffff', '852b1067fffffff', '852a3463fffffff', '852baca7fffffff', '852b116bfffffff', '852b1c6bfffffff', '852a3493fffffff', '852ba9dbfffffff', '852b180bfffffff', '842bad7ffffffff', '852b1063fffffff', '842ba93ffffffff', '852a3693fffffff', '852ba977fffffff', '852b1e9bfffffff', '852bad53fffffff', '852b100ffffffff', '852b102bfffffff', '852a3413fffffff', '852ba8b7fffffff', '852bad43fffffff', '852b1c6ffffffff', '852a340bfffffff', '852b103bfffffff', '852b1813fffffff', '852b12affffffff', '842a34dffffffff', '852b1873fffffff', '852b106ffffffff', '852b115bfffffff', '852baca3fffffff', '852b114bfffffff', '852b1143fffffff', '852a348bfffffff', '852a30d7fffffff', '852b181bfffffff', '842a345ffffffff', '852b1e8ffffffff', '852b1883fffffff', '852b1147fffffff', '852a3483fffffff', '852b12a3fffffff', '852a346bfffffff', '852ba9d7fffffff', '842b18dffffffff', '852b188bfffffff', '852a36a7fffffff', '852bacb3fffffff', '852b187bfffffff', '852bacb7fffffff', '842b1ebffffffff', '842b1e5ffffffff', '852ba8a7fffffff', '842bad9ffffffff', '852a36b7fffffff', '852a347bfffffff', '832b13fffffffff', '852ba9c7fffffff', '832b1afffffffff', '842ba91ffffffff', '852bad57fffffff', '852ba8affffffff', '852b1803fffffff', '842b1e7ffffffff', '852bad4ffffffff', '852b102ffffffff', '852b1077fffffff', '852b1237fffffff', '852b1153fffffff', '852a3697fffffff', '852a36b3fffffff', '842bad1ffffffff', '842b1e1ffffffff', '852b186bfffffff', '852b1023fffffff'} # noqa - return h_uncomp, h_comp, res + return cells_uncomp, cells_comp, res -def test_uncompact_cells(): +def test_compact_cells(): + cells_uncomp, cells_comp, _ = get_maine_cells() + out = h3.compact_cells(cells_uncomp) - h_uncomp, h_comp, res = test_compact_cells() + assert out == cells_comp - out = h3.uncompact_cells(h_comp, res) - assert out == h_uncomp +def test_uncompact_cells(): + cells_uncomp, cells_comp, res = get_maine_cells() + out = h3.uncompact_cells(cells_comp, res) + assert out == cells_uncomp def test_get_num_cells(): diff --git a/tests/test_h3.py b/tests/test_h3.py index 5900db98..94a9a949 100644 --- a/tests/test_h3.py +++ b/tests/test_h3.py @@ -61,25 +61,6 @@ def test_cell_to_boundary(): assert o == approx(e) -def test_cell_to_boundary_geo_json(): - out = h3.cell_to_boundary('85283473fffffff', True) - - expected = [ - [-121.91508032705622, 37.271355866731895], - [-121.86222328902491, 37.353926450852256], - [-121.9235499963016, 37.42834118609435], - [-122.0377349642703, 37.42012867767778], - [-122.09042892904395, 37.33755608435298], - [-122.02910130919, 37.26319797461824], - [-121.91508032705622, 37.271355866731895], - ] - - assert len(out) == len(expected) - - for o, e in zip(out, expected): - assert o == approx(e) - - def test_grid_disk(): h = '8928308280fffff' out = h3.grid_disk(h, 1) @@ -148,255 +129,6 @@ def test_grid_disk_pentagon(): assert out == expected -sf_7x7 = [ - (37.813318999983238, -122.4089866999972145), - (37.7866302000007224, -122.3805436999997056), - (37.7198061999978478, -122.3544736999993603), - (37.7076131999975672, -122.5123436999983966), - (37.7835871999971715, -122.5247187000021967), - (37.8151571999998453, -122.4798767000009008), -] - -sf_hole1 = [ - (37.7869802, -122.4471197), - (37.7664102, -122.4590777), - (37.7710682, -122.4137097), -] - -sf_hole2 = [ - (37.747976, -122.490025), - (37.731550, -122.503758), - (37.725440, -122.452603), -] - - -def test_polyfill(): - poly = h3.Polygon(sf_7x7) - out = h3.polygon_to_cells(poly, res=9) - - assert len(out) == 1253 - assert '89283080527ffff' in out - assert '89283095edbffff' in out - - -# def test_polyfill_bogus_geo_json(): -# with pytest.raises(ValueError): -# bad_geo = {'type': 'whatwhat'} -# h3.polyfill(bad_geo, 9) - - -def test_polyfill_with_hole(): - poly = h3.Polygon(sf_7x7, sf_hole1) - - out = h3.polygon_to_cells(poly, res=9) - assert len(out) == 1214 - - foo = lambda x: h3.polygon_to_cells(h3.Polygon(x), 9) - # todo: foo = lambda x: h3.Polygon(x).to_cells(9) - assert out == foo(sf_7x7) - foo(sf_hole1) - - -def test_polyfill_with_two_holes(): - - poly = h3.Polygon(sf_7x7, sf_hole1, sf_hole2) - out = h3.polygon_to_cells(poly, 9) - assert len(out) == 1172 - - foo = lambda x: h3.polygon_to_cells(h3.Polygon(x), 9) - assert out == foo(sf_7x7) - (foo(sf_hole1) | foo(sf_hole2)) - -# def test_polyfill_geo_json_compliant(): -# geo = { -# 'type': 'Polygon', -# 'coordinates': [ -# [ -# [-122.4089866999972145, 37.813318999983238], -# [-122.3805436999997056, 37.7866302000007224], -# [-122.3544736999993603, 37.7198061999978478], -# [-122.5123436999983966, 37.7076131999975672], -# [-122.5247187000021967, 37.7835871999971715], -# [-122.4798767000009008, 37.8151571999998453], -# ] -# ] -# } - -# out = h3.polyfill(geo, 9, True) -# assert len(out) > 1000 - - -def test_polyfill_down_under(): - sydney = [ - (-33.8555555, 151.1979259), - (-33.8519779, 151.2074556), - (-33.8579597, 151.224743), - (-33.8582212, 151.2254986), - (-33.8564183032, 151.235313348), - (-33.8594049408, 151.234799568), - (-33.8641069037, 151.233485084), - (-33.8715791334, 151.233181742), - (-33.8876967719, 151.223980353), - (-33.8873877027, 151.219388501), - (-33.8869995, 151.2189209), - (-33.886283399999996, 151.2181177), - (-33.8851287, 151.2157995), - (-33.8852471, 151.2156925), - (-33.8851287, 151.2141233), - (-33.8847438, 151.2116267), - (-33.8834707, 151.2083456), - (-33.8827601, 151.2080246), - (-33.8816053, 151.2059204), - (-33.8827601, 151.2043868), - (-33.8838556, 151.2028176), - (-33.8839148, 151.2022826), - (-33.8842405, 151.2011057), - (-33.8842819, 151.1986114), - (-33.8842405, 151.1986091), - (-33.8773416, 151.1948287), - (-33.8740845, 151.1923322), - (-33.8697019, 151.1850566), - (-33.8625354, 151.1902636), - (-33.8612915, 151.1986805), - (-33.8555555, 151.1979259), - ] - - poly = h3.Polygon(sydney) - out = h3.polygon_to_cells(poly, 9) - assert len(out) == 92 - assert '89be0e34207ffff' in out - assert '89be0e35ddbffff' in out - - -def test_polyfill_far_east(): - geo = [ - (41.92578147109541, 142.86483764648438), - (42.29965889253408, 142.86483764648438), - (42.29965889253408, 143.41552734375), - (41.92578147109541, 143.41552734375), - (41.92578147109541, 142.86483764648438), - ] - - poly = h3.Polygon(geo) - out = h3.polygon_to_cells(poly, 9) - assert len(out) == 18507 - assert '892e18d16c3ffff' in out - assert '892e1ebb5a7ffff' in out - - -def test_polyfill_southern_tip(): - geo = [ - (-55.41654360858007, -67.642822265625), - (-54.354955689554096, -67.642822265625), - (-54.354955689554096, -64.742431640625), - (-55.41654360858007, -64.742431640625), - (-55.41654360858007, -67.642822265625), - ] - - poly = h3.Polygon(geo) - out = h3.polygon_to_cells(poly, 9) - assert len(out) == 223247 - assert '89df4000003ffff' in out - assert '89df4636b27ffff' in out - - -def test_polyfill_null_island(): - geo = [ - (-3, -3), - (+3, -3), - (+3, +3), - (-3, +3), - (-3, -3), - ] - - poly = h3.Polygon(geo) - out = h3.polygon_to_cells(poly, 4) - assert len(out) == 345 - assert '847421bffffffff' in out - assert '84825ddffffffff' in out - - -def test_cells_to_polygons_empty(): - polys = h3.cells_to_polygons([]) - assert polys == [] - - -def test_cells_to_polygons_single(): - h = '89283082837ffff' - cells = {h} - - polys = h3.cells_to_polygons(cells) - assert len(polys) == 1 - poly = polys[0] - - vertices = h3.cell_to_boundary(h) - expected_poly = h3.Polygon(vertices) - - assert set(poly.outer) == set(expected_poly.outer) - assert poly.holes == expected_poly.holes == () - - -def test_cells_to_polygons_contiguous(): - a = '89283082837ffff' - b = '89283082833ffff' - assert h3.are_neighbor_cells(a, b) - - polys = h3.cells_to_polygons([a, b]) - assert len(polys) == 1 - poly = polys[0] - - assert len(poly.outer) == 10 - assert poly.holes == () - - verts_a = h3.cell_to_boundary(a) - verts_b = h3.cell_to_boundary(b) - assert set(poly.outer) == set(verts_a) | set(verts_b) - - -def test_cells_to_polygons_non_contiguous(): - a = '89283082837ffff' - b = '8928308280fffff' - assert not h3.are_neighbor_cells(a, b) - - polys = h3.cells_to_polygons([a, b]) - assert len(polys) == 2 - - assert all(poly.holes == () for poly in polys) - assert all(len(poly.outer) == 6 for poly in polys) - - verts_a = h3.cell_to_boundary(a) - verts_b = h3.cell_to_boundary(b) - - verts_both = set.union(*[set(poly.outer) for poly in polys]) - assert verts_both == set(verts_a) | set(verts_b) - - -def test_cells_to_polygons_hole(): - # Six hexagons in a ring around a hole - cells = [ - '892830828c7ffff', '892830828d7ffff', '8928308289bffff', - '89283082813ffff', '8928308288fffff', '89283082883ffff', - ] - polys = h3.cells_to_polygons(cells) - - assert len(polys) == 1 - poly = polys[0] - - assert len(poly.holes) == 1 - assert len(poly.holes[0]) == 6 - assert len(poly.outer) == 6 * 3 - - -def test_cells_to_polygons_2grid_disk(): - h = '8930062838bffff' - cells = h3.grid_disk(h, 2) - polys = h3.cells_to_polygons(cells) - - assert len(polys) == 1 - poly = polys[0] - - assert len(poly.holes) == 0 - assert len(poly.outer) == 6 * (2 * 2 + 1) - - def test_grid_ring(): h = '8928308280fffff' out = h3.grid_ring(h, 1) @@ -452,8 +184,17 @@ def test_grid_ring_pentagon(): def test_compact_and_uncompact_cells(): - poly = h3.Polygon(sf_7x7) - cells = h3.polygon_to_cells(poly, 9) + sf_7x7 = [ + (37.813318999983238, -122.4089866999972145), + (37.7866302000007224, -122.3805436999997056), + (37.7198061999978478, -122.3544736999993603), + (37.7076131999975672, -122.5123436999983966), + (37.7835871999971715, -122.5247187000021967), + (37.8151571999998453, -122.4798767000009008), + ] + + poly = h3.H3Poly(sf_7x7) + cells = h3.h3shape_to_cells(poly, 9) compact_cells = h3.compact_cells(cells) assert len(compact_cells) == 209 @@ -586,15 +327,6 @@ def test_origin_to_directed_edges(): assert len(h3_uni_edge_pentagon) == 5 -def test_directed_edge_to_boundary(): - e = '11928308280fffff' - boundary = h3.directed_edge_to_boundary(e) - assert len(boundary) == 2 - - boundary_geo_json = h3.directed_edge_to_boundary(e, True) - assert len(boundary_geo_json) == 3 - - def test_grid_distance(): h = '89283082993ffff' diff --git a/tests/test_polyfill.py b/tests/test_polyfill.py deleted file mode 100644 index e52be221..00000000 --- a/tests/test_polyfill.py +++ /dev/null @@ -1,240 +0,0 @@ -import h3 -import itertools -import pytest - -from h3 import H3ResDomainError - - -def reverse(loop): - return list(reversed(loop)) - - -def drop_last(loop): - return loop[:-1] - - -def toggle_map(func, poly): - """ Return all permutations of `func` being applied or not - to each element of `poly` - - returns iterable of length 2**len(poly) - """ - mapped = (list(func(loop)) for loop in poly) - - return itertools.product(*zip(poly, mapped)) - - -def chain_toggle_map(func, seq): - seq = (toggle_map(func, p) for p in seq) - seq = itertools.chain(*seq) - - return seq - - -def input_permutations(geo, res=5): - g = [geo] - g = chain_toggle_map(drop_last, g) - g = chain_toggle_map(reverse, g) - - for p in g: - poly = h3.Polygon(*p) - cells = h3.polygon_to_cells(poly, res=res) - yield cells - - -def swap_element_order(seq): - return [e[::-1] for e in seq] - - -def get_us_box_coords(): - - # big center chunk of the US in lat/lng order - outer = [ - [42.68, -110.61], - [32.17, -109.02], - [31.57, -94.26], - [42.94, -89.38], - [42.68, -110.61] - ] - - hole1 = [ - [39.77, -105.07], - [34.81, -104.72], - [34.77, -98.39], - [40.14, -96.72], - [39.77, -105.07] - ] - - hole2 = [ - [41.37, -98.61], - [40.04, -91.80], - [42.32, -91.80], - [41.37, -98.61] - ] - - return outer, hole1, hole2 - - -def test_polygon_to_cells(): - - # lat/lngs for State of Maine - maine = [ - (45.137451890638886, -67.13734351262877), - (44.8097, -66.96466), - (44.3252, -68.03252), - (43.98, -69.06), - (43.68405, -70.11617), - (43.090083319667144, -70.64573401557249), - (43.08003225358635, -70.75102474636725), - (43.21973948828747, -70.79761105007827), - (43.36789581966826, -70.98176001655037), - (43.46633942318431, -70.94416541205806), - (45.3052400000002, -71.08482), - (45.46022288673396, -70.6600225491012), - (45.914794623389355, -70.30495378282376), - (46.69317088478567, -70.00014034695016), - (47.44777598732787, -69.23708614772835), - (47.184794623394396, -68.90478084987546), - (47.35462921812177, -68.23430497910454), - (47.066248887716995, -67.79035274928509), - (45.702585354182816, -67.79141211614706), - (45.137451890638886, -67.13734351262877) - ] - - # a very rough hexagonal approximation to the State of Maine - expected = { - '832b13fffffffff', - '832b18fffffffff', - '832b1afffffffff', - '832b1efffffffff', - '832ba9fffffffff', - '832badfffffffff' - } - - poly = h3.Polygon(maine) - out = h3.polygon_to_cells(poly, 3) - - assert out == expected - - -def test_polygon_to_cells2(): - lnglat, _, _ = get_us_box_coords() - - poly = h3.Polygon(lnglat) - out = h3.polygon_to_cells(poly, 5) - - assert len(out) == 7063 - - -# todo: we can generate segfaults with malformed input data to polyfill -# need to test for this and avoid segfault -# def test_polyfill_segfault(): -# pass - - -def test_polygon_to_cells_holes(): - - outer, hole1, hole2 = get_us_box_coords() - - assert 7063 == len( - h3.polygon_to_cells(h3.Polygon(outer), 5) - ) - - for res in 1, 2, 3, 4, 5: - cells_all = h3.polygon_to_cells(h3.Polygon(outer), res) - cells_holes = h3.polygon_to_cells(h3.Polygon(outer, hole1, hole2), res=res) - - cells_1 = h3.polygon_to_cells(h3.Polygon(hole1), res) - cells_2 = h3.polygon_to_cells(h3.Polygon(hole2), res) - - assert len(cells_all) == len(cells_holes) + len(cells_1) + len(cells_2) - assert cells_all == set.union(cells_holes, cells_1, cells_2) - - -# def test_polyfill_geojson(): -# outer, hole1, hole2 = get_us_box_coords(order='lnglat') - -# d = { -# 'type': 'Polygon', -# 'coordinates': [outer], -# } - -# out = h3.polyfill_geojson(d, 5) - -# assert len(out) == 7063 - - -# def test_polyfill(): -# outer, hole1, hole2 = get_us_box_coords(order='lnglat') - -# d = { -# 'type': 'Polygon', -# 'coordinates': [outer], -# } - -# out = h3.polyfill(d, 5, geo_json_conformant=True) - -# assert len(out) == 7063 - - -def test_input_format(): - """ Test that `polygon_to_cells` can take in polygon inputs - where the LinearRings may or may not follow the right hand rule, - and they may or may not be closed loops (where the last element - is equal to the first). - - Test all permutations of these rules on polygons with - 0, 1, and 2 holes. Ensure that for any polygon, each LinearRing - may follow a different subset of rules. - """ - - geo = get_us_box_coords() - - assert len(geo) == 3 - - # two holes - for cells in input_permutations(geo[:3]): - assert len(cells) == 5437 - - # one hole - for cells in input_permutations(geo[:2]): - assert len(cells) == 5726 - - # zero holes - for cells in input_permutations(geo[:1]): - assert len(cells) == 7063 - - -def test_resolution(): - poly = h3.Polygon([]) - - assert h3.polygon_to_cells(poly, 0) == set() - assert h3.polygon_to_cells(poly, 15) == set() - - with pytest.raises(H3ResDomainError): - h3.polygon_to_cells(poly, -1) - - with pytest.raises(H3ResDomainError): - h3.polygon_to_cells(poly, 16) - - -def test_invalid_polygon(): - """ - We were previously seeing segfaults on data like - this because we weren't raising errors inside - some `cdef` functions. - """ - with pytest.raises(TypeError): - poly = h3.Polygon([1, 2, 3]) - h3.polygon_to_cells(poly, 4) - - with pytest.raises(ValueError): - poly = h3.Polygon([[1, 2, 3]]) - h3.polygon_to_cells(poly, 4) - - # d = { - # 'type': 'Polygon', - # 'coordinates': [(1, 2), (2, 2), (2, 1), (1, 2)], - # } - # with pytest.raises(TypeError): - # h3.polyfill(d, 4) diff --git a/tests/test_polygon_class.py b/tests/test_polygon_class.py deleted file mode 100644 index d14b62aa..00000000 --- a/tests/test_polygon_class.py +++ /dev/null @@ -1,27 +0,0 @@ -import h3 - - -def test_repr(): - a = '8928308280fffff' - b = h3.grid_ring(a, 5).pop() - - cells1 = h3.grid_ring(b, 2) | {a} - cells2 = cells1 | {b} - - poly1 = h3.cells_to_polygons(cells1) - poly2 = h3.cells_to_polygons(cells2) - - # unfortunately output order is nondeterministic - poly1 = sorted(map(str, poly1)) - poly2 = sorted(map(str, poly2)) - - assert poly1 == [ - '', - '', - ] - - assert poly2 == [ - '', - '', - '', - ] diff --git a/tests/test_to_multipoly.py b/tests/test_to_multipoly.py deleted file mode 100644 index be2b9486..00000000 --- a/tests/test_to_multipoly.py +++ /dev/null @@ -1,32 +0,0 @@ -import h3 - - -def test_cells_to_polygons(): - h = '8928308280fffff' - cells = h3.grid_disk(h, 1) - - polys = h3.cells_to_polygons(cells) - poly = polys[0] - - poly2 = h3.Polygon(poly.outer, *poly.holes) - out = h3.polygon_to_cells(poly2, 9) - - assert out == cells - - -def test_2_polys(): - h = '8928308280fffff' - cells = h3.grid_ring(h, 2) - cells = cells | {h} - # cells should be a center hex, and the 2-ring around it - # (with the 1-ring being absent) - - polys = h3.cells_to_polygons(cells) - - out = [ - h3.polygon_to_cells(poly, 9) - for poly in polys - ] - - assert set.union(*out) == cells - assert set(map(len, out)) == {1, 12}