Add functionality for drawing folium.Map
s
- this code is not unit-tested due to the complexity involving interactive `folium.Map`s => visual checks give high confidence
This commit is contained in:
parent
605ade4078
commit
4b6d92958d
12 changed files with 714 additions and 13 deletions
24
setup.cfg
24
setup.cfg
|
@ -92,7 +92,7 @@ extend-ignore =
|
||||||
# Google's Python Style Guide is not reStructuredText
|
# Google's Python Style Guide is not reStructuredText
|
||||||
# until after being processed by Sphinx Napoleon.
|
# until after being processed by Sphinx Napoleon.
|
||||||
# Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17
|
# Source: https://github.com/peterjc/flake8-rst-docstrings/issues/17
|
||||||
RST201,RST203,RST301,
|
RST201,RST203,RST210,RST213,RST301,
|
||||||
# String constant over-use is checked visually by the programmer.
|
# String constant over-use is checked visually by the programmer.
|
||||||
WPS226,
|
WPS226,
|
||||||
# Allow underscores in numbers.
|
# Allow underscores in numbers.
|
||||||
|
@ -101,6 +101,10 @@ extend-ignore =
|
||||||
WPS305,
|
WPS305,
|
||||||
# Classes should not have to specify a base class.
|
# Classes should not have to specify a base class.
|
||||||
WPS306,
|
WPS306,
|
||||||
|
# Let's be modern: The Walrus is ok.
|
||||||
|
WPS332,
|
||||||
|
# Let's not worry about the number of noqa's.
|
||||||
|
WPS402,
|
||||||
# Putting logic into __init__.py files may be justified.
|
# Putting logic into __init__.py files may be justified.
|
||||||
WPS412,
|
WPS412,
|
||||||
# Allow multiple assignment, e.g., x = y = 123
|
# Allow multiple assignment, e.g., x = y = 123
|
||||||
|
@ -127,8 +131,6 @@ per-file-ignores =
|
||||||
WPS114,WPS118,
|
WPS114,WPS118,
|
||||||
# Revisions may have too many expressions.
|
# Revisions may have too many expressions.
|
||||||
WPS204,WPS213,
|
WPS204,WPS213,
|
||||||
# Too many noqa's are ok.
|
|
||||||
WPS402,
|
|
||||||
noxfile.py:
|
noxfile.py:
|
||||||
# Type annotations are not strictly enforced.
|
# Type annotations are not strictly enforced.
|
||||||
ANN0, ANN2,
|
ANN0, ANN2,
|
||||||
|
@ -136,13 +138,17 @@ per-file-ignores =
|
||||||
WPS202,
|
WPS202,
|
||||||
# TODO (isort): Remove after simplifying the nox session "lint".
|
# TODO (isort): Remove after simplifying the nox session "lint".
|
||||||
WPS213,
|
WPS213,
|
||||||
# The noxfile is rather long => allow many noqa's.
|
|
||||||
WPS402,
|
|
||||||
src/urban_meal_delivery/configuration.py:
|
src/urban_meal_delivery/configuration.py:
|
||||||
# Allow upper case class variables within classes.
|
# Allow upper case class variables within classes.
|
||||||
WPS115,
|
WPS115,
|
||||||
|
src/urban_meal_delivery/db/customers.py:
|
||||||
|
# The module is not too complex.
|
||||||
|
WPS232,
|
||||||
|
src/urban_meal_delivery/db/restaurants.py:
|
||||||
|
# The module is not too complex.
|
||||||
|
WPS232,
|
||||||
src/urban_meal_delivery/forecasts/decomposition.py:
|
src/urban_meal_delivery/forecasts/decomposition.py:
|
||||||
# The module does not have a high cognitive complexity.
|
# The module is not too complex.
|
||||||
WPS232,
|
WPS232,
|
||||||
src/urban_meal_delivery/forecasts/timify.py:
|
src/urban_meal_delivery/forecasts/timify.py:
|
||||||
# No SQL injection as the inputs come from a safe source.
|
# No SQL injection as the inputs come from a safe source.
|
||||||
|
@ -247,8 +253,14 @@ single_line_exclusions = typing
|
||||||
[mypy]
|
[mypy]
|
||||||
cache_dir = .cache/mypy
|
cache_dir = .cache/mypy
|
||||||
|
|
||||||
|
[mypy-folium.*]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
[mypy-matplotlib.*]
|
||||||
|
ignore_missing_imports = true
|
||||||
[mypy-nox.*]
|
[mypy-nox.*]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
|
[mypy-numpy.*]
|
||||||
|
ignore_missing_imports = true
|
||||||
[mypy-packaging]
|
[mypy-packaging]
|
||||||
ignore_missing_imports = true
|
ignore_missing_imports = true
|
||||||
[mypy-pandas]
|
[mypy-pandas]
|
||||||
|
|
|
@ -55,6 +55,10 @@ class Config:
|
||||||
# The demand forecasting methods used in the simulations.
|
# The demand forecasting methods used in the simulations.
|
||||||
FORECASTING_METHODS = ['hets', 'rtarima']
|
FORECASTING_METHODS = ['hets', 'rtarima']
|
||||||
|
|
||||||
|
# Colors for the visualizations ins `folium`.
|
||||||
|
RESTAURANT_COLOR = 'red'
|
||||||
|
CUSTOMER_COLOR = 'blue'
|
||||||
|
|
||||||
# Implementation-specific settings
|
# Implementation-specific settings
|
||||||
# --------------------------------
|
# --------------------------------
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
"""Provide the ORM's `Address` model."""
|
"""Provide the ORM's `Address` model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
from sqlalchemy.dialects import postgresql
|
from sqlalchemy.dialects import postgresql
|
||||||
|
@ -16,7 +21,7 @@ class Address(meta.Base):
|
||||||
|
|
||||||
# Columns
|
# Columns
|
||||||
id = sa.Column(sa.Integer, primary_key=True, autoincrement=False) # noqa:WPS125
|
id = sa.Column(sa.Integer, primary_key=True, autoincrement=False) # noqa:WPS125
|
||||||
_primary_id = sa.Column('primary_id', sa.Integer, nullable=False, index=True)
|
primary_id = sa.Column(sa.Integer, nullable=False, index=True)
|
||||||
created_at = sa.Column(sa.DateTime, nullable=False)
|
created_at = sa.Column(sa.DateTime, nullable=False)
|
||||||
place_id = sa.Column(sa.Unicode(length=120), nullable=False, index=True)
|
place_id = sa.Column(sa.Unicode(length=120), nullable=False, index=True)
|
||||||
latitude = sa.Column(postgresql.DOUBLE_PRECISION, nullable=False)
|
latitude = sa.Column(postgresql.DOUBLE_PRECISION, nullable=False)
|
||||||
|
@ -83,7 +88,7 @@ class Address(meta.Base):
|
||||||
|
|
||||||
`.is_primary` indicates the first in a group of `Address` objects.
|
`.is_primary` indicates the first in a group of `Address` objects.
|
||||||
"""
|
"""
|
||||||
return self.id == self._primary_id
|
return self.id == self.primary_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def location(self) -> utils.Location:
|
def location(self) -> utils.Location:
|
||||||
|
@ -121,3 +126,40 @@ class Address(meta.Base):
|
||||||
Shortcut for `.location.y`.
|
Shortcut for `.location.y`.
|
||||||
"""
|
"""
|
||||||
return self.location.y
|
return self.location.y
|
||||||
|
|
||||||
|
def clear_map(self) -> Address: # pragma: no cover
|
||||||
|
"""Shortcut to the `.city.clear_map()` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:D402,DAR203
|
||||||
|
self.city.clear_map()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""Shortcut to the `.city.map` object."""
|
||||||
|
return self.city.map
|
||||||
|
|
||||||
|
def draw(self, **kwargs: Any) -> folium.Map: # pragma: no cover
|
||||||
|
"""Draw the address on the `.city.map`.
|
||||||
|
|
||||||
|
By default, addresses are shown as black dots.
|
||||||
|
Use `**kwargs` to overwrite that.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: passed on to `folium.Circle()`; overwrite default settings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.city.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
defaults = {
|
||||||
|
'color': 'black',
|
||||||
|
'popup': f'{self.street}, {self.zip_code} {self.city_name}',
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
|
||||||
|
marker = folium.Circle((self.latitude, self.longitude), **defaults)
|
||||||
|
marker.add_to(self.city.map)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
"""Provide the ORM's `City` model."""
|
"""Provide the ORM's `City` model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
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
|
||||||
|
|
||||||
|
@ -94,3 +99,152 @@ class City(meta.Base):
|
||||||
The city borders refer to the Google Maps viewport.
|
The city borders refer to the Google Maps viewport.
|
||||||
"""
|
"""
|
||||||
return self.northeast.northing - self.southwest.northing
|
return self.northeast.northing - self.southwest.northing
|
||||||
|
|
||||||
|
def clear_map(self) -> City: # pragma: no cover
|
||||||
|
"""Create a new `folium.Map` object aligned with the city's viewport.
|
||||||
|
|
||||||
|
The map is available via the `.map` property. Note that it is a
|
||||||
|
mutable objects that is changed from various locations in the code base.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:DAR203
|
||||||
|
self._map = folium.Map(
|
||||||
|
location=[self.center_latitude, self.center_longitude],
|
||||||
|
zoom_start=self.initial_zoom,
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""A `folium.Map` object aligned with the city's viewport.
|
||||||
|
|
||||||
|
See docstring for `.clear_map()` for further info.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, '_map'): # noqa:WPS421 note:d334120e
|
||||||
|
self.clear_map()
|
||||||
|
|
||||||
|
return self._map
|
||||||
|
|
||||||
|
def draw_restaurants( # noqa:WPS231
|
||||||
|
self, order_counts: bool = False, # pragma: no cover
|
||||||
|
) -> folium.Map:
|
||||||
|
"""Draw all restaurants on the`.map`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order_counts: show the number of orders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
# Obtain all primary `Address`es in the city that host `Restaurant`s.
|
||||||
|
addresses = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Address)
|
||||||
|
.filter(
|
||||||
|
db.Address.id.in_(
|
||||||
|
db.session.query(db.Address.primary_id) # noqa:WPS221
|
||||||
|
.join(db.Restaurant, db.Address.id == db.Restaurant.address_id)
|
||||||
|
.filter(db.Address.city == self)
|
||||||
|
.distinct()
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for address in addresses:
|
||||||
|
# Show the restaurant's name if there is only one.
|
||||||
|
# Otherwise, list all the restaurants' ID's.
|
||||||
|
restaurants = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Restaurant)
|
||||||
|
.join(db.Address, db.Restaurant.address_id == db.Address.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(restaurants) == 1:
|
||||||
|
tooltip = f'{restaurants[0].name} (#{restaurants[0].id})' # noqa:WPS221
|
||||||
|
else:
|
||||||
|
tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336
|
||||||
|
f'#{restaurant.id}' for restaurant in restaurants
|
||||||
|
)
|
||||||
|
|
||||||
|
if order_counts:
|
||||||
|
# Calculate the number of orders for ALL restaurants ...
|
||||||
|
n_orders = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Order.id)
|
||||||
|
.join(db.Address, db.Order.pickup_address_id == db.Address.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
# ... and adjust the size of the red dot on the `.map`.
|
||||||
|
if n_orders >= 1000:
|
||||||
|
radius = 20 # noqa:WPS220
|
||||||
|
elif n_orders >= 500:
|
||||||
|
radius = 15 # noqa:WPS220
|
||||||
|
elif n_orders >= 100:
|
||||||
|
radius = 10 # noqa:WPS220
|
||||||
|
elif n_orders >= 10:
|
||||||
|
radius = 5 # noqa:WPS220
|
||||||
|
else:
|
||||||
|
radius = 1 # noqa:WPS220
|
||||||
|
|
||||||
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
address.draw(
|
||||||
|
radius=radius,
|
||||||
|
color=config.RESTAURANT_COLOR,
|
||||||
|
fill_color=config.RESTAURANT_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
address.draw(
|
||||||
|
radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
||||||
|
def draw_zip_codes(self) -> folium.Map: # pragma: no cover
|
||||||
|
"""Draw all addresses on the `.map`, colorized by their `.zip_code`.
|
||||||
|
|
||||||
|
This does not make a distinction between restaurant and customer addresses.
|
||||||
|
Also, due to the high memory usage, the number of orders is not calculated.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
# First, create a color map with distinct colors for each zip code.
|
||||||
|
all_zip_codes = sorted(
|
||||||
|
row[0]
|
||||||
|
for row in db.session.execute(
|
||||||
|
f""" -- # noqa:S608
|
||||||
|
SELECT DISTINCT
|
||||||
|
zip_code
|
||||||
|
FROM
|
||||||
|
{config.CLEAN_SCHEMA}.addresses
|
||||||
|
WHERE
|
||||||
|
city_id = {self.id};
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cmap = utils.make_random_cmap(len(all_zip_codes), bright=False)
|
||||||
|
colors = {
|
||||||
|
code: utils.rgb_to_hex(*cmap(index))
|
||||||
|
for index, code in enumerate(all_zip_codes)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Second, draw every address on the `.map.
|
||||||
|
for address in self.addresses:
|
||||||
|
# Non-primary addresses are covered by primary ones anyway.
|
||||||
|
if not address.is_primary:
|
||||||
|
continue
|
||||||
|
|
||||||
|
marker = folium.Circle( # noqa:WPS317
|
||||||
|
(address.latitude, address.longitude),
|
||||||
|
color=colors[address.zip_code],
|
||||||
|
radius=1,
|
||||||
|
)
|
||||||
|
marker.add_to(self.map)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
"""Provide the ORM's `Customer` model."""
|
"""Provide the ORM's `Customer` model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,3 +27,155 @@ class Customer(meta.Base):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
orders = orm.relationship('Order', back_populates='customer')
|
orders = orm.relationship('Order', back_populates='customer')
|
||||||
|
|
||||||
|
def clear_map(self) -> Customer: # pragma: no cover
|
||||||
|
"""Shortcut to the `...city.clear_map()` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:D402,DAR203
|
||||||
|
self.orders[0].pickup_address.city.clear_map() # noqa:WPS219
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""Shortcut to the `...city.map` object."""
|
||||||
|
return self.orders[0].pickup_address.city.map # noqa:WPS219
|
||||||
|
|
||||||
|
def draw( # noqa:C901,WPS210,WPS231
|
||||||
|
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
|
) -> folium.Map:
|
||||||
|
"""Draw all the customer's delivery addresses on the `...city.map`.
|
||||||
|
|
||||||
|
By default, the pickup locations (= restaurants) are also shown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
restaurants: show the pickup locations
|
||||||
|
order_counts: show both the number of pickups at the restaurants
|
||||||
|
and the number of deliveries at the customer's delivery addresses;
|
||||||
|
the former is only shown if `restaurants=True`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`...city.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
# Note: a `Customer` may have more than one delivery `Address`es.
|
||||||
|
# That is not true for `Restaurant`s after the data cleaning.
|
||||||
|
|
||||||
|
# Obtain all primary `Address`es where
|
||||||
|
# at least one delivery was made to `self`.
|
||||||
|
delivery_addresses = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Address)
|
||||||
|
.filter(
|
||||||
|
db.Address.id.in_(
|
||||||
|
db.session.query(db.Address.primary_id) # noqa:WPS221
|
||||||
|
.join(db.Order, db.Address.id == db.Order.delivery_address_id)
|
||||||
|
.filter(db.Order.customer_id == self.id)
|
||||||
|
.distinct()
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for address in delivery_addresses:
|
||||||
|
if order_counts:
|
||||||
|
n_orders = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Order)
|
||||||
|
.join(db.Address, db.Order.delivery_address_id == db.Address.id)
|
||||||
|
.filter(db.Order.customer_id == self.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if n_orders >= 25:
|
||||||
|
radius = 20 # noqa:WPS220
|
||||||
|
elif n_orders >= 10:
|
||||||
|
radius = 15 # noqa:WPS220
|
||||||
|
elif n_orders >= 5:
|
||||||
|
radius = 10 # noqa:WPS220
|
||||||
|
elif n_orders > 1:
|
||||||
|
radius = 5 # noqa:WPS220
|
||||||
|
else:
|
||||||
|
radius = 1 # noqa:WPS220
|
||||||
|
|
||||||
|
address.draw(
|
||||||
|
radius=radius,
|
||||||
|
color=config.CUSTOMER_COLOR,
|
||||||
|
fill_color=config.CUSTOMER_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=f'n_orders={n_orders}',
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
address.draw(
|
||||||
|
radius=1, color=config.CUSTOMER_COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
if restaurants:
|
||||||
|
pickup_addresses = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Address)
|
||||||
|
.filter(
|
||||||
|
db.Address.id.in_(
|
||||||
|
db.session.query(db.Address.primary_id) # noqa:WPS221
|
||||||
|
.join(db.Order, db.Address.id == db.Order.pickup_address_id)
|
||||||
|
.filter(db.Order.customer_id == self.id)
|
||||||
|
.distinct()
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for address in pickup_addresses: # noqa:WPS440
|
||||||
|
# Show the restaurant's name if there is only one.
|
||||||
|
# Otherwise, list all the restaurants' ID's.
|
||||||
|
# We cannot show the `Order.restaurant.name` due to the aggregation.
|
||||||
|
restaurants = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Restaurant)
|
||||||
|
.join(db.Address, db.Restaurant.address_id == db.Address.id)
|
||||||
|
.filter(db.Address.primary_id == address.id) # noqa:WPS441
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(restaurants) == 1: # type:ignore
|
||||||
|
tooltip = (
|
||||||
|
f'{restaurants[0].name} (#{restaurants[0].id})' # type:ignore
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336
|
||||||
|
f'#{restaurant.id}' for restaurant in restaurants # type:ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
if order_counts:
|
||||||
|
n_orders = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Order)
|
||||||
|
.join(db.Address, db.Order.pickup_address_id == db.Address.id)
|
||||||
|
.filter(db.Order.customer_id == self.id)
|
||||||
|
.filter(db.Address.primary_id == address.id) # noqa:WPS441
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if n_orders >= 25:
|
||||||
|
radius = 20 # noqa:WPS220
|
||||||
|
elif n_orders >= 10:
|
||||||
|
radius = 15 # noqa:WPS220
|
||||||
|
elif n_orders >= 5:
|
||||||
|
radius = 10 # noqa:WPS220
|
||||||
|
elif n_orders > 1:
|
||||||
|
radius = 5 # noqa:WPS220
|
||||||
|
else:
|
||||||
|
radius = 1 # noqa:WPS220
|
||||||
|
|
||||||
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
address.draw( # noqa:WPS441
|
||||||
|
radius=radius,
|
||||||
|
color=config.RESTAURANT_COLOR,
|
||||||
|
fill_color=config.RESTAURANT_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
address.draw( # noqa:WPS441
|
||||||
|
radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
@ -104,3 +107,31 @@ class Grid(meta.Base):
|
||||||
pixel.addresses.append(assoc)
|
pixel.addresses.append(assoc)
|
||||||
|
|
||||||
return grid
|
return grid
|
||||||
|
|
||||||
|
def clear_map(self) -> Grid: # pragma: no cover
|
||||||
|
"""Shortcut to the `.city.clear_map()` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:D402,DAR203
|
||||||
|
self.city.clear_map()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""Shortcut to the `.city.map` object."""
|
||||||
|
return self.city.map
|
||||||
|
|
||||||
|
def draw(self, **kwargs: Any) -> folium.Map: # pragma: no cover
|
||||||
|
"""Draw all pixels in the grid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: passed on to `Pixel.draw()`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.city.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
for pixel in self.pixels:
|
||||||
|
pixel.draw(**kwargs)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
"""Provide the ORM's `Pixel` model."""
|
"""Provide the ORM's `Pixel` model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
import utm
|
import utm
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@ -105,3 +110,134 @@ class Pixel(meta.Base):
|
||||||
self._southwest.relate_to(self.grid.city.southwest)
|
self._southwest.relate_to(self.grid.city.southwest)
|
||||||
|
|
||||||
return self._southwest
|
return self._southwest
|
||||||
|
|
||||||
|
def clear_map(self) -> Pixel: # pragma: no cover
|
||||||
|
"""Shortcut to the `.city.clear_map()` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:D402,DAR203
|
||||||
|
self.grid.city.clear_map()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""Shortcut to the `.city.map` object."""
|
||||||
|
return self.grid.city.map
|
||||||
|
|
||||||
|
def draw( # noqa:C901,WPS210,WPS231
|
||||||
|
self, restaurants: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
|
) -> folium.Map:
|
||||||
|
"""Draw the pixel on the `.grid.city.map`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
restaurants: include the restaurants
|
||||||
|
order_counts: show the number of orders at a restaurant
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.grid.city.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
bounds = (
|
||||||
|
(self.southwest.latitude, self.southwest.longitude),
|
||||||
|
(self.northeast.latitude, self.northeast.longitude),
|
||||||
|
)
|
||||||
|
info_text = f'Pixel({self.n_x}, {self.n_y})'
|
||||||
|
|
||||||
|
# Make the `Pixel`s look like a checkerboard.
|
||||||
|
if (self.n_x + self.n_y) % 2:
|
||||||
|
color = '#808000'
|
||||||
|
else:
|
||||||
|
color = '#ff8c00'
|
||||||
|
|
||||||
|
marker = folium.Rectangle(
|
||||||
|
bounds=bounds,
|
||||||
|
color='gray',
|
||||||
|
opacity=0.2,
|
||||||
|
weight=5,
|
||||||
|
fill_color=color,
|
||||||
|
fill_opacity=0.2,
|
||||||
|
popup=info_text,
|
||||||
|
tooltip=info_text,
|
||||||
|
)
|
||||||
|
marker.add_to(self.grid.city.map)
|
||||||
|
|
||||||
|
if restaurants:
|
||||||
|
# Obtain all primary `Address`es in the city that host `Restaurant`s
|
||||||
|
# and are in the `self` `Pixel`.
|
||||||
|
addresses = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Address)
|
||||||
|
.filter(
|
||||||
|
db.Address.id.in_(
|
||||||
|
(
|
||||||
|
db.session.query(db.Address.primary_id)
|
||||||
|
.join(
|
||||||
|
db.Restaurant,
|
||||||
|
db.Address.id == db.Restaurant.address_id,
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
db.AddressPixelAssociation,
|
||||||
|
db.Address.id == db.AddressPixelAssociation.address_id,
|
||||||
|
)
|
||||||
|
.filter(db.AddressPixelAssociation.pixel_id == self.id)
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for address in addresses:
|
||||||
|
# Show the restaurant's name if there is only one.
|
||||||
|
# Otherwise, list all the restaurants' ID's.
|
||||||
|
restaurants = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Restaurant)
|
||||||
|
.join(db.Address, db.Restaurant.address_id == db.Address.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(restaurants) == 1: # type:ignore
|
||||||
|
tooltip = (
|
||||||
|
f'{restaurants[0].name} (#{restaurants[0].id})' # type:ignore
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
tooltip = 'Restaurants ' + ', '.join( # noqa:WPS336
|
||||||
|
f'#{restaurant.id}' for restaurant in restaurants # type:ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
if order_counts:
|
||||||
|
# Calculate the number of orders for ALL restaurants ...
|
||||||
|
n_orders = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Order.id)
|
||||||
|
.join(db.Address, db.Order.pickup_address_id == db.Address.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
# ... and adjust the size of the red dot on the `.map`.
|
||||||
|
if n_orders >= 1000:
|
||||||
|
radius = 20 # noqa:WPS220
|
||||||
|
elif n_orders >= 500:
|
||||||
|
radius = 15 # noqa:WPS220
|
||||||
|
elif n_orders >= 100:
|
||||||
|
radius = 10 # noqa:WPS220
|
||||||
|
elif n_orders >= 10:
|
||||||
|
radius = 5 # noqa:WPS220
|
||||||
|
else:
|
||||||
|
radius = 1 # noqa:WPS220
|
||||||
|
|
||||||
|
tooltip += f' | n_orders={n_orders}' # noqa:WPS336
|
||||||
|
|
||||||
|
address.draw(
|
||||||
|
radius=radius,
|
||||||
|
color=config.RESTAURANT_COLOR,
|
||||||
|
fill_color=config.RESTAURANT_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
address.draw(
|
||||||
|
radius=1, color=config.RESTAURANT_COLOR, tooltip=tooltip,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
"""Provide the ORM's `Restaurant` model."""
|
"""Provide the ORM's `Restaurant` model."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import folium
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,3 +50,92 @@ class Restaurant(meta.Base):
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
"""Non-literal text representation."""
|
"""Non-literal text representation."""
|
||||||
return '<{cls}({name})>'.format(cls=self.__class__.__name__, name=self.name)
|
return '<{cls}({name})>'.format(cls=self.__class__.__name__, name=self.name)
|
||||||
|
|
||||||
|
def clear_map(self) -> Restaurant: # pragma: no cover
|
||||||
|
"""Shortcut to the `.address.city.clear_map()` method.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
self: enabling method chaining
|
||||||
|
""" # noqa:D402,DAR203
|
||||||
|
self.address.city.clear_map()
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property # pragma: no cover
|
||||||
|
def map(self) -> folium.Map: # noqa:WPS125
|
||||||
|
"""Shortcut to the `.address.city.map` object."""
|
||||||
|
return self.address.city.map
|
||||||
|
|
||||||
|
def draw( # noqa:WPS231
|
||||||
|
self, customers: bool = True, order_counts: bool = False, # pragma: no cover
|
||||||
|
) -> folium.Map:
|
||||||
|
"""Draw the restaurant on the `.address.city.map`.
|
||||||
|
|
||||||
|
By default, the restaurant's delivery locations are also shown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customers: show the restaurant's delivery locations
|
||||||
|
order_counts: show the number of orders at the delivery locations;
|
||||||
|
only useful if `customers=True`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
`.address.city.map` for convenience in interactive usage
|
||||||
|
"""
|
||||||
|
if customers:
|
||||||
|
# Obtain all primary `Address`es in the city that
|
||||||
|
# received at least one delivery from `self`.
|
||||||
|
delivery_addresses = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Address)
|
||||||
|
.filter(
|
||||||
|
db.Address.id.in_(
|
||||||
|
db.session.query(db.Address.primary_id) # noqa:WPS221
|
||||||
|
.join(db.Order, db.Address.id == db.Order.delivery_address_id)
|
||||||
|
.filter(db.Order.restaurant_id == self.id)
|
||||||
|
.distinct()
|
||||||
|
.all(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
for address in delivery_addresses:
|
||||||
|
if order_counts:
|
||||||
|
n_orders = ( # noqa:ECE001
|
||||||
|
db.session.query(db.Order)
|
||||||
|
.join(db.Address, db.Order.delivery_address_id == db.Address.id)
|
||||||
|
.filter(db.Order.restaurant_id == self.id)
|
||||||
|
.filter(db.Address.primary_id == address.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if n_orders >= 25:
|
||||||
|
radius = 20 # noqa:WPS220
|
||||||
|
elif n_orders >= 10:
|
||||||
|
radius = 15 # noqa:WPS220
|
||||||
|
elif n_orders >= 5:
|
||||||
|
radius = 10 # noqa:WPS220
|
||||||
|
elif n_orders > 1:
|
||||||
|
radius = 5 # noqa:WPS220
|
||||||
|
else:
|
||||||
|
radius = 1 # noqa:WPS220
|
||||||
|
|
||||||
|
address.draw(
|
||||||
|
radius=radius,
|
||||||
|
color=config.CUSTOMER_COLOR,
|
||||||
|
fill_color=config.CUSTOMER_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=f'n_orders={n_orders}',
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
address.draw(
|
||||||
|
radius=1, color=config.CUSTOMER_COLOR,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.address.draw(
|
||||||
|
radius=20,
|
||||||
|
color=config.RESTAURANT_COLOR,
|
||||||
|
fill_color=config.RESTAURANT_COLOR,
|
||||||
|
fill_opacity=0.3,
|
||||||
|
tooltip=f'{self.name} (#{self.id}) | n_orders={len(self.orders)}',
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.map
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
"""Utilities used by the ORM models."""
|
"""Utilities used by the ORM models."""
|
||||||
|
|
||||||
|
from urban_meal_delivery.db.utils.colors import make_random_cmap
|
||||||
|
from urban_meal_delivery.db.utils.colors import rgb_to_hex
|
||||||
from urban_meal_delivery.db.utils.locations import Location
|
from urban_meal_delivery.db.utils.locations import Location
|
||||||
|
|
69
src/urban_meal_delivery/db/utils/colors.py
Normal file
69
src/urban_meal_delivery/db/utils/colors.py
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
"""Utilities for drawing maps with `folium`."""
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from matplotlib import colors
|
||||||
|
|
||||||
|
|
||||||
|
def make_random_cmap(
|
||||||
|
n_colors: int, bright: bool = True, # pragma: no cover
|
||||||
|
) -> colors.LinearSegmentedColormap:
|
||||||
|
"""Create a random `Colormap` with `n_colors` different colors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n_colors: number of of different colors; size of `Colormap`
|
||||||
|
bright: `True` for strong colors, `False` for pastel colors
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
colormap
|
||||||
|
"""
|
||||||
|
np.random.seed(42)
|
||||||
|
|
||||||
|
if bright:
|
||||||
|
hsv_colors = [
|
||||||
|
(
|
||||||
|
np.random.uniform(low=0.0, high=1),
|
||||||
|
np.random.uniform(low=0.2, high=1),
|
||||||
|
np.random.uniform(low=0.9, high=1),
|
||||||
|
)
|
||||||
|
for _ in range(n_colors)
|
||||||
|
]
|
||||||
|
|
||||||
|
rgb_colors = []
|
||||||
|
for color in hsv_colors:
|
||||||
|
rgb_colors.append(colorsys.hsv_to_rgb(*color))
|
||||||
|
|
||||||
|
else:
|
||||||
|
low = 0.0
|
||||||
|
high = 0.66
|
||||||
|
|
||||||
|
rgb_colors = [
|
||||||
|
(
|
||||||
|
np.random.uniform(low=low, high=high),
|
||||||
|
np.random.uniform(low=low, high=high),
|
||||||
|
np.random.uniform(low=low, high=high),
|
||||||
|
)
|
||||||
|
for _ in range(n_colors)
|
||||||
|
]
|
||||||
|
|
||||||
|
return colors.LinearSegmentedColormap.from_list(
|
||||||
|
'random_color_map', rgb_colors, N=n_colors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rgb_to_hex(*args: float) -> str: # pragma: no cover
|
||||||
|
"""Convert RGB colors into hexadecimal notation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
*args: percentages (0% - 100%) for the RGB channels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
hexadecimal_representation
|
||||||
|
"""
|
||||||
|
red, green, blue = (
|
||||||
|
int(255 * args[0]),
|
||||||
|
int(255 * args[1]),
|
||||||
|
int(255 * args[2]),
|
||||||
|
)
|
||||||
|
return f'#{red:02x}{green:02x}{blue:02x}' # noqa:WPS221
|
|
@ -48,7 +48,7 @@ class AddressFactory(alchemy.SQLAlchemyModelFactory):
|
||||||
# As non-primary addresses have no different behavior and
|
# As non-primary addresses have no different behavior and
|
||||||
# the property is only kept from the original dataset for
|
# the property is only kept from the original dataset for
|
||||||
# completeness sake, that is ok to do.
|
# completeness sake, that is ok to do.
|
||||||
_primary_id = factory.LazyAttribute(lambda obj: obj.id)
|
primary_id = factory.LazyAttribute(lambda obj: obj.id)
|
||||||
|
|
||||||
# Mimic a Google Maps Place ID with just random characters.
|
# Mimic a Google Maps Place ID with just random characters.
|
||||||
place_id = factory.LazyFunction(
|
place_id = factory.LazyFunction(
|
||||||
|
|
|
@ -39,8 +39,8 @@ class TestConstraints:
|
||||||
def test_delete_a_referenced_address(self, db_session, address, make_address):
|
def test_delete_a_referenced_address(self, db_session, address, make_address):
|
||||||
"""Remove a record that is referenced with a FK."""
|
"""Remove a record that is referenced with a FK."""
|
||||||
db_session.add(address)
|
db_session.add(address)
|
||||||
# Fake another_address that has the same `._primary_id` as `address`.
|
# Fake another_address that has the same `.primary_id` as `address`.
|
||||||
db_session.add(make_address(_primary_id=address.id))
|
db_session.add(make_address(primary_id=address.id))
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
db_session.delete(address)
|
db_session.delete(address)
|
||||||
|
@ -109,7 +109,7 @@ class TestProperties:
|
||||||
|
|
||||||
def test_is_primary(self, address):
|
def test_is_primary(self, address):
|
||||||
"""Test `Address.is_primary` property."""
|
"""Test `Address.is_primary` property."""
|
||||||
assert address.id == address._primary_id
|
assert address.id == address.primary_id
|
||||||
|
|
||||||
result = address.is_primary
|
result = address.is_primary
|
||||||
|
|
||||||
|
@ -117,7 +117,7 @@ class TestProperties:
|
||||||
|
|
||||||
def test_is_not_primary(self, address):
|
def test_is_not_primary(self, address):
|
||||||
"""Test `Address.is_primary` property."""
|
"""Test `Address.is_primary` property."""
|
||||||
address._primary_id = 999
|
address.primary_id = 999
|
||||||
|
|
||||||
result = address.is_primary
|
result = address.is_primary
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue