""" 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