For many types of analysis, it is necessary to compute the distance between a pair of geographic coordinates, or the distance between multiple sets of coordinates and a central reference point. This can be readily accomplished by making use of the Haversine formula, which can be used to determine the great-circle distance between two points on a sphere given their longitudes and latitudes. The formulaic representation of the Haversine formula is
$$ d = 2r\arcsin \left({\sqrt {\sin^{2}\left({\frac{\varphi_{2}- \varphi_{1}}{2}}\right)+\cos(\varphi_{1})\cos(\varphi_{2})\sin^{2} \left({\frac{\lambda_{2}-\lambda_{1}}{2}}\right)}}\right) $$
Where:
- $\phi_{1}$: Latitude of point 1
- $\phi_{2}$: Latitude of point 2
- $\lambda_{1}$: Longitude of point 1
- $\lambda_{2}$: Longitude of point 2
- $r$: The radius of the Earth
Using this notation:
- $(\phi_{1}, \lambda_{1})$ represents geographic coordinate pair 1
- $(\phi_{2}, \lambda_{2})$ represents geographic coordinate pair 2
Geographic coordinates can be specified in many different formats, but one common representation, and the one we'll focus on for the remainder of the article is the decimal degrees format (e.g., {41.8781N, 87.6298W} for Chicago; {19.4326N, 99.1332W} for Mexico City, etc.).
Computer applications typically require the arguments of trigonometric functions to be expressed in radians. To convert decimal degrees to radians, multiply the number of degrees by $\pi/180$.
The Earth is not a perfect sphere, and as a result, the radius varies as a function of distance from the equator (the radius is 6357km at the poles and 6378km at the equator. Due to this variation, the haversine distance calculation will always contain some error, but for non-antipodal coordinate pairs still provides a good approximation. In the examples that follow, we specify a constant Earth radius of 6367km. Adjust as required to suit your purposes.
Typically, inverse trigonometric functions return results expressed in radians.
This is the case for the Python Standard Library's inverse trigonometric
functions in the math library, which can be verified from the docstring for
arcsin
:
>>> import math
>>> help(math.asin)
Help on built-in function asin in module math:
asin(...)
asin(x)
Return the arc sine (measured in radians) of x.
To express the result in decimal degrees, multiply the number of radians returned by the inverse trigonometric function by $180/\pi=57.295780$ degrees/radian.
Next we create a function that computes the haversine formula and returns the distance between two geographic coordinate pairs specified in decimal degrees.
"""
Haversine formula implementation. For more information, refer to:
http://www.movable-type.co.uk/scripts/gis-faq-5.1.html
"""
import math
def haverdist(geoloc1, geoloc2, R=6367):
"""
Compute distance between geographic coordinate pairs.
Parameters
----------
geoloc1: tuple or list
(lat1, lon1) of first geolocation.
geoloc2: tuple or list
(lat2, lon2) of second geolocation.
R: float
Radius of the Earth (est).
Returns
-------
float
Distance in kilometers between geoloc1 and geoloc2.
See Also
--------
.. [1] http://www.movable-type.co.uk/scripts/gis-faq-5.1.html
"""
# Convert degress to radians then compute differences.
rlat1, rlon1 = [i * math.pi / 180 for i in geoloc1]
rlat2, rlon2 = [i * math.pi / 180 for i in geoloc2]
drlat, drlon = (rlat2 - rlat1), (rlon2 - rlon1)
init = (math.sin(drlat / 2.))**2 + (math.cos(rlat1)) * \
(math.cos(rlat2)) * (math.sin(drlon /2.))**2
# The intermediate result `init` is the great circle distance
# in radians. The return value will be in the same units as R.
# min protects against possible roundoff errors that could
# sabotage computation of the arcsine if the two points are
# very nearly antipodal, e.g., on opposide sides of the Earth.
return(2.0 * R * math.asin(min(1., math.sqrt(init))))
To test haverdist
, we compute the distance between New York City and Chicago.
For Chicago, use latitude=41.881832, and longitude=-87.623177 and New York City
latitude=40.730610 and longitude=-73.935242. It’s now only a matter of passing
the coordinates as tuples to the function and verifying the result:
>>> loc1 = (41.881832, -87.623177) # Chicago
>>> loc2 = (40.730610, -73.935242) # NYC
>>> haverdist(geoloc1=loc1, geoloc2=loc2, R=6367)
1148.514752233814
Thus, haverdist
indicates that the distance between Chicago, Illinois and
New York City is ~1149km.
If we compare this to Google Maps GeoDistance API using the same set of
coordinate pairs, we get 1,296km. The discrepancy is due to the nature of the
measurement: The distance calculated with haverdist
computes the shortest
distance between the two pairs of geographic coordinates, or the
"air travel distance". The Google Maps API calculates the driving distance,
which is always greater than or equal to the air travel distance.
We can validate our measurement using distancefromto.net. Plugging in the distance from Chicago to New York, we see that the air travel distance is 1,149km (714 miles), which agrees with the distance computed using the haversine formula.
Application
We can use the ability to calculate the distance between two or more geographic
coordinate pairs for a variety of purposes. To demonstrate, we use the
haverdist function to compute the velocity of the International Space Station
with respect to the surface of the Earth, by taking two snapshots of the ISS's
Earth-relative position separated by a known time differential. The velocity is
then the distance between the two sets of coordinates (from haverdist
)
divided by the time differential.
In order to obtain the position of the International Space Station relative to the Earth's surface at any given moment, we leverage the Open Notify API, querying the following URL (which takes no inputs):
http://api.open-notify.org/iss-now.json
A successful response will look similar to the following:
{
"message": "success",
"timestamp": UNIX_TIME_STAMP,
"iss_position": {
"latitude": CURRENT_LATITUDE,
"longitude": CURRENT_LONGITUDE
}
}
According to the Open Notify website, polling should occur at 5 second intervals. We'll use the same duration as the time differential component in the velocity calculation.
In what follows, we define two additional functions: getiss
which returns
the Open Notify json response with timestamped latitude and longitude of the
ISS, and getspeed
, which takes two coordinate pairs and the units in which
the computed speed should be returned:
"""
Compute the speed of ISS relative to the surface of the Earth.
Distance calculation relies on the haversine function, which
estimates the radius of the Earth as a constant scalar.
"""
import datetime
import math
import os
import sys
import time
import requests
def getiss(proxy=None):
"""
Get timestamped geo-coordinates of International Space Station.
Parameters
----------
proxy: dict, optional
Proxy details if required to make web requests.
Defaults to None.
Returns
-------
dict
Dictionary with keys "latitude", "longitude" and
"timestamp" indicating time and position of ISS.
"""
dpos = dict()
url = "http://api.open-notify.org/iss-now.json"
resp = requests.get(url, proxies=proxy).json()
if resp["message"]=="success":
dpos["timestamp"] = resp["timestamp"]
dpos["latitude"] = float(resp["iss_position"]["latitude"])
dpos["longitude"] = float(resp["iss_position"]["longitude"])
else:
raise RuntimeError("Unable to access Open Notify API")
return(dpos)
def haverdist(geoloc1, geoloc2, R=6367):
"""
Compute distance between geographic coordinate pairs in kilometers.
Parameters
----------
geoloc1: tuple or list
(lat1, lon1) of first geolocation.
geoloc2: tuple or list
(lat2, lon2) of second geolocation.
R: float
Radius of the Earth (est).
Returns
-------
float
Distance in kilometers between geoloc1 and geoloc2.
See Also
--------
.. [1] http://www.movable-type.co.uk/scripts/gis-faq-5.1.html
"""
# Convert degress to radians then compute differences.
rlat1, rlon1 = [i * math.pi / 180 for i in geoloc1]
rlat2, rlon2 = [i * math.pi / 180 for i in geoloc2]
drlat, drlon = (rlat2 - rlat1), (rlon2 - rlon1)
init = (math.sin(drlat / 2.))**2 + (math.cos(rlat1)) * \
(math.cos(rlat2)) * (math.sin(drlon /2.))**2
# The intermediate result `init` is the great circle distance
# in radians. The return value will be in the same units as R.
# min protects against possible roundoff errors that could
# sabotage computation of the arcsine if the two points are
# very nearly antipodal, e.g., on opposide sides of the Earth
return(2.0 * R * math.asin(min(1., math.sqrt(init))))
def getspeed(dloc1, dloc2):
"""
Compute speed of ISS relative to Earth's surface using
a pair of coordinates retrieved from `getiss`.
Parameters
----------
dloc1: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the first positional snapshot.
dloc2: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the second positional snapshot.
Returns
-------
float
Scalar value representing the average speed of the International
Space Station relative to the Earth going from ``dloc1`` to
``dloc2``.
"""
# Convert unix epochs to timestamp datetime objects.
ts1 = datetime.datetime.fromtimestamp(dloc1['timestamp'])
ts2 = datetime.datetime.fromtimestamp(dloc2['timestamp'])
secs = abs((ts2-ts1).total_seconds())
loc1 = (dloc1["latitude"], dloc1["longitude"])
loc2 = (dloc2["latitude"], dloc2["longitude"])
dist = haverdist(geoloc1=loc1, geoloc2=loc2)
vinit = (dist / secs) # kilometers per second
return(vinit * 3600)
# Typically, the following would be collected under a `main`
# function and called from the command line, but for ease of
# demonstration, we present the logic immediately following
# the function declarations.
ts_init = datetime.datetime.now().strftime("%c")
print(f"[{ts_init}] Retrieving ISS geographic coordinate snapshots...")
dpos1 = getiss()
time.sleep(5)
dpos2 = getiss()
speed = getspeed(dloc1=dpos1, dloc2=dpos2)
ts_final = datetime.datetime.now().strftime("%c")
print(f"[{ts_final}] ISS speed relative to Earth's surface: {speed:.2f}km/h")
Running the above produces the following:
[Sat Dec 29 12:40:30 2018] Retrieving ISS geographic coordinate snapshots...
[Sat Dec 29 12:40:35 2018] ISS speed relative to Earth's surface: 26253.60km/h
According to our calculations, the average orbital speed of the ISS relative to
the Earth's surface for the targeted time interval exceeded 26,000km/h
(~16,313.23mph). Wikipedia's ISS page
puts the average orbital speed at 27,600km/h (17,100 mph), a difference of only
5% when compared with getspeed
's estimate, which isn't bad considering the
constant Earth radius assumption.
A Note on Parameterized Decorators
The official definition of a decorator is a function that takes another function and extends the behavior of the latter function without explicitly modifying it. I never really understood the purpose of decorators until coming to the realization that they can be used as a way to update the units of a value returned by a function without needing to alter the nature of the function of interest in anyway.
To illustrate, we refer back to getspeed
declared in the previous code block.
This function returns a scalar representing the average speed of the ISS over a
given time differential. The result is returned in kilometers per hour, with no
parameter available to apply a change of units. To incorporate this
functionality, we can either (1) add a units parameter to change the units of
the calculated speed prior to returning to caller, or (2) implement a decorator
which converts the units of the calculated value independent of getspeed
.
In this case, the first option seems like an obvious choice, but instead of
getspeed
imagine a different function (we'll call it legacyfunc
) that we
didn't author, which has been in production for a very long time, which is very
difficult step through due to overly-complicated control structures, has lots
of strange optional parameters and many more lines of code than getspeed
, was
responsible for returning the value of interest, and it is this value that
requires a change of units. In this case, leaving the original callable
unmodified and wrapping the result returned by legacyfunc
with logic to
handle the units conversion would be the better choice.
We can create a function to handle the change of units conversion. This will result in a parameterized decorator, the parameter indicating which units to return the orbital speed. For this implementation, the only options will be kilometers per hour or miles per hour, but the decorator can be extended to facilitate any number of additional distance or time conversions.
"""
Demonstration of parameterized decorator to facilitate change of
units conversion.
"""
import functools
def units(spec):
"""
Specify the units to represent orbital speed. `spec` can be either
"kmph" (kilometer per hour) or "mph" (miles per hour). Defaults
to "kmph".
"""
def decorator(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
resinit = function(*args, **kwargs)
# resinit is speed represented in kilometers per hour.
if spec=="mph":
result = resinit * 0.62137119
else:
result = res_init
return(result)
return(wrapper)
return(decorator)
Once units
has been declared, we modify the declaration of getspeed
by
referencing the units
decorator as follows:
import functools
@units("mph")
def getspeed(dloc1, dloc2):
"""
Compute speed of ISS relative to Earth's surface using
a pair of coordinates retrieved from `getiss`.
Parameters
----------
dloc1: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the first positional snapshot.
dloc2: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the second positional snapshot.
Returns
-------
float
Scalar value representing the average speed of the International
Space Station relative to the Earth going from ``dloc1`` to
``dloc2``.
"""
# Convert unix epochs to timestamp datetime objects.
ts1 = datetime.datetime.fromtimestamp(dloc1['timestamp'])
ts2 = datetime.datetime.fromtimestamp(dloc2['timestamp'])
secs = abs((ts2-ts1).total_seconds())
loc1 = (dloc1["latitude"], dloc1["longitude"])
loc2 = (dloc2["latitude"], dloc2["longitude"])
dist = haverdist(geoloc1=loc1, geoloc2=loc2)
vinit = (dist / secs) # kilometers per second
return(vinit * 3600)
With this change, the scalar representing kilometers per hour returned by
getspeed
will be converted to miles per hour. Here's the full implementation
repeated, the differences being the units decoarator and decorated getspeed
function:
import datetime
import functools
import math
import os
import sys
import time
import requests
def units(spec):
"""
Specify the units to represent orbital speed. `spec` can be either
"kmph" (kilometer per hour) or "mph" (miles per hour). Defaults
to "kmph".
"""
def decorator(function):
@functools.wraps(function)
def wrapper(*args, **kwargs):
resinit = function(*args, **kwargs)
# resinit is speed represented in kilometers per hour
if spec=="mph":
result = resinit * 0.62137119
else: result = res_init
return(result)
return(wrapper)
return(decorator)
def getiss(proxy=None):
"""
Get timestamped geo-coordinates of International Space Station.
Parameters
----------
proxy: dict, optional
Proxy details if required to make web requests.
Defaults to None.
Returns
-------
dict
Dictionary with keys "latitude", "longitude" and
"timestamp" indicating time and position of ISS.
"""
dpos = dict()
url = "http://api.open-notify.org/iss-now.json"
resp = requests.get(url, proxies=proxy).json()
if resp["message"]=="success":
dpos["timestamp"] = resp["timestamp"]
dpos["latitude"] = float(resp["iss_position"]["latitude"])
dpos["longitude"] = float(resp["iss_position"]["longitude"])
else:
raise RuntimeError("Unable to access Open Notify API")
return(dpos)
def haverdist(geoloc1, geoloc2, R=6367):
"""
Compute distance between geographic coordinate pairs in kilometers.
Parameters
----------
geoloc1: tuple or list
(lat1, lon1) of first geolocation.
geoloc2: tuple or list
(lat2, lon2) of second geolocation.
R: float
Radius of the Earth (est).
Returns
-------
float
Distance in kilometers between geoloc1 and geoloc2.
See Also
--------
.. [1] http://www.movable-type.co.uk/scripts/gis-faq-5.1.html
"""
# Convert degress to radians then compute differences.
rlat1, rlon1 = [i * math.pi / 180 for i in geoloc1]
rlat2, rlon2 = [i * math.pi / 180 for i in geoloc2]
drlat, drlon = (rlat2 - rlat1), (rlon2 - rlon1)
init = (math.sin(drlat / 2.))**2 + (math.cos(rlat1)) * \
(math.cos(rlat2)) * (math.sin(drlon /2.))**2
# The intermediate result `init` is the great circle distance
# in radians. The return value will be in the same units as R.
# min protects against possible roundoff errors that could
# sabotage computation of the arcsine if the two points are
# very nearly antipodal, e.g., on opposide sides of the Earth
return(2.0 * R * math.asin(min(1., math.sqrt(init))))
@units("mph")
def getspeed(dloc1, dloc2):
"""
Compute speed of ISS relative to Earth's surface using
a pair of coordinates retrieved from `getiss`.
Parameters
----------
dloc1: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the first positional snapshot.
dloc2: dict
Dictionary with keys "latitude", "longitude" "timestamp"
associated with the second positional snapshot.
Returns
-------
float
Scalar value representing the average speed of the International
Space Station relative to the Earth going from ``dloc1`` to
``dloc2``.
"""
# Convert unix epochs to timestamp datetime objects.
ts1 = datetime.datetime.fromtimestamp(dloc1['timestamp'])
ts2 = datetime.datetime.fromtimestamp(dloc2['timestamp'])
secs = abs((ts2-ts1).total_seconds())
loc1 = (dloc1["latitude"], dloc1["longitude"])
loc2 = (dloc2["latitude"], dloc2["longitude"])
dist = haverdist(geoloc1=loc1, geoloc2=loc2)
vinit = (dist / secs) # kilometers per second
return(vinit * 3600)
# Note that as a result of decorating `getspeed` with the `units`,
# the ISS speed estimate will be in miles per hour instead of
# kilometers per hour.
ts_init = datetime.datetime.now().strftime("%c")
print(f"[{ts_init}] Retrieving ISS geographic coordinate snapshots...")
dpos1 = getiss()
time.sleep(5)
dpos2 = getiss()
speed = getspeed(dloc1=dpos1, dloc2=dpos2)
ts_final = datetime.datetime.now().strftime("%c")
print(f"[{ts_final}] ISS speed relative to Earth's surface: {speed:.2f}mph")
Running results in:
[Sat Dec 29 13:44:17 2018] Retrieving ISS geographic coordinate snapshots...
[Sat Dec 29 13:44:22 2018] ISS speed relative to Earth's surface: 16126.21mph
Once decorated, the call to getspeed
is no different than before. The speed
is returned in kilometers per hour, which is passed to the decorator, which
converts the speed to the units specified.