144 lines
5.8 KiB
Python
144 lines
5.8 KiB
Python
"""
|
|
Empirical ETA Calculator
|
|
========================
|
|
|
|
Drop-in replacement for `RealisticETACalculator` that prefers ETAs *learned
|
|
from actual delivery times* (see delivery_history_service.py) and falls back to
|
|
the original formula whenever history is too thin.
|
|
|
|
Design goals
|
|
------------
|
|
* **Interface-compatible**: `calculate_eta(...)` keeps the same positional args
|
|
and `int`-minutes return as the formula calculator, so existing call sites
|
|
work unchanged. Extra args (`kitchen`, `drop_coords`, `rider_id`) are optional
|
|
and let callers that *have* that context (the optimizer does) get sharper,
|
|
zone-aware estimates.
|
|
* **Safe by default**: if empirical data is missing for a context, or the
|
|
feature is disabled via `eta_empirical_enabled`, it returns exactly what the
|
|
formula would — zero behavior change until real data exists.
|
|
* **Non-blocking**: never calls Postgres on the hot path. If the stats cache is
|
|
empty it kicks off a one-shot background refresh and serves the formula
|
|
meanwhile.
|
|
|
|
Empirical values are real door-to-door leg times (gap between consecutive
|
|
deliveries), so they already include travel + drop service time. We only add the
|
|
kitchen pickup buffer for the first leg, mirroring the formula's semantics.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, List, Optional, Tuple
|
|
|
|
from app.services.routing.realistic_eta_calculator import (
|
|
RealisticETACalculator,
|
|
get_time_of_day_category,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class EmpiricalETACalculator:
|
|
"""ETA calculator backed by empirical history, with formula fallback."""
|
|
|
|
def __init__(self):
|
|
# Composed formula calculator — the fallback and the source of buffers.
|
|
self.formula = RealisticETACalculator()
|
|
|
|
# ------------------------------------------------------------------
|
|
# Main entry point (signature is a superset of RealisticETACalculator)
|
|
# ------------------------------------------------------------------
|
|
|
|
def calculate_eta(
|
|
self,
|
|
distance_km: float,
|
|
is_first_order: bool = False,
|
|
order_type: str = "Economy",
|
|
time_of_day: str = "peak",
|
|
kitchen: Optional[str] = None,
|
|
drop_coords: Optional[Tuple[float, float]] = None,
|
|
rider_id: Optional[Any] = None,
|
|
) -> int:
|
|
"""Return ETA in minutes — empirical if available, else the formula."""
|
|
if distance_km is not None and distance_km <= 0 and not is_first_order:
|
|
return 0
|
|
|
|
from app.config.dynamic_config import get_config
|
|
cfg = get_config()
|
|
|
|
if not bool(cfg.get("eta_empirical_enabled", True)):
|
|
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
|
|
|
try:
|
|
from app.services.routing.delivery_history_service import (
|
|
get_delivery_history_service,
|
|
)
|
|
svc = get_delivery_history_service()
|
|
|
|
if not svc.has_data():
|
|
# Populate in the background; serve the formula for now.
|
|
svc.maybe_background_refresh(
|
|
days=int(cfg.get("eta_history_days", 14)),
|
|
)
|
|
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
|
|
|
hit = svc.lookup(
|
|
distance_km=float(distance_km or 0.0),
|
|
traffic_cat=time_of_day,
|
|
kitchen=kitchen,
|
|
drop_coords=drop_coords,
|
|
min_samples=int(cfg.get("eta_min_samples", 20)),
|
|
stat=str(cfg.get("eta_stat", "median")),
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"[EmpiricalETA] lookup failed, using formula: {e}")
|
|
hit = None
|
|
|
|
if not hit:
|
|
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
|
|
|
value = float(hit["value_min"])
|
|
# First leg includes time spent picking up at the kitchen; the empirical
|
|
# leg gap starts at pickup completion, so add the same buffer the formula uses.
|
|
if is_first_order:
|
|
value += float(cfg.get("eta_pickup_time_min", 3.0))
|
|
return int(value) + 1 # round up for safety, matching the formula
|
|
|
|
def _formula_eta(self, distance_km, is_first_order, order_type, time_of_day) -> int:
|
|
return self.formula.calculate_eta(
|
|
distance_km=distance_km,
|
|
is_first_order=is_first_order,
|
|
order_type=order_type,
|
|
time_of_day=time_of_day,
|
|
)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Batch helper (kept compatible with RealisticETACalculator)
|
|
# ------------------------------------------------------------------
|
|
|
|
def calculate_batch_eta(self, orders: List[dict]) -> List[dict]:
|
|
"""Calculate ETAs for a batch in sequence (formula-parity batch path)."""
|
|
traffic = get_time_of_day_category()
|
|
for order in orders:
|
|
distance_km = float(order.get("previouskms", 0) or 0)
|
|
step = order.get("step", 1)
|
|
order_type = order.get("ordertype", "Economy")
|
|
drop = None
|
|
try:
|
|
dlat = float(order.get("deliverylat") or order.get("droplat") or 0)
|
|
dlon = float(order.get("deliverylong") or order.get("droplon") or 0)
|
|
if dlat and dlon:
|
|
drop = (dlat, dlon)
|
|
except (TypeError, ValueError):
|
|
drop = None
|
|
eta = self.calculate_eta(
|
|
distance_km=distance_km,
|
|
is_first_order=(step == 1),
|
|
order_type=order_type,
|
|
time_of_day=traffic,
|
|
kitchen=order.get("pickupcustomer") or order.get("locationname"),
|
|
drop_coords=drop,
|
|
rider_id=order.get("userid"),
|
|
)
|
|
order["eta"] = str(eta)
|
|
order["eta_empirical"] = True
|
|
return orders
|