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:
parent
5e9307523c
commit
db715edd6d
6 changed files with 559 additions and 3 deletions
10
setup.cfg
10
setup.cfg
|
@ -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]
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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`.
|
||||||
|
|
|
@ -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)."""
|
||||||
|
|
|
@ -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`."""
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue