Add ORM models for the simulation data

- add new ORM models `ReplaySimulation` and `ReplayedOrder`
  to store the data generated by the routing simulations
- add migrations script to create the corresponding database tables
  + create "replay_simulations" and "replayed_orders" tables
  + add missing check constraints to "orders" table
  + add unique constraint to "orders" table to enable compound key
  + drop unnecessary check constraints from the "orders" table
- add tests for the new ORM models
  + add `simulation`, `replayed_order`, `make_replay_order()`, and
    `pre_order` fixtures
  + add `ReplayedOrderFactor` faker class
- fix some typos
This commit is contained in:
Alexander Hess 2021-09-16 11:58:55 +02:00
commit e4e543bd40
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
19 changed files with 1677 additions and 10 deletions

View file

@ -4,6 +4,7 @@ from tests.db.fake_data.fixture_makers import make_address
from tests.db.fake_data.fixture_makers import make_courier
from tests.db.fake_data.fixture_makers import make_customer
from tests.db.fake_data.fixture_makers import make_order
from tests.db.fake_data.fixture_makers import make_replay_order
from tests.db.fake_data.fixture_makers import make_restaurant
from tests.db.fake_data.static_fixtures import address
from tests.db.fake_data.static_fixtures import city
@ -13,4 +14,8 @@ from tests.db.fake_data.static_fixtures import customer
from tests.db.fake_data.static_fixtures import grid
from tests.db.fake_data.static_fixtures import order
from tests.db.fake_data.static_fixtures import pixel
from tests.db.fake_data.static_fixtures import pre_order
from tests.db.fake_data.static_fixtures import replayed_order
from tests.db.fake_data.static_fixtures import restaurant
from tests.db.fake_data.static_fixtures import simulation
from tests.db.fake_data.static_fixtures import simulation_data

View file

@ -4,5 +4,6 @@ from tests.db.fake_data.factories.addresses import AddressFactory
from tests.db.fake_data.factories.couriers import CourierFactory
from tests.db.fake_data.factories.customers import CustomerFactory
from tests.db.fake_data.factories.orders import AdHocOrderFactory
from tests.db.fake_data.factories.orders import ReplayedOrderFactory
from tests.db.fake_data.factories.orders import ScheduledOrderFactory
from tests.db.fake_data.factories.restaurants import RestaurantFactory

View file

@ -1,4 +1,5 @@
"""Factory to create `Order` instances."""
from tests.db.fake_data.factories.orders.ad_hoc import AdHocOrderFactory
from tests.db.fake_data.factories.orders.replayed import ReplayedOrderFactory
from tests.db.fake_data.factories.orders.scheduled import ScheduledOrderFactory

View file

@ -98,12 +98,16 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=30, max_seconds=90),
)
restaurant_notified_at_corrected = False
restaurant_notified_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.restaurant_notified_at else None,
)
restaurant_confirmed_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ utils.random_timespan(min_seconds=30, max_seconds=150),
)
restaurant_confirmed_at_corrected = False
restaurant_confirmed_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.restaurant_confirmed_at else None,
)
# Use the database defaults of the historic data.
estimated_prep_duration = 900
estimated_prep_duration_corrected = False
@ -115,17 +119,23 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=600, max_seconds=1080),
)
dispatch_at_corrected = False
dispatch_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.dispatch_at else None,
)
courier_notified_at = factory.LazyAttribute(
lambda obj: obj.dispatch_at
+ utils.random_timespan(min_seconds=100, max_seconds=140),
)
courier_notified_at_corrected = False
courier_notified_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.courier_notified_at else None,
)
courier_accepted_at = factory.LazyAttribute(
lambda obj: obj.courier_notified_at
+ utils.random_timespan(min_seconds=15, max_seconds=45),
)
courier_accepted_at_corrected = False
courier_accepted_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.courier_accepted_at else None,
)
# Sample a realistic utilization.
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
@ -139,13 +149,17 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
lambda obj: obj.reached_pickup_at
+ utils.random_timespan(min_seconds=120, max_seconds=600),
)
pickup_at_corrected = False
pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.pickup_at else None,
)
pickup_not_confirmed = False
left_pickup_at = factory.LazyAttribute(
lambda obj: obj.pickup_at
+ utils.random_timespan(min_seconds=60, max_seconds=180),
)
left_pickup_at_corrected = False
left_pickup_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.left_pickup_at else None,
)
# Delivery-related attributes
# delivery_address -> set by the `make_order` fixture as there is only one `city`
@ -157,7 +171,9 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
lambda obj: obj.reached_delivery_at
+ utils.random_timespan(min_seconds=240, max_seconds=660),
)
delivery_at_corrected = False
delivery_at_corrected = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None,
)
delivery_not_confirmed = False
_courier_waited_at_delivery = factory.LazyAttribute(
lambda obj: False if obj.delivery_at else None,

View file

@ -0,0 +1,120 @@
"""Factory to create `Order` and `ReplayedOrder` instances."""
import datetime as dt
import factory
from factory import alchemy
from tests.db.fake_data.factories import utils
from urban_meal_delivery import db
class ReplayedOrderFactory(alchemy.SQLAlchemyModelFactory):
"""Create instances of the `db.ReplayedOrder` model.
For simplicity, we assume the underlying `.order` to be an ad-hoc `Order`.
"""
class Meta:
model = db.ReplayedOrder
sqlalchemy_get_or_create = ('simulation_id', 'order_id')
# Generic columns
# simulation -> set by the `make_replay_order` fixture for better control
# actual (`Order`) -> set by the `make_replay_order` fixture for better control
# `Order`-type related columns
ad_hoc = factory.LazyAttribute(lambda obj: obj.actual.ad_hoc)
placed_at = factory.LazyAttribute(lambda obj: obj.actual.placed_at)
scheduled_delivery_at = factory.LazyAttribute(
lambda obj: obj.actual.scheduled_delivery_at,
)
cancelled_at = None
# Restaurant-related columns
estimated_prep_duration = 1200
restaurant_notified_at = factory.LazyAttribute(
lambda obj: obj.placed_at
+ utils.random_timespan(min_seconds=1, max_seconds=30),
)
restaurant_confirmed_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
restaurant_ready_at = factory.LazyAttribute(
lambda obj: obj.restaurant_confirmed_at
+ dt.timedelta(seconds=obj.estimated_prep_duration)
+ utils.random_timespan(min_seconds=300, max_seconds=300),
)
# Dispatch-related columns
dispatch_at = factory.LazyAttribute(
lambda obj: obj.actual.placed_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
first_estimated_delivery_at = factory.LazyAttribute(
lambda obj: obj.restaurant_notified_at
+ dt.timedelta(seconds=obj.estimated_prep_duration)
+ dt.timedelta(minutes=10),
)
# courier -> set by the `make_replay_order` fixture for better control
courier_notified_at = factory.LazyAttribute(
lambda obj: obj.dispatch_at
+ utils.random_timespan(min_seconds=1, max_seconds=30),
)
courier_accepted_at = factory.LazyAttribute(
lambda obj: obj.courier_notified_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
utilization = None
# Pickup-related columns
reached_pickup_at = factory.LazyAttribute(
lambda obj: obj.restaurant_ready_at
+ utils.random_timespan(min_seconds=1, max_seconds=60),
)
pickup_at = factory.LazyAttribute(
lambda obj: obj.reached_pickup_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
left_pickup_at = factory.LazyAttribute(
lambda obj: obj.pickup_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
# Delivery-related columns
reached_delivery_at = factory.LazyAttribute(
lambda obj: obj.left_pickup_at
+ utils.random_timespan(min_minutes=5, max_minutes=10),
)
delivery_at = factory.LazyAttribute(
lambda obj: obj.reached_delivery_at
+ utils.random_timespan(min_seconds=30, max_seconds=60),
)
@factory.post_generation
def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
"""Discard timestamps that occur after cancellation."""
if obj.cancelled_at:
if obj.cancelled_at <= obj.restaurant_notified_at:
obj.restaurant_notified_at = None
if obj.cancelled_at <= obj.restaurant_confirmed_at:
obj.restaurant_confirmed_at = None
if obj.cancelled_at <= obj.restaurant_ready_at:
obj.restaurant_ready_at = None
if obj.cancelled_at <= obj.dispatch_at:
obj.dispatch_at = None
if obj.cancelled_at <= obj.courier_notified_at:
obj.courier_notified_at = None
if obj.cancelled_at <= obj.courier_accepted_at:
obj.courier_accepted_at = None
if obj.cancelled_at <= obj.reached_pickup_at:
obj.reached_pickup_at = None
if obj.cancelled_at <= obj.pickup_at:
obj.pickup_at = None
if obj.cancelled_at <= obj.left_pickup_at:
obj.left_pickup_at = None
if obj.cancelled_at <= obj.reached_delivery_at:
obj.reached_delivery_at = None
if obj.cancelled_at <= obj.delivery_at:
obj.delivery_at = None

View file

@ -103,3 +103,41 @@ def make_order(make_address, make_courier, make_customer, make_restaurant):
)
return func
@pytest.fixture
def make_replay_order(make_order, simulation):
"""Replaces `ReplayedOrderFactory.build()`: Create a `ReplayedOrder`."""
# Reset the identifiers before every test.
factories.ReplayedOrderFactory.reset_sequence(1)
def func(scheduled=False, order=None, **kwargs):
"""Create a new `ReplayedOrder` object.
Each `ReplayOrder` is made by a new `Customer` with a unique `Address`.
Args:
scheduled: if an `Order` is a pre-order
order: the `Order` that is replayed
kwargs: keyword arguments forwarded to the `ReplayedOrderFactory`
Returns:
order
Raises:
RuntimeError: if `scheduled=True` is passed in
together with an ad-hoc `order`
"""
if scheduled:
if order and order.ad_hoc is False:
raise RuntimeError('`order` must be scheduled')
elif order is None:
order = make_order(scheduled=True)
elif order is None:
order = make_order()
return factories.ReplayedOrderFactory.build(
simulation=simulation, actual=order, courier=order.courier, **kwargs,
)
return func

View file

@ -54,10 +54,22 @@ def restaurant(address, make_restaurant):
@pytest.fixture
def order(make_order, restaurant):
"""An `Order` object for the `restaurant`."""
"""An ad-hoc `Order` object for the `restaurant`."""
return make_order(restaurant=restaurant)
@pytest.fixture
def pre_order(make_order, restaurant):
"""A scheduled `Order` object for the `restaurant`."""
return make_order(restaurant=restaurant, scheduled=True)
@pytest.fixture
def replayed_order(make_replay_order, order):
"""A `ReplayedOrder` object for the `restaurant`."""
return make_replay_order(order=order)
@pytest.fixture
def grid(city):
"""A `Grid` with a pixel area of 1 square kilometer."""
@ -68,3 +80,20 @@ def grid(city):
def pixel(grid):
"""The `Pixel` in the lower-left corner of the `grid`."""
return db.Pixel(id=1, grid=grid, n_x=0, n_y=0)
@pytest.fixture
def simulation_data(city):
"""The data for the one and only `ReplaySimulation` object as a `dict`."""
return {
'id': 1,
'city': city,
'strategy': 'best_possible_routing',
'run': 0,
}
@pytest.fixture
def simulation(simulation_data):
"""The one and only `ReplaySimulation` object."""
return db.ReplaySimulation(**simulation_data)