Initial commit
This commit is contained in:
143
app/services/routing/empirical_eta_calculator.py
Normal file
143
app/services/routing/empirical_eta_calculator.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user