diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py index ad813b7..9d8c924 100644 --- a/src/urban_meal_delivery/configuration.py +++ b/src/urban_meal_delivery/configuration.py @@ -48,8 +48,9 @@ class Config: # individual orders into time series. TIME_STEPS = [60] - # Training horizons (in full weeks) used - # to train the forecasting models. + # Training horizons (in full weeks) used to train the forecasting models. + # For now, we only use 8 weeks as that was the best performing in + # a previous study (note:4f79e8fa). TRAINING_HORIZONS = [8] # The demand forecasting methods used in the simulations. diff --git a/src/urban_meal_delivery/forecasts/models/__init__.py b/src/urban_meal_delivery/forecasts/models/__init__.py index 9d33f71..391efcf 100644 --- a/src/urban_meal_delivery/forecasts/models/__init__.py +++ b/src/urban_meal_delivery/forecasts/models/__init__.py @@ -29,6 +29,7 @@ A future `planning` sub-package will contain the `*Model`s used to plan the `Courier`'s shifts a week ahead. """ # noqa:RST215 +from urban_meal_delivery.forecasts.models.base import ForecastingModelABC from urban_meal_delivery.forecasts.models.tactical.horizontal import HorizontalETSModel from urban_meal_delivery.forecasts.models.tactical.realtime import RealtimeARIMAModel from urban_meal_delivery.forecasts.models.tactical.vertical import VerticalARIMAModel diff --git a/src/urban_meal_delivery/forecasts/timify.py b/src/urban_meal_delivery/forecasts/timify.py index c5ecdb2..92674f7 100644 --- a/src/urban_meal_delivery/forecasts/timify.py +++ b/src/urban_meal_delivery/forecasts/timify.py @@ -1,5 +1,7 @@ """Obtain and work with time series data.""" +from __future__ import annotations + import datetime as dt from typing import Tuple @@ -7,6 +9,7 @@ import pandas as pd from urban_meal_delivery import config from urban_meal_delivery import db +from urban_meal_delivery.forecasts import models class OrderHistory: @@ -502,3 +505,53 @@ class OrderHistory: n_days = (last_day - first_day).days + 1 return round(training_ts.sum() / n_days, 1) + + def choose_tactical_model( + self, pixel_id: int, predict_day: dt.date, train_horizon: int, + ) -> models.ForecastingModelABC: + """Choose the most promising forecasting `*Model` for tactical purposes. + + The rules are deduced from "Table 1: Top-3 models by ..." in the article + "Real-time demand forecasting for an urban delivery platform", the first + research paper published for this `urban-meal-delivery` project. + + According to the research findings in the article "Real-time demand + forecasting for an urban delivery platform", the best model is a function + of the average daily demand (ADD) and the length of the training horizon. + + For the paper check: + https://github.com/webartifex/urban-meal-delivery-demand-forecasting/blob/main/paper.pdf + https://www.sciencedirect.com/science/article/pii/S1366554520307936 + + Args: + pixel_id: pixel for which a forecasting `*Model` is chosen + predict_day: day for which demand is to be predicted with the `*Model` + train_horizon: time horizon available for training the `*Model` + + Returns: + most promising forecasting `*Model` + + # noqa:DAR401 RuntimeError + """ # noqa:RST215 + add = self.avg_daily_demand( + pixel_id=pixel_id, predict_day=predict_day, train_horizon=train_horizon, + ) + + # For now, we only make forecasts with 8 weeks + # as the training horizon (note:4f79e8fa). + if train_horizon == 8: + if add >= 25: # = "high demand" + return models.HorizontalETSModel(order_history=self) + elif add >= 10: # = "medium demand" + return models.HorizontalETSModel(order_history=self) + elif add >= 2.5: # = "low demand" + # TODO: create HorizontalSMAModel + return models.HorizontalETSModel(order_history=self) + + # = "no demand" + # TODO: create HorizontalTrivialModel + return models.HorizontalETSModel(order_history=self) + + raise RuntimeError( + 'no rule for the given average daily demand and training horizon', + ) diff --git a/tests/config.py b/tests/config.py index 7b1ec29..2af0d60 100644 --- a/tests/config.py +++ b/tests/config.py @@ -1,6 +1,6 @@ """Globals used when testing.""" -import datetime +import datetime as dt from urban_meal_delivery import config @@ -11,10 +11,11 @@ YEAR, MONTH, DAY = 2016, 7, 1 # The hour when most test cases take place. NOON = 12 -# `START` and `END` constitute a 22-day time span. -# That implies a maximum `train_horizon` of `3` as that needs full 7-day weeks. -START = datetime.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 0) -END = datetime.datetime(YEAR, MONTH, DAY + 21, config.SERVICE_END, 0) +# `START` and `END` constitute a 57-day time span, 8 full weeks plus 1 day. +# That implies a maximum `train_horizon` of `8` as that needs full 7-day weeks. +START = dt.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 0) +_end = START + dt.timedelta(days=56) # `56` as `START` is not included +END = dt.datetime(_end.year, _end.month, _end.day, config.SERVICE_END, 0) # Default time steps (in minutes), for example, for `OrderHistory` objects. LONG_TIME_STEP = 60 @@ -28,6 +29,6 @@ VERTICAL_FREQUENCY_SHORT = 7 * 24 # Default training horizons, for example, for # `OrderHistory.make_horizontal_time_series()`. -LONG_TRAIN_HORIZON = 3 +LONG_TRAIN_HORIZON = 8 SHORT_TRAIN_HORIZON = 2 TRAIN_HORIZONS = (SHORT_TRAIN_HORIZON, LONG_TRAIN_HORIZON) diff --git a/tests/forecasts/timify/test_avg_daily_demand.py b/tests/forecasts/timify/test_avg_daily_demand.py index f8e2bb4..c8ab66f 100644 --- a/tests/forecasts/timify/test_avg_daily_demand.py +++ b/tests/forecasts/timify/test_avg_daily_demand.py @@ -1,37 +1,145 @@ -"""Tests for the `OrderHistory.avg_daily_demand()` method.""" +"""Tests for the `OrderHistory.avg_daily_demand()` and ... + +`OrderHistory.choose_tactical_model()` methods. + +We test both methods together as they take the same input and are really +two parts of the same conceptual step. +""" + +import pytest from tests import config as test_config +from urban_meal_delivery.forecasts import models -def test_avg_daily_demand_with_constant_demand( - order_history, good_pixel_id, predict_at, -): - """The average daily demand must be the number of time steps ... +class TestAverageDailyDemand: + """Tests for the `OrderHistory.avg_daily_demand()` method.""" - ... if the demand is `1` at each time step. + def test_avg_daily_demand_with_constant_demand( + self, order_history, good_pixel_id, predict_at, + ): + """The average daily demand must be the number of time steps ... - Note: The `order_history` fixture assumes `12` time steps per day as it - uses `LONG_TIME_STEP=60` as the length of a time step. - """ - result = order_history.avg_daily_demand( - pixel_id=good_pixel_id, - predict_day=predict_at.date(), - train_horizon=test_config.LONG_TRAIN_HORIZON, - ) + ... if the demand is `1` at each time step. - assert result == 12.0 + Note: The `order_history` fixture assumes `12` time steps per day as it + uses `LONG_TIME_STEP=60` as the length of a time step. + """ + result = order_history.avg_daily_demand( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + assert result == 12.0 + + def test_avg_daily_demand_with_no_demand( + self, order_history, good_pixel_id, predict_at, + ): + """Without demand, the average daily demand must be `0.0`.""" + order_history._data.loc[:, 'n_orders'] = 0 + + result = order_history.avg_daily_demand( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + assert result == 0.0 -def test_avg_daily_demand_with_no_demand( - order_history, good_pixel_id, predict_at, -): - """Without demand, the average daily demand must be `0.0`.""" - order_history._data.loc[:, 'n_orders'] = 0 +class TestChooseTacticalModel: + """Tests for the `OrderHistory.choose_tactical_model()` method.""" - result = order_history.avg_daily_demand( - pixel_id=good_pixel_id, - predict_day=predict_at.date(), - train_horizon=test_config.LONG_TRAIN_HORIZON, - ) + def test_best_model_with_high_demand( + self, order_history, good_pixel_id, predict_at, + ): + """With high demand, the average daily demand is `.>= 25.0`.""" + # With 12 time steps per day, the ADD becomes `36.0`. + order_history._data.loc[:, 'n_orders'] = 3 - assert result == 0.0 + result = order_history.choose_tactical_model( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + assert isinstance(result, models.HorizontalETSModel) + + def test_best_model_with_medium_demand( + self, order_history, good_pixel_id, predict_at, + ): + """With medium demand, the average daily demand is `>= 10.0` and `< 25.0`.""" + # With 12 time steps per day, the ADD becomes `24.0`. + order_history._data.loc[:, 'n_orders'] = 2 + + result = order_history.choose_tactical_model( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + assert isinstance(result, models.HorizontalETSModel) + + def test_best_model_with_low_demand( + self, order_history, good_pixel_id, predict_at, + ): + """With low demand, the average daily demand is `>= 2.5` and `< 10.0`.""" + # With 12 time steps per day, the ADD becomes `12.0` ... + data = order_history._data + data.loc[:, 'n_orders'] = 1 + + # ... and we set three additional time steps per day to `0`. + data.loc[ # noqa:ECE001 + # all `Pixel`s, all `Order`s in time steps starting at 11 am + (slice(None), slice(data.index.levels[1][0], None, 12)), + 'n_orders', + ] = 0 + data.loc[ # noqa:ECE001 + # all `Pixel`s, all `Order`s in time steps starting at 12 am + (slice(None), slice(data.index.levels[1][1], None, 12)), + 'n_orders', + ] = 0 + data.loc[ # noqa:ECE001 + # all `Pixel`s, all `Order`s in time steps starting at 1 pm + (slice(None), slice(data.index.levels[1][2], None, 12)), + 'n_orders', + ] = 0 + + result = order_history.choose_tactical_model( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + # TODO: this should be the future `HorizontalSMAModel`. + assert isinstance(result, models.HorizontalETSModel) + + def test_best_model_with_no_demand( + self, order_history, good_pixel_id, predict_at, + ): + """Without demand, the average daily demand is `< 2.5`.""" + order_history._data.loc[:, 'n_orders'] = 0 + + result = order_history.choose_tactical_model( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.LONG_TRAIN_HORIZON, + ) + + # TODO: this should be the future `HorizontalTrivialModel`. + assert isinstance(result, models.HorizontalETSModel) + + def test_best_model_for_unknown_train_horizon( + self, order_history, good_pixel_id, predict_at, # noqa:RST215 + ): + """For `train_horizon`s not included in the rule-based system ... + + ... the method raises a `RuntimeError`. + """ + with pytest.raises(RuntimeError, match='no rule'): + order_history.choose_tactical_model( + pixel_id=good_pixel_id, + predict_day=predict_at.date(), + train_horizon=test_config.SHORT_TRAIN_HORIZON, + ) diff --git a/tests/forecasts/timify/test_make_time_series.py b/tests/forecasts/timify/test_make_time_series.py index c47c14a..790eec6 100644 --- a/tests/forecasts/timify/test_make_time_series.py +++ b/tests/forecasts/timify/test_make_time_series.py @@ -36,7 +36,7 @@ def bad_predict_at(): ... not a long enough history so that both `SHORT_TRAIN_HORIZON` and `LONG_TRAIN_HORIZON` do not work. """ - predict_day = test_config.END - datetime.timedelta(weeks=2, days=1) + predict_day = test_config.END - datetime.timedelta(weeks=6, days=1) return datetime.datetime( predict_day.year, predict_day.month, predict_day.day, test_config.NOON, 0, )