Files
routesapi/app/services/routing/empirical_eta_calculator.py
2026-06-22 17:40:08 +05:30

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