diff --git a/setup.cfg b/setup.cfg index 47924d4..f00e6f7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -144,6 +144,9 @@ per-file-ignores = src/urban_meal_delivery/console/forecasts.py: # The module is not too complex. WPS232, + src/urban_meal_delivery/db/addresses_addresses.py: + # The module does not have too many imports. + WPS201, src/urban_meal_delivery/db/customers.py: # The module is not too complex. WPS232, @@ -217,6 +220,7 @@ allowed-domain-names = obj, param, result, + results, value, max-name-length = 40 # darglint @@ -267,12 +271,18 @@ cache_dir = .cache/mypy [mypy-folium.*] ignore_missing_imports = true +[mypy-geopy.*] +ignore_missing_imports = true +[mypy-googlemaps.*] +ignore_missing_imports = true [mypy-matplotlib.*] ignore_missing_imports = true [mypy-nox.*] ignore_missing_imports = true [mypy-numpy.*] ignore_missing_imports = true +[mypy-ordered_set.*] +ignore_missing_imports = true [mypy-packaging] ignore_missing_imports = true [mypy-pandas] diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py index e303523..5d1a5c3 100644 --- a/src/urban_meal_delivery/configuration.py +++ b/src/urban_meal_delivery/configuration.py @@ -64,6 +64,7 @@ class Config: # -------------------------------- DATABASE_URI = os.getenv('DATABASE_URI') + GOOGLE_MAPS_API_KEY = os.getenv('GOOGLE_MAPS_API_KEY') # The PostgreSQL schema that holds the tables with the original data. ORIGINAL_SCHEMA = os.getenv('ORIGINAL_SCHEMA') or 'public' diff --git a/src/urban_meal_delivery/db/addresses_addresses.py b/src/urban_meal_delivery/db/addresses_addresses.py index de175b4..7a997ef 100644 --- a/src/urban_meal_delivery/db/addresses_addresses.py +++ b/src/urban_meal_delivery/db/addresses_addresses.py @@ -1,12 +1,20 @@ """Model for the relationship between two `Address` objects (= distance matrix).""" +from __future__ import annotations + +import itertools import json from typing import List +import googlemaps as gm +import ordered_set import sqlalchemy as sa +from geopy import distance as geo_distance from sqlalchemy import orm from sqlalchemy.dialects import postgresql +from urban_meal_delivery import config +from urban_meal_delivery import db from urban_meal_delivery.db import meta from urban_meal_delivery.db import utils @@ -92,6 +100,118 @@ class DistanceMatrix(meta.Base): # We do not implement a `.__init__()` method and leave that to SQLAlchemy. # Instead, we use `hasattr()` to check for uninitialized attributes. grep:86ffc14e + @classmethod + def from_addresses( + cls, *addresses: db.Address, google_maps: bool = False, + ) -> List[DistanceMatrix]: + """Calculate pair-wise distances for `Address` objects. + + This is the main constructor method for the class. + + It handles the "sorting" of the `Address` objects by `.id`, which is + the logic that enforces the symmetric graph behind the distances. + + Args: + *addresses: to calculate the pair-wise distances for; + must contain at least two `Address` objects + google_maps: if `.bicylce_distance` and `.directions` should be + populated with a query to the Google Maps Directions API; + by default, only the `.air_distance` is calculated with `geopy` + + Returns: + distances + """ + distances = [] + + # We consider all 2-tuples of `Address`es. The symmetric graph is ... + for first, second in itertools.combinations(addresses, 2): + # ... implicitly enforced by a precedence constraint for the `.id`s. + first, second = ( # noqa:WPS211 + (first, second) if first.id < second.id else (second, first) + ) + + # If there is no `DistaneMatrix` object in the database ... + distance = ( # noqa:ECE001 + db.session.query(db.DistanceMatrix) + .filter(db.DistanceMatrix.first_address == first) + .filter(db.DistanceMatrix.second_address == second) + .first() + ) + # ... create a new one. + if distance is None: + air_distance = geo_distance.great_circle( + first.location.lat_lng, second.location.lat_lng, + ) + + distance = cls( + first_address=first, + second_address=second, + air_distance=round(air_distance.meters), + ) + + db.session.add(distance) + db.session.commit() + + distances.append(distance) + + if google_maps: + for distance in distances: # noqa:WPS440 + distance.sync_with_google_maps() + + return distances + + def sync_with_google_maps(self) -> None: + """Fill in `.bicycle_distance` and `.directions` with Google Maps. + + `.directions` will not contain the coordinates of `.first_address` and + `.second_address`. + + This uses the Google Maps Directions API. + + Further info: + https://developers.google.com/maps/documentation/directions + """ + # To save costs, we do not make an API call + # if we already have data from Google Maps. + if self.bicycle_distance is not None: + return + + client = gm.Client(config.GOOGLE_MAPS_API_KEY) + response = client.directions( + origin=self.first_address.location.lat_lng, + destination=self.second_address.location.lat_lng, + mode='bicycling', + alternatives=False, + ) + # Without "alternatives" and "waypoints", the `response` contains + # exactly one "route" that consists of exactly one "leg". + # Source: https://developers.google.com/maps/documentation/directions/get-directions#Legs # noqa:E501 + route = response[0]['legs'][0] + + self.bicycle_distance = route['distance']['value'] # noqa:WPS601 + self.bicycle_duration = route['duration']['value'] # noqa:WPS601 + + # Each route consists of many "steps" that are instructions as to how to + # get from A to B. As a step's "start_location" may equal the previous step's + # "end_location", we use an `OrderedSet` to find the unique latitude-longitude + # pairs that make up the path from `.first_address` to `.second_address`. + steps = ordered_set.OrderedSet() + for step in route['steps']: + steps.add( # noqa:WPS221 + (step['start_location']['lat'], step['start_location']['lng']), + ) + steps.add( # noqa:WPS221 + (step['end_location']['lat'], step['end_location']['lng']), + ) + + steps.discard(self.first_address.location.lat_lng) + steps.discard(self.second_address.location.lat_lng) + + self.directions = json.dumps(list(steps)) # noqa:WPS601 + + db.session.add(self) + db.session.commit() + @property def path(self) -> List[utils.Location]: """The couriers' path from `.first_address` to `.second_address`. diff --git a/src/urban_meal_delivery/db/utils/locations.py b/src/urban_meal_delivery/db/utils/locations.py index b6ef41e..4c92889 100644 --- a/src/urban_meal_delivery/db/utils/locations.py +++ b/src/urban_meal_delivery/db/utils/locations.py @@ -7,7 +7,7 @@ from typing import Optional, Tuple import utm -class Location: +class Location: # noqa:WPS214 """A location represented in WGS84 and UTM coordinates. WGS84: @@ -67,6 +67,11 @@ class Location: """ return self._longitude + @property + def lat_lng(self) -> Tuple[float, float]: + """The `.latitude` and `.longitude` as a 2-`tuple`.""" + return (self._latitude, self._longitude) + @property def easting(self) -> int: """The easting of the location in meters (UTM).""" diff --git a/tests/db/test_addresses_addresses.py b/tests/db/test_addresses_addresses.py index d6f43d4..0435622 100644 --- a/tests/db/test_addresses_addresses.py +++ b/tests/db/test_addresses_addresses.py @@ -2,6 +2,7 @@ import json +import googlemaps import pytest import sqlalchemy as sqla from geopy import distance @@ -21,8 +22,7 @@ def another_address(make_address): def assoc(address, another_address, make_address): """An association between `address` and `another_address`.""" air_distance = distance.great_circle( # noqa:WPS317 - (address.latitude, address.longitude), - (another_address.latitude, another_address.longitude), + address.location.lat_lng, another_address.location.lat_lng, ).meters # We put 5 latitude-longitude pairs as the "path" from @@ -195,6 +195,417 @@ class TestConstraints: db_session.commit() +@pytest.mark.db +class TestFromAddresses: + """Test the alternative constructor `DistanceMatrix.from_addresses()`.""" + + @pytest.fixture + def _prepare_db(self, db_session, address): + """Put the `address` into the database. + + `Address`es must be in the database as otherwise the `.city_id` column + cannot be resolved in `DistanceMatrix.from_addresses()`. + """ + db_session.add(address) + + @pytest.mark.usefixtures('_prepare_db') + def test_make_distance_matrix_instance( + self, db_session, address, another_address, + ): + """Test instantiation of a new `DistanceMatrix` instance.""" + assert db_session.query(db.DistanceMatrix).count() == 0 + + db.DistanceMatrix.from_addresses(address, another_address) + + assert db_session.query(db.DistanceMatrix).count() == 1 + + @pytest.mark.usefixtures('_prepare_db') + def test_make_the_same_distance_matrix_instance_twice( + self, db_session, address, another_address, + ): + """Test instantiation of a new `DistanceMatrix` instance.""" + assert db_session.query(db.DistanceMatrix).count() == 0 + + db.DistanceMatrix.from_addresses(address, another_address) + + assert db_session.query(db.DistanceMatrix).count() == 1 + + db.DistanceMatrix.from_addresses(another_address, address) + + assert db_session.query(db.DistanceMatrix).count() == 1 + + @pytest.mark.usefixtures('_prepare_db') + def test_structure_of_return_value(self, db_session, address, another_address): + """Test instantiation of a new `DistanceMatrix` instance.""" + results = db.DistanceMatrix.from_addresses(address, another_address) + + assert isinstance(results, list) + + @pytest.mark.usefixtures('_prepare_db') + def test_instances_must_have_air_distance( + self, db_session, address, another_address, + ): + """Test instantiation of a new `DistanceMatrix` instance.""" + distances = db.DistanceMatrix.from_addresses(address, another_address) + + result = distances[0] + + assert result.air_distance is not None + + @pytest.mark.usefixtures('_prepare_db') + def test_do_not_sync_instances_with_google_maps( + self, db_session, address, another_address, + ): + """Test instantiation of a new `DistanceMatrix` instance.""" + distances = db.DistanceMatrix.from_addresses(address, another_address) + + result = distances[0] + + assert result.bicycle_distance is None + assert result.bicycle_duration is None + + @pytest.mark.usefixtures('_prepare_db') + def test_sync_instances_with_google_maps( + self, db_session, address, another_address, monkeypatch, + ): + """Test instantiation of a new `DistanceMatrix` instance.""" + + def sync(self): + self.bicycle_distance = 1.25 * self.air_distance + self.bicycle_duration = 300 + + monkeypatch.setattr(db.DistanceMatrix, 'sync_with_google_maps', sync) + + distances = db.DistanceMatrix.from_addresses( + address, another_address, google_maps=True, + ) + + result = distances[0] + + assert result.bicycle_distance is not None + assert result.bicycle_duration is not None + + @pytest.mark.usefixtures('_prepare_db') + def test_one_distance_for_two_addresses(self, db_session, address, another_address): + """Test instantiation of a new `DistanceMatrix` instance.""" + result = len(db.DistanceMatrix.from_addresses(address, another_address)) + + assert result == 1 + + @pytest.mark.usefixtures('_prepare_db') + def test_two_distances_for_three_addresses(self, db_session, make_address): + """Test instantiation of a new `DistanceMatrix` instance.""" + result = len( + db.DistanceMatrix.from_addresses(*[make_address() for _ in range(3)]), + ) + + assert result == 3 + + @pytest.mark.usefixtures('_prepare_db') + def test_six_distances_for_four_addresses(self, db_session, make_address): + """Test instantiation of a new `DistanceMatrix` instance.""" + result = len( + db.DistanceMatrix.from_addresses(*[make_address() for _ in range(4)]), + ) + + assert result == 6 + + +@pytest.mark.db +class TestSyncWithGoogleMaps: + """Test the `DistanceMatrix.sync_with_google_maps()` method.""" + + @pytest.fixture + def api_response(self): + """A typical (shortened) response by the Google Maps Directions API.""" + return [ # noqa:ECE001 + { + 'bounds': { + 'northeast': {'lat': 44.8554284, 'lng': -0.5652398}, + 'southwest': {'lat': 44.8342256, 'lng': -0.5708206}, + }, + 'copyrights': 'Map data ©2021', + 'legs': [ + { + 'distance': {'text': '3.0 km', 'value': 2969}, + 'duration': {'text': '10 mins', 'value': 596}, + 'end_address': '13 Place Paul et Jean Paul Avisseau, ...', + 'end_location': {'lat': 44.85540839999999, 'lng': -0.5672105}, + 'start_address': '59 Rue Saint-François, 33000 Bordeaux, ...', + 'start_location': {'lat': 44.8342256, 'lng': -0.570372}, + 'steps': [ + { + 'distance': {'text': '0.1 km', 'value': 108}, + 'duration': {'text': '1 min', 'value': 43}, + 'end_location': { + 'lat': 44.83434380000001, + 'lng': -0.5690105999999999, + }, + 'html_instructions': 'Head east on ...', + 'polyline': {'points': '}tspGxknBKcDIkB'}, + 'start_location': {'lat': 44.8342256, 'lng': -0.57032}, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.1 km', 'value': 115}, + 'duration': {'text': '1 min', 'value': 22}, + 'end_location': {'lat': 44.8353651, 'lng': -0.569199}, + 'html_instructions': 'Turn left onto ...', + 'maneuver': 'turn-left', + 'polyline': {'points': 'suspGhcnBc@JE@_@DiAHA?w@F'}, + 'start_location': { + 'lat': 44.83434380000001, + 'lng': -0.5690105999999999, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.3 km', 'value': 268}, + 'duration': {'text': '1 min', 'value': 59}, + 'end_location': {'lat': 44.8362675, 'lng': -0.5660914}, + 'html_instructions': 'Turn right onto ...', + 'maneuver': 'turn-right', + 'polyline': { + 'points': 'a|spGndnBEYEQKi@Mi@Is@CYCOE]CQIq@ ...', + }, + 'start_location': {'lat': 44.8353651, 'lng': -0.56919}, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.1 km', 'value': 95}, + 'duration': {'text': '1 min', 'value': 29}, + 'end_location': {'lat': 44.8368458, 'lng': -0.5652398}, + 'html_instructions': 'Slight left onto ...', + 'maneuver': 'turn-slight-left', + 'polyline': { + 'points': 'uatpG`qmBg@aAGM?ACE[k@CICGACEGCCAAEAG?', + }, + 'start_location': { + 'lat': 44.8362675, + 'lng': -0.5660914, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '23 m', 'value': 23}, + 'duration': {'text': '1 min', 'value': 4}, + 'end_location': {'lat': 44.83697, 'lng': -0.5654425}, + 'html_instructions': 'Slight left to stay ...', + 'maneuver': 'turn-slight-left', + 'polyline': { + 'points': 'ietpGvkmBA@C?CBCBEHA@AB?B?B?B?@', + }, + 'start_location': { + 'lat': 44.8368458, + 'lng': -0.5652398, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.2 km', 'value': 185}, + 'duration': {'text': '1 min', 'value': 23}, + 'end_location': {'lat': 44.8382126, 'lng': -0.5669969}, + 'html_instructions': 'Take the ramp to Le Lac ...', + 'polyline': { + 'points': 'aftpG~lmBY^[^sAdB]`@CDKLQRa@h@A@IZ', + }, + 'start_location': {'lat': 44.83697, 'lng': -0.5654425}, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.3 km', 'value': 253}, + 'duration': {'text': '1 min', 'value': 43}, + 'end_location': {'lat': 44.840163, 'lng': -0.5686525}, + 'html_instructions': 'Merge onto Quai Richelieu', + 'maneuver': 'merge', + 'polyline': { + 'points': 'ymtpGvvmBeAbAe@b@_@ZUN[To@f@e@^A?g ...', + }, + 'start_location': { + 'lat': 44.8382126, + 'lng': -0.5669969, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.1 km', 'value': 110}, + 'duration': {'text': '1 min', 'value': 21}, + 'end_location': {'lat': 44.841079, 'lng': -0.5691835}, + 'html_instructions': 'Continue onto Quai de la ...', + 'polyline': {'points': '_ztpG`anBUNQLULUJOHMFKDWN'}, + 'start_location': {'lat': 44.840163, 'lng': -0.56865}, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.3 km', 'value': 262}, + 'duration': {'text': '1 min', 'value': 44}, + 'end_location': {'lat': 44.8433375, 'lng': -0.5701161}, + 'html_instructions': 'Continue onto Quai du ...', + 'polyline': { + 'points': 'w_upGjdnBeBl@sBn@gA^[JIBc@Nk@Nk@L', + }, + 'start_location': {'lat': 44.841079, 'lng': -0.56915}, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.6 km', 'value': 550}, + 'duration': {'text': '2 mins', 'value': 97}, + 'end_location': { + 'lat': 44.84822339999999, + 'lng': -0.5705307, + }, + 'html_instructions': 'Continue onto Quai ...', + 'polyline': { + 'points': '{mupGfjnBYFI@IBaAPUD{AX}@NK@]Fe@H ...', + }, + 'start_location': { + 'lat': 44.8433375, + 'lng': -0.5701161, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.5 km', 'value': 508}, + 'duration': {'text': '1 min', 'value': 87}, + 'end_location': {'lat': 44.8523224, 'lng': -0.5678223}, + 'html_instructions': 'Continue onto ...', + 'polyline': { + 'points': 'klvpGxlnBWEUGWGSGMEOEOE[KMEQGIA] ...', + }, + 'start_location': { + 'lat': 44.84822339999999, + 'lng': -0.5705307, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '28 m', 'value': 28}, + 'duration': {'text': '1 min', 'value': 45}, + 'end_location': { + 'lat': 44.85245620000001, + 'lng': -0.5681259, + }, + 'html_instructions': 'Turn left onto ...', + 'maneuver': 'turn-left', + 'polyline': {'points': '_fwpGz{mBGLADGPCFEN'}, + 'start_location': { + 'lat': 44.8523224, + 'lng': -0.5678223, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.2 km', 'value': 176}, + 'duration': {'text': '1 min', 'value': 31}, + 'end_location': {'lat': 44.8536857, 'lng': -0.5667282}, + 'html_instructions': 'Turn right onto ...', + 'maneuver': 'turn-right', + 'polyline': { + 'points': '{fwpGx}mB_@c@mAuAOQi@m@m@y@_@c@', + }, + 'start_location': { + 'lat': 44.85245620000001, + 'lng': -0.5681259, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.2 km', 'value': 172}, + 'duration': {'text': '1 min', 'value': 28}, + 'end_location': {'lat': 44.8547766, 'lng': -0.5682825}, + 'html_instructions': 'Turn left onto ... ', + 'maneuver': 'turn-left', + 'polyline': {'points': 'qnwpG`umBW`@UkDtF'}, + 'start_location': { + 'lat': 44.8536857, + 'lng': -0.5667282, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '0.1 km', 'value': 101}, + 'duration': {'text': '1 min', 'value': 17}, + 'end_location': {'lat': 44.8554284, 'lng': -0.5673822}, + 'html_instructions': 'Turn right onto ...', + 'maneuver': 'turn-right', + 'polyline': {'points': 'kuwpGv~mBa@q@cA_B[a@'}, + 'start_location': { + 'lat': 44.8547766, + 'lng': -0.5682825, + }, + 'travel_mode': 'BICYCLING', + }, + { + 'distance': {'text': '15 m', 'value': 15}, + 'duration': {'text': '1 min', 'value': 3}, + 'end_location': { + 'lat': 44.85540839999999, + 'lng': -0.5672105, + }, + 'html_instructions': 'Turn right onto ...', + 'maneuver': 'turn-right', + 'polyline': {'points': 'mywpGbymBBC@C?E?C?E?EAC'}, + 'start_location': { + 'lat': 44.8554284, + 'lng': -0.5673822, + }, + 'travel_mode': 'BICYCLING', + }, + ], + 'traffic_speed_entry': [], + 'via_waypoint': [], + }, + ], + 'overview_polyline': { + 'points': '}tspGxknBUoGi@LcDVe@_CW{Ba@sC[eA_@} ...', + }, + 'summary': 'Quai des Chartrons', + 'warnings': ['Bicycling directions are in beta ...'], + 'waypoint_order': [], + }, + ] + + @pytest.fixture + def _fake_google_api(self, api_response, monkeypatch): + """Patch out the call to the Google Maps Directions API.""" + + def directions(self, *args, **kwargs): + return api_response + + monkeypatch.setattr(googlemaps.Client, 'directions', directions) + + @pytest.mark.usefixtures('_fake_google_api') + def test_sync_instances_with_google_maps(self, db_session, assoc): + """Call the method for a `DistanceMatrix` object without Google data.""" + assoc.bicycle_distance = None + assoc.bicycle_duration = None + assoc.directions = None + + assoc.sync_with_google_maps() + + assert assoc.bicycle_distance == 2_969 + assert assoc.bicycle_duration == 596 + assert assoc.directions is not None + + @pytest.mark.usefixtures('_fake_google_api') + def test_repeated_sync_instances_with_google_maps(self, db_session, assoc): + """Call the method for a `DistanceMatrix` object with Google data. + + That call should immediately return without changing any data. + + We use the `assoc`'s "Google" data from above. + """ + old_distance = assoc.bicycle_distance + old_duration = assoc.bicycle_duration + old_directions = assoc.directions + + assoc.sync_with_google_maps() + + assert assoc.bicycle_distance is old_distance + assert assoc.bicycle_duration is old_duration + assert assoc.directions is old_directions + + class TestProperties: """Test properties in `DistanceMatrix`.""" diff --git a/tests/db/utils/test_locations.py b/tests/db/utils/test_locations.py index 8eb0263..0a3d3d2 100644 --- a/tests/db/utils/test_locations.py +++ b/tests/db/utils/test_locations.py @@ -122,6 +122,15 @@ class TestProperties: assert result == pytest.approx(float(address.longitude)) + def test_lat_lng(self, location, address): + """Test `Location.lat_lng` property.""" + result = location.lat_lng + + assert result == ( + pytest.approx(float(address.latitude)), + pytest.approx(float(address.longitude)), + ) + def test_easting(self, location): """Test `Location.easting` property.""" result = location.easting