Repository: mapado/haversine

Issue:
haversine_vector is no longer fast
Following commit b949e32246976f14f192c97c9bb0b82b8e1ee451, which introduces element-wise limits check, the vectorized version is 10 times or more slower than it used to be.

There should probably be vectorized versions of the check, or a flag to indicate that limits check is not needed.

Comments:

Patch:
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 03a4139..42bc10b 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -3,7 +3,8 @@
 
 name: Python application
 
-on:
+on:  
+  workflow_dispatch: ~
   push:
     branches: [main]
   pull_request:
diff --git a/README.md b/README.md
index a76b4c0..d267e98 100755
--- a/README.md
+++ b/README.md
@@ -88,7 +88,8 @@ inverse_haversine(paris, 10, Direction.SOUTH, unit=Unit.NAUTICAL_MILES)
 
 ### Performance optimisation for distances between all points in two vectors
 
-You will need to add [numpy](https://pypi.org/project/numpy/) in order to gain performance with vectors.
+You will need to install [numpy](https://pypi.org/project/numpy/) in order to gain performance with vectors.
+For optimal performance, you can turn off coordinate checking by adding `check=False` and install the optional packages [numba](https://pypi.org/project/numba/) and [icc_rt](https://pypi.org/project/icc_rt/).
 
 You can then do this:
 
diff --git a/haversine/__init__.py b/haversine/__init__.py
index 806fe82..9643763 100755
--- a/haversine/__init__.py
+++ b/haversine/__init__.py
@@ -1,1 +1,1 @@
-from .haversine import Unit, haversine, haversine_vector, Direction, inverse_haversine
+from .haversine import Unit, haversine, haversine_vector, Direction, inverse_haversine, inverse_haversine_vector
diff --git a/haversine/haversine.py b/haversine/haversine.py
index 8140ef9..f780d0d 100755
--- a/haversine/haversine.py
+++ b/haversine/haversine.py
@@ -1,13 +1,14 @@
-from math import radians, cos, sin, asin, sqrt, degrees, pi, atan2
 from enum import Enum
+from math import pi
 from typing import Union, Tuple
+import math
 
 
 # mean earth radius - https://en.wikipedia.org/wiki/Earth_radius#Mean_radius
 _AVG_EARTH_RADIUS_KM = 6371.0088
 
 
-class Unit(Enum):
+class Unit(str, Enum):
     """
     Enumeration of supported units.
     The full list can be checked by iterating over the class; e.g.
@@ -24,7 +25,7 @@ class Unit(Enum):
     DEGREES = 'deg'
 
 
-class Direction(Enum):
+class Direction(float, Enum):
     """
     Enumeration of supported directions.
     The full list can be checked by iterating over the class; e.g.
@@ -32,7 +33,7 @@ class Direction(Enum):
     Angles expressed in radians.
     """
 
-    NORTH = 0
+    NORTH = 0.0
     NORTHEAST = pi * 0.25
     EAST = pi * 0.5
     SOUTHEAST = pi * 0.75
@@ -56,7 +57,6 @@ _CONVERSIONS = {
 
 
 def get_avg_earth_radius(unit):
-    unit = Unit(unit)
     return _AVG_EARTH_RADIUS_KM * _CONVERSIONS[unit]
 
 
@@ -72,6 +72,19 @@ def _normalize(lat: float, lon: float) -> Tuple[float, float]:
     return lat, lon
 
 
+def _normalize_vector(lat: "numpy.ndarray", lon: "numpy.ndarray") -> Tuple["numpy.ndarray", "numpy.ndarray"]:
+    """
+    Normalize points to [-90, 90] latitude and [-180, 180] longitude.
+    """
+    lat = (lat + 90) % 360 - 90
+    lon = (lon + 180) % 360 - 180
+    wrap = lat > 90
+    if numpy.any(wrap):
+        lat[wrap] = 180 - lat[wrap]
+        lon[wrap] = lon[wrap] % 360 - 180
+    return lat, lon
+
+
 def _ensure_lat_lon(lat: float, lon: float):
     """
     Ensure that the given latitude and longitude have proper values. An exception is raised if they are not.
@@ -82,7 +95,92 @@ def _ensure_lat_lon(lat: float, lon: float):
         raise ValueError(f"Longitude {lon} is out of range [-180, 180]")
 
 
-def haversine(point1, point2, unit=Unit.KILOMETERS, normalize=False):
+def _ensure_lat_lon_vector(lat: "numpy.ndarray", lon: "numpy.ndarray"):
+    """
+    Ensure that the given latitude and longitude have proper values. An exception is raised if they are not.
+    """
+    if numpy.abs(lat).max() > 90:
+        raise ValueError("Latitude(s) out of range [-90, 90]")
+    if numpy.abs(lon).max() > 180:
+        raise ValueError("Longitude(s) out of range [-180, 180]")
+
+
+def _explode_args(f):
+    return lambda ops: f(**ops.__dict__)
+
+
+@_explode_args
+def _create_haversine_kernel(*, asin=None, arcsin=None, cos, radians, sin, sqrt, **_):
+    asin = asin or arcsin
+
+    def _haversine_kernel(lat1, lng1, lat2, lng2):
+        """
+        Compute the haversine distance on unit sphere.  Inputs are in degrees,
+        either scalars (with ops==math) or arrays (with ops==numpy).
+        """
+        lat1 = radians(lat1)
+        lng1 = radians(lng1)
+        lat2 = radians(lat2)
+        lng2 = radians(lng2)
+        lat = lat2 - lat1
+        lng = lng2 - lng1
+        d = (sin(lat * 0.5) ** 2
+             + cos(lat1) * cos(lat2) * sin(lng * 0.5) ** 2)
+        # Note: 2 * atan2(sqrt(d), sqrt(1-d)) is more accurate at
+        # large distance (d is close to 1), but also slower.
+        return 2 * asin(sqrt(d))
+    return _haversine_kernel
+
+
+
+@_explode_args
+def _create_inverse_haversine_kernel(*, asin=None, arcsin=None, atan2=None, arctan2=None, cos, degrees, radians, sin, sqrt, **_):
+    asin = asin or arcsin
+    atan2 = atan2 or arctan2
+
+    def _inverse_haversine_kernel(lat, lng, direction, d):
+        """
+        Compute the inverse haversine on unit sphere.  lat/lng are in degrees,
+        direction in radians; all inputs are either scalars (with ops==math) or
+        arrays (with ops==numpy).
+        """
+        lat = radians(lat)
+        lng = radians(lng)
+        cos_d, sin_d = cos(d), sin(d)
+        cos_lat, sin_lat = cos(lat), sin(lat)
+        sin_d_cos_lat = sin_d * cos_lat
+        return_lat = asin(cos_d * sin_lat + sin_d_cos_lat * cos(direction))
+        return_lng = lng + atan2(sin(direction) * sin_d_cos_lat,
+                                 cos_d - sin_lat * sin(return_lat))
+        return degrees(return_lat), degrees(return_lng)
+    return _inverse_haversine_kernel
+
+
+_haversine_kernel = _create_haversine_kernel(math)
+_inverse_haversine_kernel = _create_inverse_haversine_kernel(math)
+
+try:
+    import numpy
+    has_numpy = True
+    _haversine_kernel_vector = _create_haversine_kernel(numpy)
+    _inverse_haversine_kernel_vector = _create_inverse_haversine_kernel(numpy)
+except ModuleNotFoundError:
+    # Import error will be reported in haversine_vector() / inverse_haversine_vector()
+    has_numpy = False
+
+try:
+    import numba # type: ignore
+    if has_numpy:
+        _haversine_kernel_vector = numba.vectorize(fastmath=True)(_haversine_kernel)
+        # Tuple output is not supported for numba.vectorize. Just jit the numpy version.
+        _inverse_haversine_kernel_vector = numba.njit(fastmath=True)(_inverse_haversine_kernel_vector)
+    _haversine_kernel = numba.njit(_haversine_kernel)
+    _inverse_haversine_kernel = numba.njit(_inverse_haversine_kernel)
+except ModuleNotFoundError:
+    pass
+
+
+def haversine(point1, point2, unit=Unit.KILOMETERS, normalize=False, check=True):
     """ Calculate the great-circle distance between two points on the Earth surface.
 
     Takes two 2-tuples, containing the latitude and longitude of each point in decimal degrees,
@@ -94,6 +192,7 @@ def haversine(point1, point2, unit=Unit.KILOMETERS, normalize=False):
                  initials of its corresponding unit of measurement (i.e. miles = mi)
                  default 'km' (kilometers).
     :param normalize: if True, normalize the points to [-90, 90] latitude and [-180, 180] longitude.
+    :param check: if True, check that points are normalized.
 
     Example: ``haversine((45.7597, 4.8422), (48.8567, 2.3508), unit=Unit.METERS)``
 
@@ -115,25 +214,14 @@ def haversine(point1, point2, unit=Unit.KILOMETERS, normalize=False):
     if normalize:
         lat1, lng1 = _normalize(lat1, lng1)
         lat2, lng2 = _normalize(lat2, lng2)
-    else:
+    elif check:
         _ensure_lat_lon(lat1, lng1)
         _ensure_lat_lon(lat2, lng2)
 
-    # convert all latitudes/longitudes from decimal degrees to radians
-    lat1 = radians(lat1)
-    lng1 = radians(lng1)
-    lat2 = radians(lat2)
-    lng2 = radians(lng2)
-
-    # calculate haversine
-    lat = lat2 - lat1
-    lng = lng2 - lng1
-    d = sin(lat * 0.5) ** 2 + cos(lat1) * cos(lat2) * sin(lng * 0.5) ** 2
+    return get_avg_earth_radius(unit) * _haversine_kernel(lat1, lng1, lat2, lng2)
 
-    return 2 * get_avg_earth_radius(unit) * asin(sqrt(d))
 
-
-def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize=False):
+def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize=False, check=True):
     '''
     The exact same function as "haversine", except that this
     version replaces math functions with numpy functions.
@@ -141,11 +229,9 @@ def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize
     distance between two points, but is much faster for computing
     the distance between two vectors of points due to vectorization.
     '''
-    try:
-        import numpy
-    except ModuleNotFoundError:
-        return 'Error, unable to import Numpy,\
-        consider using haversine instead of haversine_vector.'
+    if not has_numpy:
+        raise RuntimeError('Error, unable to import Numpy, '
+                           'consider using haversine instead of haversine_vector.')
 
     # ensure arrays are numpy ndarrays
     if not isinstance(array1, numpy.ndarray):
@@ -165,23 +251,17 @@ def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize
             raise IndexError(
                 "When not in combination mode, arrays must be of same size. If mode is required, use comb=True as argument.")
 
-    # normalize points or ensure they are proper lat/lon, i.e., in [-90, 90] and [-180, 180]
-    if normalize:
-        array1 = numpy.array([_normalize(p[0], p[1]) for p in array1])
-        array2 = numpy.array([_normalize(p[0], p[1]) for p in array2])
-    else:
-        [_ensure_lat_lon(p[0], p[1]) for p in array1]
-        [_ensure_lat_lon(p[0], p[1]) for p in array2]
-
     # unpack latitude/longitude
     lat1, lng1 = array1[:, 0], array1[:, 1]
     lat2, lng2 = array2[:, 0], array2[:, 1]
 
-    # convert all latitudes/longitudes from decimal degrees to radians
-    lat1 = numpy.radians(lat1)
-    lng1 = numpy.radians(lng1)
-    lat2 = numpy.radians(lat2)
-    lng2 = numpy.radians(lng2)
+    # normalize points or ensure they are proper lat/lon, i.e., in [-90, 90] and [-180, 180]
+    if normalize:
+        lat1, lng1 = _normalize_vector(lat1, lng1)
+        lat2, lng2 = _normalize_vector(lat2, lng2)
+    elif check:
+        _ensure_lat_lon_vector(lat1, lng1)
+        _ensure_lat_lon_vector(lat2, lng2)
 
     # If in combination mode, turn coordinates of array1 into column vectors for broadcasting
     if comb:
@@ -190,27 +270,33 @@ def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize
         lat2 = numpy.expand_dims(lat2, axis=1)
         lng2 = numpy.expand_dims(lng2, axis=1)
 
-    # calculate haversine
-    lat = lat2 - lat1
-    lng = lng2 - lng1
-    d = (numpy.sin(lat * 0.5) ** 2
-         + numpy.cos(lat1) * numpy.cos(lat2) * numpy.sin(lng * 0.5) ** 2)
-
-    return 2 * get_avg_earth_radius(unit) * numpy.arcsin(numpy.sqrt(d))
+    return get_avg_earth_radius(unit) * _haversine_kernel_vector(lat1, lng1, lat2, lng2)
 
 
 def inverse_haversine(point, distance, direction: Union[Direction, float], unit=Unit.KILOMETERS):
-
     lat, lng = point
-    lat, lng = map(radians, (lat, lng))
-    d = distance
     r = get_avg_earth_radius(unit)
-    brng = direction.value if isinstance(direction, Direction) else direction
+    return _inverse_haversine_kernel(lat, lng, direction, distance/r)
+
+
+def inverse_haversine_vector(array, distance, direction, unit=Unit.KILOMETERS):
+    if not has_numpy:
+        raise RuntimeError('Error, unable to import Numpy, '
+                           'consider using inverse_haversine instead of inverse_haversine_vector.')
 
-    return_lat = asin(sin(lat) * cos(d / r) + cos(lat)
-                      * sin(d / r) * cos(brng))
-    return_lng = lng + atan2(sin(brng) * sin(d / r) *
-                             cos(lat), cos(d / r) - sin(lat) * sin(return_lat))
+    # ensure arrays are numpy ndarrays
+    array, distance, direction = map(numpy.asarray, (array, distance, direction))
+
+    # ensure will be able to iterate over rows by adding dimension if needed
+    if array.ndim == 1:
+        array = numpy.expand_dims(array, 0)
+
+    # Asserts that arrays are correctly sized
+    if array.ndim != 2 or array.shape[1] != 2 or array.shape[0] != len(distance) or array.shape[0] != len(direction):
+        raise IndexError("Arrays must be of same size.")
 
-    return_lat, return_lng = map(degrees, (return_lat, return_lng))
-    return return_lat, return_lng
+    # unpack latitude/longitude
+    lat, lng = array[:, 0], array[:, 1]
+
+    r = get_avg_earth_radius(unit)
+    return _inverse_haversine_kernel_vector(lat, lng, direction, distance/r)


Test patch:
diff --git a/tests/test_inverse_haversine_vector.py b/tests/test_inverse_haversine_vector.py
new file mode 100644
index 0000000..4e3dd90
--- /dev/null
+++ b/tests/test_inverse_haversine_vector.py
@@ -0,0 +1,23 @@
+from haversine import inverse_haversine_vector, Unit, Direction
+from numpy import isclose
+from math import pi
+import pytest
+
+from tests.geo_ressources import LYON, PARIS, NEW_YORK, LONDON
+
+
+@pytest.mark.parametrize(
+    "point, dir, dist, result",
+    [
+        (PARIS, Direction.NORTH, 32, (49.144444, 2.3508)),
+        (PARIS, 0, 32, (49.144444, 2.3508)),
+        (LONDON, Direction.WEST, 50, (51.507778, -0.840556)),
+        (LONDON, pi * 1.5, 50, (51.507778, -0.840556)),
+        (NEW_YORK, Direction.SOUTH, 15, (40.568611, -74.235278)),
+        (NEW_YORK, Direction.NORTHWEST, 50, (41.020556, -74.656667)),
+        (NEW_YORK, pi * 1.25, 50, (40.384722, -74.6525)),
+    ],
+)
+def test_inverse_kilometers(point, dir, dist, result):
+    assert isclose(inverse_haversine_vector([point], [dist], [dir]),
+                   ([result[0]], [result[1]]), rtol=1e-5).all()