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:
|
src/urban_meal_delivery/configuration.py:
|
||||||
# Allow upper case class variables within classes.
|
# Allow upper case class variables within classes.
|
||||||
WPS115,
|
WPS115,
|
||||||
|
src/urban_meal_delivery/console/forecasts.py:
|
||||||
|
# The module is not too complex.
|
||||||
|
WPS232,
|
||||||
src/urban_meal_delivery/db/customers.py:
|
src/urban_meal_delivery/db/customers.py:
|
||||||
# The module is not too complex.
|
# The module is not too complex.
|
||||||
WPS232,
|
WPS232,
|
||||||
|
|
|
@ -51,7 +51,7 @@ class Config:
|
||||||
# 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
|
# For now, we only use 8 weeks as that was the best performing in
|
||||||
# a previous study (note:4f79e8fa).
|
# a previous study (note:4f79e8fa).
|
||||||
TRAINING_HORIZONS = [8]
|
TRAIN_HORIZONS = [8]
|
||||||
|
|
||||||
# The demand forecasting methods used in the simulations.
|
# The demand forecasting methods used in the simulations.
|
||||||
FORECASTING_METHODS = ['hets', 'rtarima']
|
FORECASTING_METHODS = ['hets', 'rtarima']
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
"""Provide CLI scripts for the project."""
|
"""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 gridify
|
||||||
from urban_meal_delivery.console import main
|
from urban_meal_delivery.console import main
|
||||||
|
|
||||||
|
|
||||||
cli = main.entry_point
|
cli = main.entry_point
|
||||||
|
|
||||||
|
cli.add_command(forecasts.tactical_heuristic, name='tactical-forecasts')
|
||||||
cli.add_command(gridify.gridify)
|
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