From 6f9935072ed84e932f56d6211264b2ef9e631ed6 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Sat, 2 Jan 2021 14:31:59 +0100 Subject: [PATCH] Add UTMCoordinate class - the class is a utility to abstract working with latitude-longitude coordinates in their UTM representation (~ "cartesian plane") - the class's .x and .y properties enable working with simple x-y coordinates where the (0, 0) origin is the lower-left of a city's viewport --- setup.cfg | 7 + src/urban_meal_delivery/db/utils/__init__.py | 3 + .../db/utils/coordinates.py | 130 +++++++++++ tests/db/utils/__init__.py | 1 + tests/db/utils/test_coordinates.py | 206 ++++++++++++++++++ 5 files changed, 347 insertions(+) create mode 100644 src/urban_meal_delivery/db/utils/__init__.py create mode 100644 src/urban_meal_delivery/db/utils/coordinates.py create mode 100644 tests/db/utils/__init__.py create mode 100644 tests/db/utils/test_coordinates.py diff --git a/setup.cfg b/setup.cfg index cfc1969..91f2727 100644 --- a/setup.cfg +++ b/setup.cfg @@ -149,6 +149,8 @@ per-file-ignores = S311, # Shadowing outer scopes occurs naturally with mocks. WPS442, + # Test names may be longer than 40 characters. + WPS118, # Modules may have many test cases. WPS202,WPS204,WPS214, # Do not check for Jones complexity in the test suite. @@ -167,6 +169,9 @@ per-file-ignores = # Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development max-complexity = 10 +# Allow more than wemake-python-styleguide's 7 methods per class. +max-methods = 12 + # Comply with black's style. # Source: https://github.com/psf/black/blob/master/docs/the_black_code_style.md#line-length max-line-length = 88 @@ -238,6 +243,8 @@ ignore_missing_imports = true ignore_missing_imports = true [mypy-sqlalchemy.*] ignore_missing_imports = true +[mypy-utm.*] +ignore_missing_imports = true [pylint.FORMAT] diff --git a/src/urban_meal_delivery/db/utils/__init__.py b/src/urban_meal_delivery/db/utils/__init__.py new file mode 100644 index 0000000..3fe2e82 --- /dev/null +++ b/src/urban_meal_delivery/db/utils/__init__.py @@ -0,0 +1,3 @@ +"""Utilities used by the ORM models.""" + +from urban_meal_delivery.db.utils.coordinates import UTMCoordinate # noqa:F401 diff --git a/src/urban_meal_delivery/db/utils/coordinates.py b/src/urban_meal_delivery/db/utils/coordinates.py new file mode 100644 index 0000000..17a60e6 --- /dev/null +++ b/src/urban_meal_delivery/db/utils/coordinates.py @@ -0,0 +1,130 @@ +"""A `UTMCoordinate` class to unify working with coordinates.""" + +from __future__ import annotations + +from typing import Optional + +import utm + + +class UTMCoordinate: + """A GPS location represented in UTM coordinates. + + For further info, we refer to this comprehensive article on the UTM system: + https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system + """ + + # pylint:disable=too-many-instance-attributes + + def __init__( + self, latitude: float, longitude: float, relative_to: UTMCoordinate = None, + ) -> None: + """Cast a WGS84-conforming `latitude`-`longitude` pair as UTM coordinates.""" + # The SQLAlchemy columns come as `Decimal`s due to the `DOUBLE_PRECISION`. + self._latitude = float(latitude) + self._longitude = float(longitude) + + easting, northing, zone, band = utm.from_latlon(self._latitude, self._longitude) + + # `.easting` and `.northing` as `int`s are precise enough. + self._easting = int(easting) + self._northing = int(northing) + self._zone = zone + self._band = band.upper() + + self._normalized_easting: Optional[int] = None + self._normalized_northing: Optional[int] = None + + if relative_to: + try: + self.relate_to(relative_to) + except TypeError: + raise TypeError( + '`relative_to` must be a `UTMCoordinate` object', + ) from None + except ValueError: + raise ValueError( + '`relative_to` must be in the same UTM zone as the `latitude`-`longitude` pair', # noqa:E501 + ) from None + + def __repr__(self) -> str: + """A non-literal text representation. + + Convention is {ZONE} {EASTING} {NORTHING}. + + Example: + `'` + """ + return f'' # noqa:WPS221 + + @property + def easting(self) -> int: + """The easting of the location in meters.""" + return self._easting + + @property + def northing(self) -> int: + """The northing of the location in meters.""" + return self._northing + + @property + def zone(self) -> str: + """The UTM zone of the location.""" + return f'{self._zone}{self._band}' + + def __eq__(self, other: object) -> bool: + """Check if two `UTMCoordinate` objects are the same location.""" + if not isinstance(other, UTMCoordinate): + return NotImplemented + + if self.zone != other.zone: + raise ValueError('locations must be in the same zone, including the band') + + return (self.easting, self.northing) == (other.easting, other.northing) + + @property + def x(self) -> int: # noqa:WPS111 + """The `.easting` of the location in meters, relative to some origin. + + The origin, which defines the `(0, 0)` coordinate, is set with `.relate_to()`. + """ + if self._normalized_easting is None: + raise RuntimeError('an origin to relate to must be set first') + + return self._normalized_easting + + @property + def y(self) -> int: # noqa:WPS111 + """The `.northing` of the location in meters, relative to some origin. + + The origin, which defines the `(0, 0)` coordinate, is set with `.relate_to()`. + """ + if self._normalized_northing is None: + raise RuntimeError('an origin to relate to must be set first') + + return self._normalized_northing + + def relate_to(self, other: UTMCoordinate) -> None: + """Make the origin in the lower-left corner relative to `other`. + + The `.x` and `.y` properties are the `.easting` and `.northing` values + of `self` minus the ones from `other`. So, `.x` and `.y` make up a + Cartesian coordinate system where the `other` origin is `(0, 0)`. + + This method is implicitly called by `.__init__()` if that is called + with a `relative_to` argument. + + To prevent semantic errors in calculations based on the `.x` and `.y` + properties, the `other` origin may only be set once! + """ + if self._normalized_easting is not None: + raise RuntimeError('the `other` origin may only be set once') + + if not isinstance(other, UTMCoordinate): + raise TypeError('`other` is not a `UTMCoordinate` object') + + if self.zone != other.zone: + raise ValueError('`other` must be in the same zone, including the band') + + self._normalized_easting = self.easting - other.easting + self._normalized_northing = self.northing - other.northing diff --git a/tests/db/utils/__init__.py b/tests/db/utils/__init__.py new file mode 100644 index 0000000..4a95f0a --- /dev/null +++ b/tests/db/utils/__init__.py @@ -0,0 +1 @@ +"""Test the utilities for the ORM layer.""" diff --git a/tests/db/utils/test_coordinates.py b/tests/db/utils/test_coordinates.py new file mode 100644 index 0000000..6909240 --- /dev/null +++ b/tests/db/utils/test_coordinates.py @@ -0,0 +1,206 @@ +"""Test the `UTMCoordinate` class.""" +# pylint:disable=no-self-use + +import pytest + +from urban_meal_delivery.db import utils + + +# All tests take place in Paris. +MIN_EASTING, MAX_EASTING = 443_100, 461_200 +MIN_NORTHING, MAX_NORTHING = 5_407_200, 5_416_800 +ZONE = '31U' + + +@pytest.fixture +def location(address): + """A `UTMCoordinate` object based off the `address` fixture.""" + obj = utils.UTMCoordinate(address.latitude, address.longitude) + + assert obj.zone == ZONE # sanity check + + return obj + + +@pytest.fixture +def faraway_location(): + """A `UTMCoordinate` object far away from the `location`.""" + obj = utils.UTMCoordinate(latitude=0, longitude=0) + + assert obj.zone != ZONE # sanity check + + return obj + + +@pytest.fixture +def origin(city): + """A `UTMCoordinate` object based off the one and only `city`.""" + # Use the `city`'s lower left viewport corner as the `(0, 0)` origin. + lower_left = city.viewport['southwest'] + obj = utils.UTMCoordinate(lower_left['latitude'], lower_left['longitude']) + + assert obj.zone == ZONE # sanity check + + return obj + + +class TestSpecialMethods: + """Test special methods in `UTMCoordinate`.""" + + def test_create_utm_coordinates(self, location): + """Test instantiation of a new `UTMCoordinate` object.""" + assert location is not None + + def test_create_utm_coordinates_with_origin(self, address, origin): + """Test instantiation with a `relate_to` argument.""" + result = utils.UTMCoordinate( + latitude=address.latitude, longitude=address.longitude, relative_to=origin, + ) + + assert result is not None + + def test_create_utm_coordinates_with_non_utm_origin(self): + """Test instantiation with a `relate_to` argument of the wrong type.""" + with pytest.raises(TypeError, match='UTMCoordinate'): + utils.UTMCoordinate( + latitude=0, longitude=0, relative_to=object(), + ) + + def test_create_utm_coordinates_with_invalid_origin( + self, address, faraway_location, + ): + """Test instantiation with a `relate_to` argument at an invalid location.""" + with pytest.raises(ValueError, match='must be in the same UTM zone'): + utils.UTMCoordinate( + latitude=address.latitude, + longitude=address.longitude, + relative_to=faraway_location, + ) + + def test_text_representation(self, location): + """The text representation is a non-literal.""" + result = repr(location) + + assert result.startswith('') + + @pytest.mark.e2e + def test_coordinates_in_the_text_representation(self, location): + """Test the UTM convention in the non-literal text `repr()`. + + Example Format: + `'` + """ + result = repr(location) + + parts = result.split(' ') + zone = parts[1] + easting = int(parts[2]) + northing = int(parts[3][:-1]) # strip the ending ">" + + assert zone == location.zone + assert MIN_EASTING < easting < MAX_EASTING + assert MIN_NORTHING < northing < MAX_NORTHING + + def test_compare_utm_coordinates_to_different_data_type(self, location): + """Test `UTMCoordinate.__eq__()`.""" + result = location == object() + + assert result is False + + def test_compare_utm_coordinates_to_far_away_coordinates( + self, location, faraway_location, + ): + """Test `UTMCoordinate.__eq__()`.""" + with pytest.raises(ValueError, match='must be in the same zone'): + bool(location == faraway_location) + + def test_compare_utm_coordinates_to_equal_coordinates(self, location, address): + """Test `UTMCoordinate.__eq__()`.""" + same_location = utils.UTMCoordinate(address.latitude, address.longitude) + + result = location == same_location + + assert result is True + + def test_compare_utm_coordinates_to_themselves(self, location): + """Test `UTMCoordinate.__eq__()`.""" + # pylint:disable=comparison-with-itself + result = location == location # noqa:WPS312 + + assert result is True + + def test_compare_utm_coordinates_to_different_coordinates(self, location, origin): + """Test `UTMCoordinate.__eq__()`.""" + result = location == origin + + assert result is False + + +class TestProperties: + """Test properties in `UTMCoordinate`.""" + + def test_easting(self, location): + """Test `UTMCoordinate.easting` property.""" + result = location.easting + + assert MIN_EASTING < result < MAX_EASTING + + def test_northing(self, location): + """Test `UTMCoordinate.northing` property.""" + result = location.northing + + assert MIN_NORTHING < result < MAX_NORTHING + + def test_zone(self, location): + """Test `UTMCoordinate.zone` property.""" + result = location.zone + + assert result == ZONE + + +class TestRelateTo: + """Test the `UTMCoordinate.relate_to()` method and the `.x` and `.y` properties.""" + + def test_run_relate_to_twice(self, location, origin): + """The `.relate_to()` method must only be run once.""" + location.relate_to(origin) + + with pytest.raises(RuntimeError, match='once'): + location.relate_to(origin) + + def test_call_relate_to_with_wrong_other_type(self, location): + """`other` must be another `UTMCoordinate`.""" + with pytest.raises(TypeError, match='UTMCoordinate'): + location.relate_to(object()) + + def test_call_relate_to_with_far_away_other( + self, location, faraway_location, + ): + """The `other` origin must be in the same UTM zone.""" + with pytest.raises(ValueError, match='must be in the same zone'): + location.relate_to(faraway_location) + + def test_access_x_without_origin(self, location): + """`.relate_to()` must be called before `.x` can be accessed.""" + with pytest.raises(RuntimeError, match='origin to relate to must be set'): + int(location.x) + + def test_access_y_without_origin(self, location): + """`.relate_to()` must be called before `.y` can be accessed.""" + with pytest.raises(RuntimeError, match='origin to relate to must be set'): + int(location.y) + + def test_origin_must_be_lower_left_when_relating_to_oneself(self, location): + """`.x` and `.y` must be `== (0, 0)` when oneself is the origin.""" + location.relate_to(location) + + assert (location.x, location.y) == (0, 0) + + @pytest.mark.e2e + def test_x_and_y_must_not_be_lower_left_for_address_in_city(self, location, origin): + """`.x` and `.y` must be `> (0, 0)` when oneself is the origin.""" + location.relate_to(origin) + + assert location.x > 0 + assert location.y > 0