From 50b35a828479fdda1293f547b2acf1eeea8fe302 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Thu, 4 Feb 2021 12:05:43 +0100 Subject: [PATCH] Add CLI script to run tactical forecasting heuristic --- setup.cfg | 3 + src/urban_meal_delivery/configuration.py | 2 +- src/urban_meal_delivery/console/__init__.py | 2 + src/urban_meal_delivery/console/forecasts.py | 144 +++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/urban_meal_delivery/console/forecasts.py diff --git a/setup.cfg b/setup.cfg index d9a8249..47924d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -141,6 +141,9 @@ per-file-ignores = src/urban_meal_delivery/configuration.py: # Allow upper case class variables within classes. WPS115, + src/urban_meal_delivery/console/forecasts.py: + # The module is not too complex. + WPS232, src/urban_meal_delivery/db/customers.py: # The module is not too complex. WPS232, diff --git a/src/urban_meal_delivery/configuration.py b/src/urban_meal_delivery/configuration.py index 9d8c924..8e43cf5 100644 --- a/src/urban_meal_delivery/configuration.py +++ b/src/urban_meal_delivery/configuration.py @@ -51,7 +51,7 @@ class Config: # 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] + TRAIN_HORIZONS = [8] # The demand forecasting methods used in the simulations. FORECASTING_METHODS = ['hets', 'rtarima'] diff --git a/src/urban_meal_delivery/console/__init__.py b/src/urban_meal_delivery/console/__init__.py index 60ac801..baa089c 100644 --- a/src/urban_meal_delivery/console/__init__.py +++ b/src/urban_meal_delivery/console/__init__.py @@ -1,9 +1,11 @@ """Provide CLI scripts for the project.""" +from urban_meal_delivery.console import forecasts from urban_meal_delivery.console import gridify from urban_meal_delivery.console import main cli = main.entry_point +cli.add_command(forecasts.tactical_heuristic, name='tactical-forecasts') cli.add_command(gridify.gridify) diff --git a/src/urban_meal_delivery/console/forecasts.py b/src/urban_meal_delivery/console/forecasts.py new file mode 100644 index 0000000..1a7bcf9 --- /dev/null +++ b/src/urban_meal_delivery/console/forecasts.py @@ -0,0 +1,144 @@ +"""CLI script to forecast demand. + +The main purpose of this script is to pre-populate the `db.Forecast` table +with demand predictions such that they can readily be used by the +predictive routing algorithms. +""" + +import datetime as dt +import sys + +import click +from sqlalchemy import func +from sqlalchemy.orm import exc as orm_exc + +from urban_meal_delivery import config +from urban_meal_delivery import db +from urban_meal_delivery.console import decorators +from urban_meal_delivery.forecasts import timify + + +@click.command() +@click.argument('city', default='Paris', type=str) +@click.argument('side_length', default=1000, type=int) +@click.argument('time_step', default=60, type=int) +@click.argument('train_horizon', default=8, type=int) +@decorators.db_revision('8bfb928a31f8') +def tactical_heuristic( # noqa:C901,WPS213,WPS216,WPS231 + city: str, side_length: int, time_step: int, train_horizon: int, +) -> None: # pragma: no cover + """Predict demand for all pixels and days in a city. + + This command makes demand `Forecast`s for all `Pixel`s and days + for tactical purposes with the heuristic specified in + `urban_meal_delivery.forecasts.timify.OrderHistory.choose_tactical_model()`. + + According to this heuristic, there is exactly one `Forecast` per + `Pixel` and time step (e.g., hour of the day with 60-minute time steps) + given the lengths of the training horizon and a time step. That is so + as the heuristic chooses the most promising forecasting `*Model`. + + All `Forecast`s are persisted to the database so that they can be readily + used by the predictive routing algorithms. + + This command first checks, which `Forecast`s still need to be made + and then does its work. So, it can be interrupted at any point in + time and then simply continues where it left off the next time it + is executed. + + Important: In a future revision, this command may need to be adapted such + that is does not simply obtain the last time step for which a `Forecast` + was made and continues from there. The reason is that another future command + may make predictions using all available forecasting `*Model`s per `Pixel` + and time step. + + Arguments: + + CITY: one of "Bordeaux", "Lyon", or "Paris" (=default) + + SIDE_LENGTH: of a pixel in the grid; defaults to `1000` + + TIME_STEP: length of one time step in minutes; defaults to `60` + + TRAIN_HORIZON: length of the training horizon; defaults to `8` + """ # noqa:D412,D417,RST215 + # Input validation. + + try: + city_obj = ( + db.session.query(db.City).filter_by(name=city.title()).one() # noqa:WPS221 + ) + except orm_exc.NoResultFound: + click.echo('NAME must be one of "Paris", "Lyon", or "Bordeaux"') + sys.exit(1) + + for grid in city_obj.grids: + if grid.side_length == side_length: + break + else: + click.echo(f'SIDE_LENGTH must be in {config.GRID_SIDE_LENGTHS}') + sys.exit(1) + + if time_step not in config.TIME_STEPS: + click.echo(f'TIME_STEP must be in {config.TIME_STEPS}') + sys.exit(1) + + if train_horizon not in config.TRAIN_HORIZONS: + click.echo(f'TRAIN_HORIZON must be in {config.TRAIN_HORIZONS}') + sys.exit(1) + + click.echo( + 'Parameters: ' + + f'city="{city}", grid.side_length={side_length}, ' + + f'time_step={time_step}, train_horizon={train_horizon}', + ) + + # Load the historic order data. + order_history = timify.OrderHistory(grid=grid, time_step=time_step) # noqa:WPS441 + order_history.aggregate_orders() + + # Run the tactical heuristic. + + for pixel in grid.pixels: # noqa:WPS441 + # Important: this check may need to be adapted once further + # commands are added the make `Forecast`s without the heuristic! + # Continue with forecasting on the day the last prediction was made ... + last_predict_at = ( # noqa:ECE001 + db.session.query(func.max(db.Forecast.start_at)) + .filter(db.Forecast.pixel == pixel) + .first() + )[0] + # ... or start `train_horizon` weeks after the first `Order` + # if no `Forecast`s are in the database yet. + if last_predict_at is None: + predict_day = order_history.first_order_at(pixel_id=pixel.id).date() + predict_day += dt.timedelta(weeks=train_horizon) + else: + predict_day = last_predict_at.date() + + # Go over all days in chronological order ... + while predict_day <= order_history.last_order_at(pixel_id=pixel.id).date(): + # ... and choose the most promising `*Model` for that day. + model = order_history.choose_tactical_model( + pixel_id=pixel.id, predict_day=predict_day, train_horizon=train_horizon, + ) + click.echo( + f'Predicting pixel #{pixel.id} in {city} ' + + f'for {predict_day} with {model.name}', + ) + + # Only loop over the time steps corresponding to working hours. + predict_at = dt.datetime( + predict_day.year, + predict_day.month, + predict_day.day, + config.SERVICE_START, + ) + while predict_at.hour < config.SERVICE_END: + model.make_forecast( + pixel=pixel, predict_at=predict_at, train_horizon=train_horizon, + ) + + predict_at += dt.timedelta(minutes=time_step) + + predict_day += dt.timedelta(days=1)