From 5e0dceb2256f1a8209356e4cf727e57093032a2e Mon Sep 17 00:00:00 2001 From: AJ Friend Date: Fri, 2 Oct 2020 12:51:54 -0700 Subject: [PATCH] New cell area functions with h3==3.7 (#171) * move to h3lib 3.8pre * cython functions done * have tests running * linting * bump core library * skip building wheels for Python 3.9 for now (buggy) * try bumping codecov action version * try testing for 3.9 * ah, no 3.9 up yet * bump some action versions * add some docstrings * changelog * bump badges --- .github/workflows/coverage-lint.yml | 4 +- .github/workflows/tests.yml | 4 +- .github/workflows/wheels.yml | 6 +- CHANGELOG.md | 6 ++ makefile | 2 +- readme.md | 2 +- src/h3/_cy/__init__.py | 5 +- src/h3/_cy/cells.pyx | 23 ++--- src/h3/_cy/edges.pyx | 39 ++++++++ src/h3/_cy/geo.pyx | 21 ++++ src/h3/_cy/h3lib.pxd | 29 +++--- src/h3/_version.py | 2 +- src/h3/api/_api_template.py | 83 ++++++++++++++++ src/h3lib | 2 +- tests/test_length_area.py | 143 ++++++++++++++++++++++++++++ 15 files changed, 335 insertions(+), 36 deletions(-) create mode 100644 tests/test_length_area.py diff --git a/.github/workflows/coverage-lint.yml b/.github/workflows/coverage-lint.yml index da9f0a77..e8cf87cf 100644 --- a/.github/workflows/coverage-lint.yml +++ b/.github/workflows/coverage-lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.1.1 + - uses: actions/checkout@v2.3.3 with: submodules: recursive @@ -32,7 +32,7 @@ jobs: run: pytest --cov=h3 --full-trace --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.0.7 + uses: codecov/codecov-action@v1.0.13 with: file: ./coverage.xml fail_ci_if_error: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9ab8e84a..60c44848 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,7 +20,7 @@ jobs: python-version: 2.7 steps: - - uses: actions/checkout@v2.1.1 + - uses: actions/checkout@v2.3.3 with: submodules: recursive @@ -29,7 +29,7 @@ jobs: python-version: "${{ matrix.python-version }}" ## Start Windows stuff - - uses: ilammy/msvc-dev-cmd@v1.2.0 + - uses: ilammy/msvc-dev-cmd@v1.3.0 if: startsWith(matrix.os, 'windows') - name: Set Windows Compiler diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9ec3f776..5454b8bb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -23,7 +23,7 @@ jobs: ## Setup Env - - uses: actions/checkout@v2.1.1 + - uses: actions/checkout@v2.3.3 with: submodules: recursive @@ -31,7 +31,7 @@ jobs: with: python-version: 3.8 - - uses: ilammy/msvc-dev-cmd@v1.2.0 + - uses: ilammy/msvc-dev-cmd@v1.3.0 if: startsWith(matrix.os, 'windows') @@ -52,7 +52,7 @@ jobs: - name: Build Mac if: startsWith(matrix.os, 'mac') env: - CIBW_SKIP: pp* + CIBW_SKIP: pp* cp39-* run: | python -m cibuildwheel --output-dir wheelhouse diff --git a/CHANGELOG.md b/CHANGELOG.md index c04ca334..f5d4e654 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ Because H3-Py is versioned in lockstep with the H3 core library, please avoid adding features or APIs which do not map onto the [H3 core API](https://uber.github.io/h3/#/documentation/api-reference/). +## Unreleased + +- Add functions (#171) + + `cell_area` + + `exact_edge_length` + + `point_dist` ## [3.6.4] - 2020-07-20 diff --git a/makefile b/makefile index 3f3ae4ed..2381b732 100644 --- a/makefile +++ b/makefile @@ -23,7 +23,7 @@ test: env/bin/pytest tests/* --cov=h3 --cov-report term-missing --durations=10 lint: - flake8 src/h3 setup.py tests + env/bin/flake8 src/h3 setup.py tests lab: env/bin/pip install jupyterlab diff --git a/readme.md b/readme.md index a8993ae0..8e5c671c 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ [![PyPI version](https://badge.fury.io/py/h3.svg)](https://badge.fury.io/py/h3) [![PyPI downloads](https://pypip.in/d/h3/badge.png)](https://pypistats.org/packages/h3) [![conda](https://img.shields.io/conda/vn/conda-forge/h3-py.svg)](https://anaconda.org/conda-forge/h3-py) -[![version](https://img.shields.io/badge/h3-v3.6.4-blue.svg)](https://github.com/uber/h3/releases/tag/v3.6.4) +[![version](https://img.shields.io/badge/h3-v3.7.0-blue.svg)](https://github.com/uber/h3/releases/tag/v3.7.0) [![version](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) [![Tests](https://github.com/uber/h3-py/workflows/tests/badge.svg)](https://github.com/uber/h3-py/actions) diff --git a/src/h3/_cy/__init__.py b/src/h3/_cy/__init__.py index efe6db3b..bb4dfc06 100644 --- a/src/h3/_cy/__init__.py +++ b/src/h3/_cy/__init__.py @@ -27,7 +27,7 @@ uncompact, num_hexagons, mean_hex_area, - mean_edge_length, + cell_area, line, is_res_class_iii, get_pentagon_indexes, @@ -46,6 +46,8 @@ edge_destination, edge_cells, edges_from_cell, + mean_edge_length, + edge_length, ) from .geo import ( @@ -56,6 +58,7 @@ polyfill, cell_boundary, edge_boundary, + point_dist, ) from .to_multipoly import ( diff --git a/src/h3/_cy/cells.pyx b/src/h3/_cy/cells.pyx index 657fb7c4..33f94969 100644 --- a/src/h3/_cy/cells.pyx +++ b/src/h3/_cy/cells.pyx @@ -250,23 +250,20 @@ cpdef double mean_hex_area(int resolution, unit='km^2') except -1: return area -cpdef double mean_edge_length(int resolution, unit='km') except -1: - check_res(resolution) - - length = h3lib.edgeLengthKm(resolution) - # todo: multiple units - convert = { - 'km': 1.0, - 'm': 1000.0 - } +cpdef double cell_area(H3int h, unit='km^2') except -1: + check_cell(h) - try: - length *= convert[unit] - except: + if unit == 'rads^2': + area = h3lib.cellAreaRads2(h) + elif unit == 'km^2': + area = h3lib.cellAreaKm2(h) + elif unit == 'm^2': + area = h3lib.cellAreaM2(h) + else: raise H3ValueError('Unknown unit: {}'.format(unit)) - return length + return area cpdef H3int[:] line(H3int start, H3int end): diff --git a/src/h3/_cy/edges.pyx b/src/h3/_cy/edges.pyx index 2d5c6cce..65600063 100644 --- a/src/h3/_cy/edges.pyx +++ b/src/h3/_cy/edges.pyx @@ -4,6 +4,7 @@ from .h3lib cimport bool, H3int from .util cimport ( check_cell, check_edge, + check_res, create_ptr, create_mv, ) @@ -59,3 +60,41 @@ cpdef H3int[:] edges_from_cell(H3int origin): mv = create_mv(ptr, 6) return mv + + +cpdef double mean_edge_length(int resolution, unit='km') except -1: + check_res(resolution) + + length = h3lib.edgeLengthKm(resolution) + + # todo: multiple units + convert = { + 'km': 1.0, + 'm': 1000.0 + } + + try: + length *= convert[unit] + except: + raise H3ValueError('Unknown unit: {}'.format(unit)) + + return length + + +cpdef double edge_length(H3int e, unit='km') except -1: + check_edge(e) + + # todo: maybe kick this logic up to the python level + # it might be a little cleaner, because we can do the "switch statement" + # with a dict, but would require exposing more C functions + + if unit == 'rads': + length = h3lib.exactEdgeLengthRads(e) + elif unit == 'km': + length = h3lib.exactEdgeLengthKm(e) + elif unit == 'm': + length = h3lib.exactEdgeLengthM(e) + else: + raise H3ValueError('Unknown unit: {}'.format(unit)) + + return length diff --git a/src/h3/_cy/geo.pyx b/src/h3/_cy/geo.pyx index 3035eda0..dc2c1d7b 100644 --- a/src/h3/_cy/geo.pyx +++ b/src/h3/_cy/geo.pyx @@ -11,6 +11,8 @@ from .util cimport ( ) from libc cimport stdlib +from .util import H3ValueError + cpdef H3int geo_to_h3(double lat, double lng, int res) except 1: cdef: @@ -255,3 +257,22 @@ def edge_boundary(H3int edge, bool geo_json=False): verts = tuple(v[::-1] for v in verts) return verts + + +cpdef double point_dist( + double lat1, double lng1, + double lat2, double lng2, unit='km') except -1: + + a = deg2coord(lat1, lng1) + b = deg2coord(lat2, lng2) + + if unit == 'rads': + d = h3lib.pointDistRads(&a, &b) + elif unit == 'km': + d = h3lib.pointDistKm(&a, &b) + elif unit == 'm': + d = h3lib.pointDistM(&a, &b) + else: + raise H3ValueError('Unknown unit: {}'.format(unit)) + + return d diff --git a/src/h3/_cy/h3lib.pxd b/src/h3/_cy/h3lib.pxd index 9f9489e7..d4433f31 100644 --- a/src/h3/_cy/h3lib.pxd +++ b/src/h3/_cy/h3lib.pxd @@ -86,14 +86,6 @@ cdef extern from "h3api.h": double radsToDegs(double radians) nogil - double hexAreaKm2(int res) - - double hexAreaM2(int res) - - double edgeLengthKm(int res) - - double edgeLengthM(int res) - stdint.int64_t numHexagons(int res) int h3GetResolution(H3Index h) @@ -149,13 +141,28 @@ cdef extern from "h3api.h": void getH3UnidirectionalEdgeBoundary(H3Index edge, GeoBoundary *gb) int h3LineSize(H3Index start, H3Index end) - int h3Line(H3Index start, H3Index end, H3Index *out) int maxFaceCount(H3Index h3) - void h3GetFaces(H3Index h3, int *out) int experimentalH3ToLocalIj(H3Index origin, H3Index h3, CoordIJ *out) - int experimentalLocalIjToH3(H3Index origin, const CoordIJ *ij, H3Index *out) + + double hexAreaKm2(int res) nogil + double hexAreaM2(int res) nogil + + double cellAreaRads2(H3Index h) nogil + double cellAreaKm2(H3Index h) nogil + double cellAreaM2(H3Index h) nogil + + double edgeLengthKm(int res) nogil + double edgeLengthM(int res) nogil + + double exactEdgeLengthRads(H3Index edge) nogil + double exactEdgeLengthKm(H3Index edge) nogil + double exactEdgeLengthM(H3Index edge) nogil + + double pointDistRads(const GeoCoord *a, const GeoCoord *b) nogil + double pointDistKm(const GeoCoord *a, const GeoCoord *b) nogil + double pointDistM(const GeoCoord *a, const GeoCoord *b) nogil diff --git a/src/h3/_version.py b/src/h3/_version.py index 2389bdcc..3217a7ab 100644 --- a/src/h3/_version.py +++ b/src/h3/_version.py @@ -1,4 +1,4 @@ -__version__ = '3.6.4' +__version__ = '3.7.0' __description__ = 'Hierarchical hexagonal geospatial indexing system' __url__ = 'https://github.com/uber/h3-py' __license__ = "Apache 2.0 License" diff --git a/src/h3/api/_api_template.py b/src/h3/api/_api_template.py index 87dadb80..32e4e37f 100644 --- a/src/h3/api/_api_template.py +++ b/src/h3/api/_api_template.py @@ -886,4 +886,87 @@ def experimental_local_ij_to_h3(origin, i, j): return h + def cell_area(h, unit='km^2'): + """ + Compute the spherical surface area of a specific H3 cell. + + Parameters + ---------- + h : H3Cell + unit: str + Unit for area result ('km^2', 'm^2', or 'rads^2') + + + Returns + ------- + The area of the H3 cell in the given units + + + Implementation Notes + -------------------- + This function breaks the cell into spherical triangles, and computes + their spherical area. + The function uses the spherical distance calculation given by + `point_dist`. + """ + h = _in_scalar(h) + + return _cy.cell_area(h, unit=unit) + + def exact_edge_length(e, unit='km'): + """ + Compute the spherical length of a specific H3 edge. + + Parameters + ---------- + h : H3Cell + unit: str + Unit for length result ('km', 'm', or 'rads') + + + Returns + ------- + The length of the edge in the given units + + + Implementation Notes + -------------------- + This function uses the spherical distance calculation given by + `point_dist`. + """ + e = _in_scalar(e) + + return _cy.edge_length(e, unit=unit) + + def point_dist(point1, point2, unit='km'): + """ + Compute the spherical distance between two (lat, lng) points. + + todo: do we handle lat/lng points consistently in the api? what + about (lat1, lng1, lat2, lng2) as the input? How will this work + for vectorized versions? + + Parameters + ---------- + point1 : tuple + (lat, lng) tuple in degrees + point2 : tuple + (lat, lng) tuple in degrees + unit: str + Unit for distance result ('km', 'm', or 'rads') + + + Returns + ------- + Spherical (or "haversine") distance between the points + """ + lat1, lng1 = point1 + lat2, lng2 = point2 + + return _cy.point_dist( + lat1, lng1, + lat2, lng2, + unit=unit + ) + _globals.update(locals()) diff --git a/src/h3lib b/src/h3lib index 59f0fd17..c99cad0d 160000 --- a/src/h3lib +++ b/src/h3lib @@ -1 +1 @@ -Subproject commit 59f0fd17d858f48c0eea3d05eb0e1bae84d88012 +Subproject commit c99cad0dec4ce7f53561eea562eb3ee768a0a4bd diff --git a/tests/test_length_area.py b/tests/test_length_area.py new file mode 100644 index 00000000..8236199d --- /dev/null +++ b/tests/test_length_area.py @@ -0,0 +1,143 @@ +import h3 +import pytest + +from h3 import H3ValueError + + +def approx2(a, b): + if len(a) != len(b): + return False + + return all( + x == pytest.approx(y) + for x, y in zip(a, b) + ) + + +def cell_perimiter1(h, unit='km'): + edges = h3.get_h3_unidirectional_edges_from_hexagon(h) + + dists = [ + h3.exact_edge_length(e, unit=unit) + for e in edges + ] + + assert all(d > 0 for d in dists) + + return sum(dists) + + +def cell_perimiter2(h, unit='km'): + verts = h3.h3_to_geo_boundary(h) + N = len(verts) + verts += (verts[0],) + + dists = [ + h3.point_dist(verts[i], verts[i + 1], unit=unit) + for i in range(N) + ] + + assert all(d > 0 for d in dists) + + return sum(dists) + + +def test_areas_at_00(): + areas_km2 = [ + 2.562182162955495529e+06, + 4.476842018179409206e+05, + 6.596162242711056024e+04, + 9.228872919002589697e+03, + 1.318694490797110348e+03, + 1.879593512281297762e+02, + 2.687164354763186225e+01, + 3.840848847060638782e+00, + 5.486939641329895423e-01, + 7.838600808637447015e-02, + 1.119834221989390345e-02, + 1.599777169186613647e-03, + 2.285390931423379875e-04, + 3.264850232091780848e-05, + 4.664070326136773890e-06, + 6.662957615868890711e-07, + ] + + out = [ + h3.cell_area(h3.geo_to_h3(0, 0, r), unit='km^2') + for r in range(16) + ] + + assert approx2(out, areas_km2) + + areas_rads2 = [ + 6.312389871006786335e-02, + 1.102949377223657809e-02, + 1.625081476657283096e-03, + 2.273696413041990331e-04, + 3.248837599063685022e-05, + 4.630711750349743332e-06, + 6.620305651949173071e-07, + 9.462611873890716096e-08, + 1.351804829317986891e-08, + 1.931178237937334527e-09, + 2.758910081529350229e-10, + 3.941334595426616175e-11, + 5.630465614578665530e-12, + 8.043537197853909460e-13, + 1.149076389260636790e-13, + 1.641537700693487648e-14, + ] + + out = [ + h3.cell_area(h3.geo_to_h3(0, 0, r), unit='rads^2') + for r in range(16) + ] + + assert approx2(out, areas_rads2) + + +def test_bad_units(): + h = '89754e64993ffff' + e = '139754e64993ffff' + + assert h3.h3_is_valid(h) + assert h3.h3_unidirectional_edge_is_valid(e) + + with pytest.raises(H3ValueError): + h3.cell_area(h, unit='foot-pounds') + + with pytest.raises(H3ValueError): + h3.exact_edge_length(h, unit='foot-pounds') + + with pytest.raises(H3ValueError): + h3.point_dist((0, 0), (0, 0), unit='foot-pounds') + + +def test_point_dist(): + lyon = (45.7597, 4.8422) # (lat, lon) + paris = (48.8567, 2.3508) + + d = h3.point_dist(lyon, paris, unit='rads') + assert d == pytest.approx(0.0615628186794217) + + d = h3.point_dist(lyon, paris, unit='m') + assert d == pytest.approx(392217.1598841777) + + d = h3.point_dist(lyon, paris, unit='km') + assert d == pytest.approx(392.21715988417765) + + # test that 'km' is the default unit + assert h3.point_dist(lyon, paris, unit='km') == h3.point_dist(lyon, paris) + + +def test_cell_perimiter_calculations(): + resolutions = [0, 1] + + for r in resolutions: + cells = h3.uncompact(h3.get_res0_indexes(), r) + for h in cells: + for unit in ['rads', 'm', 'km']: + v1 = cell_perimiter1(h, unit) + v2 = cell_perimiter2(h, unit) + + assert v1 == pytest.approx(v2)