Add Grid.gridify() constructor

- the purpose of this constructor method is to generate all `Pixel`s
  for a `Grid` that have at least one `Address` assigned to them
- fix missing `UniqueConstraint` in `Grid` class => it was not possible
  to create two `Grid`s with the same `.side_length` in different cities
- change the `City.viewport` property into two separate `City.southwest`
  and `City.northeast` properties; also add `City.total_x` and
  `City.total_y` properties for convenience
This commit is contained in:
Alexander Hess 2021-01-05 18:58:48 +01:00
parent a1cbb808fd
commit 776112d609
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
10 changed files with 224 additions and 57 deletions

View file

@ -36,7 +36,11 @@ def upgrade():
onupdate='RESTRICT', onupdate='RESTRICT',
ondelete='RESTRICT', ondelete='RESTRICT',
), ),
sa.UniqueConstraint('side_length', name=op.f('uq_grids_on_side_length')), sa.UniqueConstraint(
'city_id', 'side_length', name=op.f('uq_grids_on_city_id_side_length'),
),
# This `UniqueConstraint` is needed by the `addresses_pixels` table below.
sa.UniqueConstraint('id', 'city_id', name=op.f('uq_grids_on_id_city_id')),
schema=config.CLEAN_SCHEMA, schema=config.CLEAN_SCHEMA,
) )
@ -85,19 +89,13 @@ def upgrade():
schema=config.CLEAN_SCHEMA, schema=config.CLEAN_SCHEMA,
) )
# These `UniqueConstraints`s are needed by the `addresses_pixels` table below. # This `UniqueConstraint` is needed by the `addresses_pixels` table below.
op.create_unique_constraint( op.create_unique_constraint(
'uq_addresses_on_id_city_id', 'uq_addresses_on_id_city_id',
'addresses', 'addresses',
['id', 'city_id'], ['id', 'city_id'],
schema=config.CLEAN_SCHEMA, schema=config.CLEAN_SCHEMA,
) )
op.create_unique_constraint(
'uq_grids_on_id_city_id',
'grids',
['id', 'city_id'],
schema=config.CLEAN_SCHEMA,
)
op.create_table( op.create_table(
'addresses_pixels', 'addresses_pixels',

View file

@ -470,7 +470,7 @@ def init_project(session):
@nox.session(name='clean-pwd', python=PYTHON, venv_backend='none') @nox.session(name='clean-pwd', python=PYTHON, venv_backend='none')
def clean_pwd(session): # noqa:WPS210,WPS231 def clean_pwd(session): # noqa:WPS231
"""Remove (almost) all glob patterns listed in .gitignore. """Remove (almost) all glob patterns listed in .gitignore.
The difference compared to `git clean -X` is that this task The difference compared to `git clean -X` is that this task

View file

@ -170,6 +170,9 @@ per-file-ignores =
# Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development # Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development
max-complexity = 10 max-complexity = 10
# Allow more than wemake-python-styleguide's 5 local variables per function.
max-local-variables = 8
# Allow more than wemake-python-styleguide's 7 methods per class. # Allow more than wemake-python-styleguide's 7 methods per class.
max-methods = 12 max-methods = 12

View file

@ -1,6 +1,5 @@
"""Provide the ORM's `Address` model.""" """Provide the ORM's `Address` model."""
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
@ -93,7 +92,7 @@ class Address(meta.Base):
def location(self) -> utils.Location: def location(self) -> utils.Location:
"""The location of the address. """The location of the address.
The returned `Location` object relates to `.city.viewport.southwest`. The returned `Location` object relates to `.city.southwest`.
See also the `.x` and `.y` properties that are shortcuts for See also the `.x` and `.y` properties that are shortcuts for
`.location.x` and `.location.y`. `.location.x` and `.location.y`.
@ -103,7 +102,7 @@ class Address(meta.Base):
""" """
if not hasattr(self, '_location'): # noqa:WPS421 note:b1f68d24 if not hasattr(self, '_location'): # noqa:WPS421 note:b1f68d24
self._location = utils.Location(self.latitude, self.longitude) self._location = utils.Location(self.latitude, self.longitude)
self._location.relate_to(self.city.as_xy_origin) self._location.relate_to(self.city.southwest)
return self._location return self._location
@property @property

View file

@ -1,6 +1,5 @@
"""Provide the ORM's `City` model.""" """Provide the ORM's `City` model."""
from typing import Dict
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
@ -69,30 +68,45 @@ class City(meta.Base):
return self._center return self._center
@property @property
def viewport(self) -> Dict[str, utils.Location]: def northeast(self) -> utils.Location:
"""Google Maps viewport of the city. """The city's northeast corner of the Google Maps viewport.
Implementation detail: This property is cached as none of the Implementation detail: This property is cached as none of the
underlying attributes to calculate the value are to be changed. underlying attributes to calculate the value are to be changed.
""" """
if not hasattr(self, '_viewport'): # noqa:WPS421 note:d334120e if not hasattr(self, '_northeast'): # noqa:WPS421 note:d334120e
self._viewport = { self._northeast = utils.Location(
'northeast': utils.Location( self._northeast_latitude, self._northeast_longitude,
self._northeast_latitude, self._northeast_longitude, )
),
'southwest': utils.Location(
self._southwest_latitude, self._southwest_longitude,
),
}
return self._viewport return self._northeast
@property @property
def as_xy_origin(self) -> utils.Location: def southwest(self) -> utils.Location:
"""The southwest corner of the `.viewport`. """The city's southwest corner of the Google Maps viewport.
This property serves, for example, as the `other` argument to the Implementation detail: This property is cached as none of the
`Location.relate_to()` method when representing an `Address` underlying attributes to calculate the value are to be changed.
in the x-y plane.
""" """
return self.viewport['southwest'] if not hasattr(self, '_southwest'): # noqa:WPS421 note:d334120e
self._southwest = utils.Location(
self._southwest_latitude, self._southwest_longitude,
)
return self._southwest
@property
def total_x(self) -> int:
"""The horizontal distance from the city's west to east end in meters.
The city borders refer to the Google Maps viewport.
"""
return self.northeast.easting - self.southwest.easting
@property
def total_y(self) -> int:
"""The vertical distance from the city's south to north end in meters.
The city borders refer to the Google Maps viewport.
"""
return self.northeast.northing - self.southwest.northing

View file

@ -1,8 +1,11 @@
"""Provide the ORM's `Grid` model.""" """Provide the ORM's `Grid` model."""
from __future__ import annotations
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from urban_meal_delivery import db
from urban_meal_delivery.db import meta from urban_meal_delivery.db import meta
@ -27,6 +30,9 @@ class Grid(meta.Base):
sa.ForeignKeyConstraint( sa.ForeignKeyConstraint(
['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT', ['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT',
), ),
# Each `Grid`, characterized by its `.side_length`,
# may only exists once for a given `.city`.
sa.UniqueConstraint('city_id', 'side_length'),
# Needed by a `ForeignKeyConstraint` in `address_pixel_association`. # Needed by a `ForeignKeyConstraint` in `address_pixel_association`.
sa.UniqueConstraint('id', 'city_id'), sa.UniqueConstraint('id', 'city_id'),
) )
@ -46,3 +52,45 @@ class Grid(meta.Base):
def pixel_area(self) -> float: def pixel_area(self) -> float:
"""The area of a `Pixel` on the grid in square kilometers.""" """The area of a `Pixel` on the grid in square kilometers."""
return (self.side_length ** 2) / 1_000_000 # noqa:WPS432 return (self.side_length ** 2) / 1_000_000 # noqa:WPS432
@classmethod
def gridify(cls, city: db.City, side_length: int) -> db.Grid:
"""Create a fully populated `Grid` for a `city`.
The created `Grid` contains only the `Pixel`s for which
there is at least one `Address` in it.
Args:
city: city for which the grid is created
side_length: the length of a square `Pixel`'s side
Returns:
grid: including `grid.pixels` with the associated `city.addresses`
"""
grid = cls(city=city, side_length=side_length)
# Create `Pixel` objects covering the entire `city`.
# Note: `+1` so that `city.northeast` corner is on the grid.
possible_pixels = [
db.Pixel(n_x=n_x, n_y=n_y)
for n_x in range((city.total_x // side_length) + 1)
for n_y in range((city.total_y // side_length) + 1)
]
# For convenient lookup by `.n_x`-`.n_y` coordinates.
pixel_map = {(pixel.n_x, pixel.n_y): pixel for pixel in possible_pixels}
for address in city.addresses:
# Determine which `pixel` the `address` belongs to.
n_x = address.x // side_length
n_y = address.y // side_length
pixel = pixel_map[n_x, n_y]
# Create an association between the `address` and `pixel`.
assoc = db.AddressPixelAssociation(address=address, pixel=pixel)
pixel.addresses.append(assoc)
# Only keep `pixel`s that contain at least one `Address`.
grid.pixels = [pixel for pixel in pixel_map.values() if pixel.addresses]
return grid

View file

@ -3,7 +3,6 @@
import pytest import pytest
from tests.db.utils import test_locations as consts
from urban_meal_delivery import db from urban_meal_delivery import db
from urban_meal_delivery.db import utils from urban_meal_delivery.db import utils
@ -55,33 +54,44 @@ class TestProperties:
assert result1 is result2 assert result1 is result2
def test_viewport_overall(self, city): def test_northeast(self, city, city_data):
"""Test `City.viewport` property.""" """Test `City.northeast` property."""
result = city.viewport result = city.northeast
assert isinstance(result, dict)
assert len(result) == 2
@pytest.mark.parametrize('corner', ['northeast', 'southwest'])
def test_viewport_corners(self, city, city_data, corner):
"""Test `City.viewport` property."""
result = city.viewport[corner]
assert isinstance(result, utils.Location) assert isinstance(result, utils.Location)
assert result.latitude == pytest.approx(city_data[f'_{corner}_latitude']) assert result.latitude == pytest.approx(city_data['_northeast_latitude'])
assert result.longitude == pytest.approx(city_data[f'_{corner}_longitude']) assert result.longitude == pytest.approx(city_data['_northeast_longitude'])
def test_viewport_is_cached(self, city): def test_northeast_is_cached(self, city):
"""Test `City.viewport` property.""" """Test `City.northeast` property."""
result1 = city.viewport result1 = city.northeast
result2 = city.viewport result2 = city.northeast
assert result1 is result2 assert result1 is result2
def test_city_as_xy_origin(self, city): def test_southwest(self, city, city_data):
"""Test `City.as_xy_origin` property.""" """Test `City.southwest` property."""
result = city.as_xy_origin result = city.southwest
assert result.zone == consts.ZONE assert isinstance(result, utils.Location)
assert consts.MIN_EASTING < result.easting < consts.MAX_EASTING assert result.latitude == pytest.approx(city_data['_southwest_latitude'])
assert consts.MIN_NORTHING < result.northing < consts.MAX_NORTHING assert result.longitude == pytest.approx(city_data['_southwest_longitude'])
def test_southwest_is_cached(self, city):
"""Test `City.southwest` property."""
result1 = city.southwest
result2 = city.southwest
assert result1 is result2
def test_total_x(self, city):
"""Test `City.total_x` property."""
result = city.total_x
assert result > 18_000
def test_total_y(self, city):
"""Test `City.total_y` property."""
result = city.total_y
assert result > 9_000

View file

@ -49,6 +49,18 @@ class TestConstraints:
): ):
db_session.execute(stmt) db_session.execute(stmt)
def test_two_grids_with_identical_side_length(self, db_session, grid):
"""Insert a record that violates a unique constraint."""
db_session.add(grid)
db_session.commit()
# Create a `Grid` with the same `.side_length` in the same `.city`.
another_grid = db.Grid(city=grid.city, side_length=grid.side_length)
db_session.add(another_grid)
with pytest.raises(sa_exc.IntegrityError, match='duplicate key value'):
db_session.commit()
class TestProperties: class TestProperties:
"""Test properties in `Grid`.""" """Test properties in `Grid`."""
@ -58,3 +70,86 @@ class TestProperties:
result = grid.pixel_area result = grid.pixel_area
assert result == 1.0 assert result == 1.0
class TestGridification:
"""Test the `Grid.gridify()` constructor."""
def test_one_pixel_covering_entire_city_without_addresses(self, city):
"""At the very least, there must be one `Pixel` ...
... if the `side_length` is greater than both the
horizontal and vertical distances of the viewport.
"""
city.addresses = []
# `+1` as otherwise there would be a second pixel in one direction.
side_length = max(city.total_x, city.total_y) + 1
result = db.Grid.gridify(city=city, side_length=side_length)
assert isinstance(result, db.Grid)
assert len(result.pixels) == 0 # noqa:WPS507
def test_one_pixel_covering_entire_city_with_one_address(self, city, address):
"""At the very least, there must be one `Pixel` ...
... if the `side_length` is greater than both the
horizontal and vertical distances of the viewport.
"""
city.addresses = [address]
# `+1` as otherwise there would be a second pixel in one direction.
side_length = max(city.total_x, city.total_y) + 1
result = db.Grid.gridify(city=city, side_length=side_length)
assert isinstance(result, db.Grid)
assert len(result.pixels) == 1
def test_four_pixels_with_two_addresses(self, city, make_address):
"""Two `Address` objects in distinct `Pixel` objects."""
# Create two `Address` objects in distinct `Pixel`s.
city.addresses = [
# One `Address` in the lower-left `Pixel`, ...
make_address(latitude=48.8357377, longitude=2.2517412),
# ... and another one in the upper-right one.
make_address(latitude=48.8898312, longitude=2.4357622),
]
side_length = max(city.total_x // 2, city.total_y // 2) + 1
# By assumption of the test data.
n_pixels_x = (city.total_x // side_length) + 1
n_pixels_y = (city.total_y // side_length) + 1
assert n_pixels_x * n_pixels_y == 4
# Create a `Grid` with at most four `Pixel`s.
result = db.Grid.gridify(city=city, side_length=side_length)
assert isinstance(result, db.Grid)
assert len(result.pixels) == 2
@pytest.mark.db
@pytest.mark.no_cover
@pytest.mark.parametrize('side_length', [250, 500, 1_000, 2_000, 4_000, 8_000])
def test_make_random_grids(self, db_session, city, make_address, side_length):
"""With 100 random `Address` objects, a grid must have ...
... between 1 and a deterministic number of `Pixel` objects.
This test creates confidence that the created `Grid`
objects adhere to the database constraints.
"""
city.addresses = [make_address() for _ in range(100)]
n_pixels_x = (city.total_x // side_length) + 1
n_pixels_y = (city.total_y // side_length) + 1
result = db.Grid.gridify(city=city, side_length=side_length)
assert isinstance(result, db.Grid)
assert 1 <= len(result.pixels) <= n_pixels_x * n_pixels_y
db_session.add(result)
db_session.commit()

View file

@ -425,7 +425,7 @@ class TestProperties:
@pytest.mark.db @pytest.mark.db
@pytest.mark.no_cover @pytest.mark.no_cover
def test_make_random_orders( # noqa:C901,WPS211,WPS210,WPS213,WPS231 def test_make_random_orders( # noqa:C901,WPS211,WPS213,WPS231
db_session, make_address, make_courier, make_restaurant, make_order, db_session, make_address, make_courier, make_restaurant, make_order,
): ):
"""Sanity check the all the `make_*` fixtures. """Sanity check the all the `make_*` fixtures.

View file

@ -35,7 +35,7 @@ def faraway_location():
@pytest.fixture @pytest.fixture
def origin(city): def origin(city):
"""A `Location` object based off the one and only `city`.""" """A `Location` object based off the one and only `city`."""
obj = city.as_xy_origin obj = city.southwest
assert obj.zone == ZONE # sanity check assert obj.zone == ZONE # sanity check