Add CLI script to run tactical forecasting heuristic
This commit is contained in:
parent
23391c2fa4
commit
50b35a8284
4 changed files with 150 additions and 1 deletions
|
@ -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,
|
||||
|
|
|
@ -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']
|
||||
|
|
|
@ -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)
|
||||
|
|
144
src/urban_meal_delivery/console/forecasts.py
Normal file
144
src/urban_meal_delivery/console/forecasts.py
Normal file
|
@ -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)
|
Loading…
Reference in a new issue