Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a GeoLocation utility #17

Merged
merged 5 commits into from
Nov 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions aiopurpleair/util/geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Define various geographical utilities."""
from __future__ import annotations

import math
from dataclasses import dataclass

EARTH_RAIDUS_KM = 6378.1

MINIMUM_LATITUDE = math.radians(-90)
MAXIMUM_LATITUDE = math.radians(90)
MINIMUM_LONGITUDE = math.radians(-180)
MAXIMUM_LONGITUDE = math.radians(180)


@dataclass
class GeoLocation:
"""Define a representation of a single latitude/longitude coordinate.

Inspiration from https://github.com/jfein/PyGeoTools/blob/master/geolocation.py.
"""

latitude_radians: float
longitude_radians: float
latitude_degrees: float
longitude_degrees: float

def __post_init__(self) -> None:
"""Perform some post-init processing.

Raises:
ValueError: Raised upon invalid latitude/longitude.
"""
for kind, value, minimum, maximum in (
("latitude", self.latitude_radians, MINIMUM_LATITUDE, MAXIMUM_LATITUDE),
("longitude", self.longitude_radians, MINIMUM_LONGITUDE, MAXIMUM_LONGITUDE),
):
if value < minimum or value > maximum:
raise ValueError(f"Invalid {kind}: {value} radians")

@classmethod
def from_degrees(
cls, latitude_degrees: float, longitude_degrees: float
) -> GeoLocation:
"""Create a GeoLocation object from a latitude/longitude in degrees.

Args:
latitude_degrees: A latitude in degrees.
longitude_degrees: A longitude in degrees.

Returns:
A GeoLocation object.
"""
latitude_radians = math.radians(latitude_degrees)
longitude_radians = math.radians(longitude_degrees)
return cls(
latitude_radians, longitude_radians, latitude_degrees, longitude_degrees
)

@classmethod
def from_radians(
cls, latitude_radians: float, longitude_radians: float
) -> GeoLocation:
"""Create a GeoLocation object from a latitude/longitude in radians.

Args:
latitude_radians: A latitude in radians.
longitude_radians: A longitude in radians.

Returns:
A GeoLocation object.
"""
latitude_degrees = math.degrees(latitude_radians)
longitude_degrees = math.degrees(longitude_radians)
return cls(
latitude_radians, longitude_radians, latitude_degrees, longitude_degrees
)

def bounding_box(self, distance_km: float) -> tuple[GeoLocation, GeoLocation]:
bachya marked this conversation as resolved.
Show resolved Hide resolved
"""Calculate a bounding box a certain distance from this GeoLocation.

Args:
distance_km: A distance (in kilometers).

Returns:
Two GeoLocation objects (representing the NW and SE corners of the box).

Raises:
ValueError: Raised on a negative distance_km parameter.
"""
if distance_km < 0:
raise ValueError("Cannot calculate a bounding box with negative distance")

distance_radians = distance_km / EARTH_RAIDUS_KM
box_minimum_latitude = self.latitude_radians - distance_radians
box_maximum_latitude = self.latitude_radians + distance_radians

if MINIMUM_LATITUDE < box_maximum_latitude < MAXIMUM_LATITUDE:
delta_longitude = math.asin(
math.sin(distance_radians) / math.cos(self.latitude_radians)
)

box_minimum_longitude = self.longitude_radians - delta_longitude
if box_minimum_longitude < MINIMUM_LONGITUDE:
box_minimum_longitude += 2 * math.pi

box_maximum_longitude = self.longitude_radians + delta_longitude
if box_maximum_longitude > MAXIMUM_LONGITUDE:
box_maximum_longitude -= 2 * math.pi
else:
# One of the poles is within the bounding box:
box_minimum_latitude = max(box_minimum_latitude, MINIMUM_LATITUDE)
box_maximum_latitude = min(box_maximum_latitude, MAXIMUM_LATITUDE)
box_minimum_longitude = MINIMUM_LONGITUDE
box_maximum_longitude = MAXIMUM_LONGITUDE

return (
GeoLocation.from_radians(box_maximum_latitude, box_minimum_longitude),
GeoLocation.from_radians(box_minimum_latitude, box_maximum_longitude),
)

def distance_to(self, endpoint: GeoLocation) -> float:
"""Calculate the great circle distance between this GeoLocation and another.

Args:
endpoint: The GeoLocation to which the distance should be measured.

Returns:
The distance between this GeoLocation and the endpoint GeoLocation.
"""
return EARTH_RAIDUS_KM * math.acos(
math.sin(self.latitude_radians) * math.sin(endpoint.latitude_radians)
+ math.cos(self.latitude_radians)
* math.cos(endpoint.latitude_radians)
* math.cos(self.longitude_radians - endpoint.longitude_radians)
)
115 changes: 115 additions & 0 deletions tests/util/test_geo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Define geographical util tests."""
import pytest

from aiopurpleair.util.geo import GeoLocation


@pytest.mark.parametrize(
"latitude,longitude,distance_km,nw_latitude,nw_longitude,se_latitude,se_longitude",
[
(
51.5285582,
-0.2416796,
5,
51.57347,
-0.31388,
51.48364,
-0.16948,
),
(
0.2,
179.99999999999,
500,
4.6916,
175.50837,
-4.2916,
-175.50837,
),
(
0.2,
-179.99999999999,
500,
4.6916,
175.50837,
-4.2916,
-175.50837,
),
(
89.9,
0.2,
500,
90.0,
-180.0,
85.4084,
180.0,
),
],
)
def test_geo_location_bounding_box( # pylint: disable=too-many-arguments
distance_km: float,
latitude: float,
longitude: float,
nw_latitude: float,
nw_longitude: float,
se_latitude: float,
se_longitude: float,
) -> None:
"""Test getting a 5km bounding box around London.

Args:
distance_km: The bounding box distance (in kilometers).
latitude: The central latitude.
longitude: The central longitude.
nw_latitude: The latitude of the NW point.
nw_longitude: The longitude of the NW point.
se_latitude: The latitude of the SE point.
se_longitude: The longitude of the SE point.
"""
location = GeoLocation.from_degrees(latitude, longitude)
nw_coordinate, se_coordinate = location.bounding_box(distance_km)
# We round to prevent floating point errors in CI:
assert round(nw_coordinate.latitude_degrees, 5) == nw_latitude
assert round(nw_coordinate.longitude_degrees, 5) == nw_longitude
assert round(se_coordinate.latitude_degrees, 5) == se_latitude
assert round(se_coordinate.longitude_degrees, 5) == se_longitude


def test_geo_location_bounding_box_invalid_distance() -> None:
"""Test an error with an invalid bounding box distance."""
location = GeoLocation.from_degrees(51.5285582, -0.2416796)
with pytest.raises(ValueError) as err:
_, _ = location.bounding_box(-1)
assert "Cannot calculate a bounding box with negative distance" in str(err.value)


def test_geo_location_distance_to() -> None:
"""Test getting the distance between two GeoLocation objects."""
london = GeoLocation.from_degrees(51.5285582, -0.2416796)
liverpool = GeoLocation.from_degrees(53.4121569, -2.9860979)
distance = london.distance_to(liverpool)
assert distance == 280.31725082207095


def test_geo_location_from_degrees() -> None:
"""Test creating a GeoLocation object from a degrees-based latitude/longitude."""
location = GeoLocation.from_degrees(51.5285582, -0.2416796)
assert location.latitude_degrees == 51.5285582
assert location.longitude_degrees == -0.2416796
assert location.latitude_radians == 0.8993429993955228
assert location.longitude_radians == -0.004218104754902888


def test_geo_location_from_radians() -> None:
"""Test creating a GeoLocation object from a radians-based latitude/longitude."""
location = GeoLocation.from_radians(0.8993429993955228, -0.004218104754902888)
assert location.latitude_degrees == 51.5285582
assert location.longitude_degrees == -0.24167960000000002
assert location.latitude_radians == 0.8993429993955228
assert location.longitude_radians == -0.004218104754902888


def test_geo_location_invalid_coordinates() -> None:
"""Test an error with invalid coordinates."""
with pytest.raises(ValueError) as err:
_ = GeoLocation.from_degrees(97.0, -0.2416796)
assert "Invalid latitude: 1.6929693744344996 radians" in str(err.value)