- as of September 2021, PyCharm is used to write some of the code
- PyCharm's built-in code styler, linter, and type checker issued
some warnings that are resolved in this commit
+ spelling mistakes
+ all instance attributes must be specified explicitly
in a class's __init__() method
=> use `functools.cached_property` for caching
+ make `tuple`s explicit with `(...)`
+ one test failed randomly although everything is ok
=> adjust the fixture's return value (stub for Google Directions API)
+ reformulate SQL so that PyCharm can understand the symbols
254 lines
9 KiB
Python
254 lines
9 KiB
Python
"""Provide the ORM's `Pixel` model."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
from typing import List
|
|
|
|
import folium
|
|
import sqlalchemy as sa
|
|
import utm
|
|
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 utils
|
|
|
|
|
|
class Pixel(meta.Base):
|
|
"""A pixel in a `Grid`.
|
|
|
|
Square pixels aggregate `Address` objects within a `City`.
|
|
Every `Address` belongs to exactly one `Pixel` in a `Grid`.
|
|
|
|
Every `Pixel` has a unique `n_x`-`n_y` coordinate within the `Grid`.
|
|
"""
|
|
|
|
__tablename__ = 'pixels'
|
|
|
|
# Columns
|
|
id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) # noqa:WPS125
|
|
grid_id = sa.Column(sa.SmallInteger, nullable=False, index=True)
|
|
n_x = sa.Column(sa.SmallInteger, nullable=False, index=True)
|
|
n_y = sa.Column(sa.SmallInteger, nullable=False, index=True)
|
|
|
|
# Constraints
|
|
__table_args__ = (
|
|
sa.ForeignKeyConstraint(
|
|
['grid_id'], ['grids.id'], onupdate='RESTRICT', ondelete='RESTRICT',
|
|
),
|
|
sa.CheckConstraint('0 <= n_x', name='n_x_is_positive'),
|
|
sa.CheckConstraint('0 <= n_y', name='n_y_is_positive'),
|
|
# Needed by a `ForeignKeyConstraint` in `AddressPixelAssociation`.
|
|
sa.UniqueConstraint('id', 'grid_id'),
|
|
# Each coordinate within the same `grid` is used at most once.
|
|
sa.UniqueConstraint('grid_id', 'n_x', 'n_y'),
|
|
)
|
|
|
|
# Relationships
|
|
grid = orm.relationship('Grid', back_populates='pixels')
|
|
addresses = orm.relationship('AddressPixelAssociation', back_populates='pixel')
|
|
forecasts = orm.relationship('Forecast', back_populates='pixel')
|
|
|
|
def __repr__(self) -> str:
|
|
"""Non-literal text representation."""
|
|
return '<{cls}: ({x}|{y})>'.format(
|
|
cls=self.__class__.__name__, x=self.n_x, y=self.n_y,
|
|
)
|
|
|
|
# Convenience properties
|
|
|
|
@property
|
|
def side_length(self) -> int:
|
|
"""The length of one side of a pixel in meters."""
|
|
return self.grid.side_length
|
|
|
|
@property
|
|
def area(self) -> float:
|
|
"""The area of a pixel in square kilometers."""
|
|
return self.grid.pixel_area
|
|
|
|
@functools.cached_property
|
|
def northeast(self) -> utils.Location:
|
|
"""The pixel's northeast corner, relative to `.grid.city.southwest`.
|
|
|
|
Implementation detail: This property is cached as none of the
|
|
underlying attributes to calculate the value are to be changed.
|
|
"""
|
|
easting, northing = (
|
|
self.grid.city.southwest.easting + ((self.n_x + 1) * self.side_length),
|
|
self.grid.city.southwest.northing + ((self.n_y + 1) * self.side_length),
|
|
)
|
|
latitude, longitude = utm.to_latlon(
|
|
easting, northing, *self.grid.city.southwest.zone_details,
|
|
)
|
|
|
|
location = utils.Location(latitude, longitude)
|
|
location.relate_to(self.grid.city.southwest)
|
|
|
|
return location
|
|
|
|
@functools.cached_property
|
|
def southwest(self) -> utils.Location:
|
|
"""The pixel's southwest corner, relative to `.grid.city.southwest`.
|
|
|
|
Implementation detail: This property is cached as none of the
|
|
underlying attributes to calculate the value are to be changed.
|
|
"""
|
|
easting, northing = (
|
|
self.grid.city.southwest.easting + (self.n_x * self.side_length),
|
|
self.grid.city.southwest.northing + (self.n_y * self.side_length),
|
|
)
|
|
latitude, longitude = utm.to_latlon(
|
|
easting, northing, *self.grid.city.southwest.zone_details,
|
|
)
|
|
|
|
location = utils.Location(latitude, longitude)
|
|
location.relate_to(self.grid.city.southwest)
|
|
|
|
return location
|
|
|
|
@functools.cached_property
|
|
def restaurants(self) -> List[db.Restaurant]: # pragma: no cover
|
|
"""Obtain all `Restaurant`s in `self`."""
|
|
return ( # noqa:ECE001
|
|
db.session.query(db.Restaurant)
|
|
.join(
|
|
db.AddressPixelAssociation,
|
|
db.Restaurant.address_id == db.AddressPixelAssociation.address_id,
|
|
)
|
|
.filter(db.AddressPixelAssociation.pixel_id == self.id)
|
|
.all()
|
|
)
|
|
|
|
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
|