149 lines
5.7 KiB
Python
149 lines
5.7 KiB
Python
"""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)) # noqa:WPS221
|
|
.join(db.Pixel, db.Forecast.pixel_id == db.Pixel.id)
|
|
.join(db.Grid, db.Pixel.grid_id == db.Grid.id)
|
|
.filter(db.Forecast.pixel == pixel)
|
|
.filter(db.Grid.side_length == side_length)
|
|
.filter(db.Forecast.time_step == time_step)
|
|
.filter(db.Forecast.train_horizon == train_horizon)
|
|
.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)
|