From cb7611d58763bb4885a479db871232655c031d28 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Mon, 1 Feb 2021 21:48:28 +0100 Subject: [PATCH] Add `OrderHistory.avg_daily_demand()` - the method calculates the number of daily `Order`s in a `Pixel` withing the `train_horizon` preceding the `predict_day` --- src/urban_meal_delivery/forecasts/timify.py | 35 ++++++++++++++++++ tests/forecasts/conftest.py | 11 ++++++ tests/forecasts/test_models.py | 11 ------ .../forecasts/timify/test_avg_daily_demand.py | 37 +++++++++++++++++++ 4 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 tests/forecasts/timify/test_avg_daily_demand.py diff --git a/src/urban_meal_delivery/forecasts/timify.py b/src/urban_meal_delivery/forecasts/timify.py index 4f85dfe..c5ecdb2 100644 --- a/src/urban_meal_delivery/forecasts/timify.py +++ b/src/urban_meal_delivery/forecasts/timify.py @@ -467,3 +467,38 @@ class OrderHistory: raise LookupError('`predict_at` is not in the order history') return training_ts, frequency, actuals_ts + + def avg_daily_demand( + self, pixel_id: int, predict_day: dt.date, train_horizon: int, + ) -> float: + """Calculate the average daily demand (ADD) for a `Pixel`. + + The ADD is defined as the average number of daily `Order`s in a + `Pixel` within the training horizon preceding the `predict_day`. + + The ADD is primarily used for the rule-based heuristic to determine + the best forecasting model for a `Pixel` on the `predict_day`. + + Implementation note: To calculate the ADD, the order counts are + generated as a vertical time series. That must be so as we need to + include all time steps of the days before the `predict_day` and + no time step of the latter. + + Args: + pixel_id: pixel for which the ADD is calculated + predict_day: following the `train_horizon` on which the ADD is calculated + train_horizon: time horizon over which the ADD is calculated + + Returns: + average number of orders per day + """ + training_ts, _, _ = self.make_vertical_ts( # noqa:WPS434 + pixel_id=pixel_id, predict_day=predict_day, train_horizon=train_horizon, + ) + + first_day = training_ts.index.min().date() + last_day = training_ts.index.max().date() + # `+1` as both `first_day` and `last_day` are included. + n_days = (last_day - first_day).days + 1 + + return round(training_ts.sum() / n_days, 1) diff --git a/tests/forecasts/conftest.py b/tests/forecasts/conftest.py index 527b5b9..f258a3c 100644 --- a/tests/forecasts/conftest.py +++ b/tests/forecasts/conftest.py @@ -82,6 +82,17 @@ def good_pixel_id(pixel): return pixel.id # `== 1` +@pytest.fixture +def predict_at() -> dt.datetime: + """`NOON` on the day to be predicted.""" + return dt.datetime( + test_config.END.year, + test_config.END.month, + test_config.END.day, + test_config.NOON, + ) + + @pytest.fixture def order_totals(good_pixel_id): """A mock for `OrderHistory.totals`. diff --git a/tests/forecasts/test_models.py b/tests/forecasts/test_models.py index c4b8a91..2ce04b4 100644 --- a/tests/forecasts/test_models.py +++ b/tests/forecasts/test_models.py @@ -1,6 +1,5 @@ """Tests for the `urban_meal_delivery.forecasts.models` sub-package.""" -import datetime as dt import pandas as pd import pytest @@ -59,16 +58,6 @@ class TestGenericForecastingModelProperties: self.unique_model_names.add(model.name) - @pytest.fixture - def predict_at(self) -> dt.datetime: - """`NOON` on the day to be predicted.""" - return dt.datetime( - test_config.END.year, - test_config.END.month, - test_config.END.day, - test_config.NOON, - ) - @pytest.mark.r def test_make_prediction_structure( self, model_cls, order_history, pixel, predict_at, diff --git a/tests/forecasts/timify/test_avg_daily_demand.py b/tests/forecasts/timify/test_avg_daily_demand.py new file mode 100644 index 0000000..f8e2bb4 --- /dev/null +++ b/tests/forecasts/timify/test_avg_daily_demand.py @@ -0,0 +1,37 @@ +"""Tests for the `OrderHistory.avg_daily_demand()` method.""" + +from tests import config as test_config + + +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 ... + + ... if the demand is `1` at each time step. + + 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( + 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