Skip to content

Commit

Permalink
Merge pull request #52 from merschformann/merschformann/normalize-lat…
Browse files Browse the repository at this point in the history
…-lon

Adds normalization of lat/lon to their corresponding ranges
  • Loading branch information
jdeniau committed Jul 10, 2022
2 parents e3c6952 + b7e74af commit f42078f
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 3 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ haversine(lyon, paris, unit=Unit.NAUTICAL_MILES)
>> 211.78037755311516 # in nautical miles
```

The lat/lon values need to be provided in degrees of the ranges [-90,90] (lat) and [-180,180] (lon).
If values are outside their ranges, an error will be raised. This can be avoided by automatic normalization via the `normalize` parameter.

The `haversine.Unit` enum contains all supported units:

```python
Expand Down
45 changes: 42 additions & 3 deletions haversine/haversine.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from math import radians, cos, sin, asin, sqrt, degrees, pi, atan2
from enum import Enum
from typing import Union
from typing import Union, Tuple


# mean earth radius - https://en.wikipedia.org/wiki/Earth_radius#Mean_radius
Expand Down Expand Up @@ -60,7 +60,29 @@ def get_avg_earth_radius(unit):
return _AVG_EARTH_RADIUS_KM * _CONVERSIONS[unit]


def haversine(point1, point2, unit=Unit.KILOMETERS):
def _normalize(lat: float, lon: float) -> Tuple[float, float]:
"""
Normalize point to [-90, 90] latitude and [-180, 180] longitude.
"""
lat = (lat + 90) % 360 - 90
if lat > 90:
lat = 180 - lat
lon += 180
lon = (lon + 180) % 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.
"""
if lat < -90 or lat > 90:
raise ValueError(f"Latitude {lat} is out of range [-90, 90]")
if lon < -180 or lon > 180:
raise ValueError(f"Longitude {lon} is out of range [-180, 180]")


def haversine(point1, point2, unit=Unit.KILOMETERS, normalize=False):
""" 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,
Expand All @@ -71,6 +93,7 @@ def haversine(point1, point2, unit=Unit.KILOMETERS):
:param unit: a member of haversine.Unit, or, equivalently, a string containing the
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.
Example: ``haversine((45.7597, 4.8422), (48.8567, 2.3508), unit=Unit.METERS)``
Expand All @@ -88,6 +111,14 @@ def haversine(point1, point2, unit=Unit.KILOMETERS):
lat1, lng1 = point1
lat2, lng2 = point2

# normalize points or ensure they are proper lat/lon, i.e., in [-90, 90] and [-180, 180]
if normalize:
lat1, lng1 = _normalize(lat1, lng1)
lat2, lng2 = _normalize(lat2, lng2)
else:
_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)
Expand All @@ -102,7 +133,7 @@ def haversine(point1, point2, unit=Unit.KILOMETERS):
return 2 * get_avg_earth_radius(unit) * asin(sqrt(d))


def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False):
def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False, normalize=False):
'''
The exact same function as "haversine", except that this
version replaces math functions with numpy functions.
Expand Down Expand Up @@ -133,6 +164,14 @@ def haversine_vector(array1, array2, unit=Unit.KILOMETERS, comb=False):
if array1.shape != array2.shape:
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]
Expand Down
46 changes: 46 additions & 0 deletions tests/test_haversine.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from haversine import haversine, Unit
from math import pi
import pytest

from tests.geo_ressources import LYON, PARIS, NEW_YORK, LONDON, EXPECTED_LYON_PARIS

Expand Down Expand Up @@ -36,6 +37,51 @@ def test_haversine_deg_rad():
assert haversine(p1, p2, unit=Unit.RADIANS) == pi
assert round(haversine(p1, p2, unit=Unit.DEGREES), 13) == 180.0


@pytest.mark.parametrize(
"oob_from,oob_to,proper_from,proper_to", [
((-90.0001, 0), (0, 0), (-89.9999, 180), (0, 0)),
((-90.0001, 30), (0, 0), (-89.9999, -150), (0, 0)),
((0, 0), (90.0001, 0), (0, 0), (89.9999, -180)),
((0, 0), (90.0001, 30), (0, 0), (89.9999, -150)),
((0, -180.0001), (0, 0), (0, 179.9999), (0, 0)),
((30, -180.0001), (0, 0), (30, 179.9999), (0, 0)),
((0, 0), (0, 180.0001), (0, 0), (0, -179.9999)),
((0, 0), (30, 180.0001), (0, 0), (30, -179.9999)),
]
)
def test_normalization(oob_from, oob_to, proper_from, proper_to):
"""
Test makes sure that normalization works as expected by comparing distance of out of
bounds points cases to equal cases where all points are within lat/lon ranges. The
results are expected to be equal (within some tolerance to account for numerical
issues).
"""
normalized_during, normalized_already = (
haversine(oob_from, oob_to, Unit.DEGREES, normalize=True),
haversine(proper_from, proper_to, Unit.DEGREES, normalize=True),
)
assert normalized_during == pytest.approx(normalized_already, abs=1e-10)


@pytest.mark.parametrize(
"oob_from,oob_to", [
((-90.0001, 0), (0, 0)),
((0, 0), (90.0001, 0)),
((0, -180.0001), (0, 0)),
((0, 0), (0, 180.0001)),
]
)
def test_out_of_bounds(oob_from, oob_to):
"""
Test makes sure that a ValueError is raised when latitude or longitude values are out of bounds.
"""
with pytest.raises(ValueError):
haversine(oob_from, oob_to)
with pytest.raises(ValueError):
haversine(oob_from, oob_to, normalize=False)


def test_haversine_deg_rad_great_circle_distance():
"""
Test makes sure the haversine functions returns the great circle distance (https://en.wikipedia.org/wiki/Great-circle_distance) between two points on a sphere.
Expand Down
44 changes: 44 additions & 0 deletions tests/test_haversine_vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,50 @@ def test_lyon_paris(unit):
return test_lyon_paris(unit)


@pytest.mark.parametrize(
"oob_from,oob_to,proper_from,proper_to", [
((-90.0001, 0), (0, 0), (-89.9999, 180), (0, 0)),
((-90.0001, 30), (0, 0), (-89.9999, -150), (0, 0)),
((0, 0), (90.0001, 0), (0, 0), (89.9999, -180)),
((0, 0), (90.0001, 30), (0, 0), (89.9999, -150)),
((0, -180.0001), (0, 0), (0, 179.9999), (0, 0)),
((30, -180.0001), (0, 0), (30, 179.9999), (0, 0)),
((0, 0), (0, 180.0001), (0, 0), (0, -179.9999)),
((0, 0), (30, 180.0001), (0, 0), (30, -179.9999)),
]
)
def test_normalization(oob_from, oob_to, proper_from, proper_to):
"""
Test makes sure that normalization works as expected by comparing distance of out of
bounds points cases to equal cases where all points are within lat/lon ranges. The
results are expected to be equal (within some tolerance to account for numerical
issues).
"""
normalized_during, normalized_already = (
haversine_vector([oob_from], [oob_to], Unit.DEGREES, normalize=True),
haversine_vector([proper_from], [proper_to], Unit.DEGREES, normalize=True),
)
assert normalized_during == pytest.approx(normalized_already, abs=1e-10)


@pytest.mark.parametrize(
"oob_from,oob_to", [
((-90.0001, 0), (0, 0)),
((0, 0), (90.0001, 0)),
((0, -180.0001), (0, 0)),
((0, 0), (0, 180.0001)),
]
)
def test_out_of_bounds(oob_from, oob_to):
"""
Test makes sure that a ValueError is raised when latitude or longitude values are out of bounds.
"""
with pytest.raises(ValueError):
haversine_vector([oob_from], [oob_to])
with pytest.raises(ValueError):
haversine_vector([oob_from], [oob_to], normalize=False)


def test_haversine_vector_comb():
unit = Unit.KILOMETERS
expected = [
Expand Down

0 comments on commit f42078f

Please sign in to comment.