Add functionality for drawing folium.Maps

- 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:
Alexander Hess 2021-01-26 17:07:50 +01:00
parent 605ade4078
commit 4b6d92958d
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
12 changed files with 714 additions and 13 deletions

View file

@ -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]

View file

@ -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
# -------------------------------- # --------------------------------

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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(

View file

@ -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