Add constructor for the DistanceMatrix class

- `DistanceMatrix.from_addresses()` takes a variable number of
  `Address` objects and creates distance matrix entries for them
- as a base measure, the air distance between two `Address`
  objects is calculated
- in addition, an integration with the Google Maps Directions API is
  implemented that provides a more realistic measure of the distance
  and duration a rider on a bicycle would need to travel between two
  `Address` objects
- add a `Location.lat_lng` convenience property that provides the
  `.latitude` and `.longitude` of an `Address` as a 2-`tuple`
This commit is contained in:
Alexander Hess 2021-03-02 18:03:43 +01:00
parent 5e9307523c
commit db715edd6d
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
6 changed files with 559 additions and 3 deletions

View file

@ -144,6 +144,9 @@ per-file-ignores =
src/urban_meal_delivery/console/forecasts.py: src/urban_meal_delivery/console/forecasts.py:
# The module is not too complex. # The module is not too complex.
WPS232, 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: src/urban_meal_delivery/db/customers.py:
# The module is not too complex. # The module is not too complex.
WPS232, WPS232,
@ -217,6 +220,7 @@ allowed-domain-names =
obj, obj,
param, param,
result, result,
results,
value, value,
max-name-length = 40 max-name-length = 40
# darglint # darglint
@ -267,12 +271,18 @@ cache_dir = .cache/mypy
[mypy-folium.*] [mypy-folium.*]
ignore_missing_imports = true ignore_missing_imports = true
[mypy-geopy.*]
ignore_missing_imports = true
[mypy-googlemaps.*]
ignore_missing_imports = true
[mypy-matplotlib.*] [mypy-matplotlib.*]
ignore_missing_imports = true ignore_missing_imports = true
[mypy-nox.*] [mypy-nox.*]
ignore_missing_imports = true ignore_missing_imports = true
[mypy-numpy.*] [mypy-numpy.*]
ignore_missing_imports = true ignore_missing_imports = true
[mypy-ordered_set.*]
ignore_missing_imports = true
[mypy-packaging] [mypy-packaging]
ignore_missing_imports = true ignore_missing_imports = true
[mypy-pandas] [mypy-pandas]

View file

@ -64,6 +64,7 @@ class Config:
# -------------------------------- # --------------------------------
DATABASE_URI = os.getenv('DATABASE_URI') 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. # The PostgreSQL schema that holds the tables with the original data.
ORIGINAL_SCHEMA = os.getenv('ORIGINAL_SCHEMA') or 'public' ORIGINAL_SCHEMA = os.getenv('ORIGINAL_SCHEMA') or 'public'

View file

@ -1,12 +1,20 @@
"""Model for the relationship between two `Address` objects (= distance matrix).""" """Model for the relationship between two `Address` objects (= distance matrix)."""
from __future__ import annotations
import itertools
import json import json
from typing import List from typing import List
import googlemaps as gm
import ordered_set
import sqlalchemy as sa import sqlalchemy as sa
from geopy import distance as geo_distance
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.dialects import postgresql 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 meta
from urban_meal_delivery.db import utils 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. # We do not implement a `.__init__()` method and leave that to SQLAlchemy.
# Instead, we use `hasattr()` to check for uninitialized attributes. grep:86ffc14e # 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 @property
def path(self) -> List[utils.Location]: def path(self) -> List[utils.Location]:
"""The couriers' path from `.first_address` to `.second_address`. """The couriers' path from `.first_address` to `.second_address`.

View file

@ -7,7 +7,7 @@ from typing import Optional, Tuple
import utm import utm
class Location: class Location: # noqa:WPS214
"""A location represented in WGS84 and UTM coordinates. """A location represented in WGS84 and UTM coordinates.
WGS84: WGS84:
@ -67,6 +67,11 @@ class Location:
""" """
return self._longitude 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 @property
def easting(self) -> int: def easting(self) -> int:
"""The easting of the location in meters (UTM).""" """The easting of the location in meters (UTM)."""

View file

@ -2,6 +2,7 @@
import json import json
import googlemaps
import pytest import pytest
import sqlalchemy as sqla import sqlalchemy as sqla
from geopy import distance from geopy import distance
@ -21,8 +22,7 @@ def another_address(make_address):
def assoc(address, another_address, make_address): def assoc(address, another_address, make_address):
"""An association between `address` and `another_address`.""" """An association between `address` and `another_address`."""
air_distance = distance.great_circle( # noqa:WPS317 air_distance = distance.great_circle( # noqa:WPS317
(address.latitude, address.longitude), address.location.lat_lng, another_address.location.lat_lng,
(another_address.latitude, another_address.longitude),
).meters ).meters
# We put 5 latitude-longitude pairs as the "path" from # We put 5 latitude-longitude pairs as the "path" from
@ -195,6 +195,417 @@ class TestConstraints:
db_session.commit() 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 <b>east</b> on <b> ...',
'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 <b>left</b> onto <b> ...',
'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 <b>right</b> onto <b> ...',
'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 <b>left</b> onto <b> ...',
'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 <b>left</b> 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 <b>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 <b>Quai Richelieu</b>',
'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 <b>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 <b>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 <b>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 <b>left</b> onto <b> ...',
'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 <b>right</b> onto <b> ...',
'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 <b>left</b> onto <b> ... ',
'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 <b>right</b> 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 <b>right</b> onto <b> ...',
'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: class TestProperties:
"""Test properties in `DistanceMatrix`.""" """Test properties in `DistanceMatrix`."""

View file

@ -122,6 +122,15 @@ class TestProperties:
assert result == pytest.approx(float(address.longitude)) 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): def test_easting(self, location):
"""Test `Location.easting` property.""" """Test `Location.easting` property."""
result = location.easting result = location.easting