""" Delivery History Service — Empirical ETA from ground truth ========================================================== WHY THIS EXISTS --------------- Until now every ETA in the system was a *formula guess* (`RealisticETACalculator`: distance / configured_speed + fixed buffers) that was never checked against what actually happened in the field. Senior feedback was that manual riders deliver faster than our estimates — i.e. the guess is wrong. This service closes the loop. The external `nearledb` Postgres already records, for every completed delivery, when the rider picked up (`pickuptime`) and when the order was delivered (`deliverytime`). From those we reconstruct **actual per-leg travel times** and learn empirical medians, so ETAs reflect reality instead of a hand-tuned formula. GROUND-TRUTH RECONSTRUCTION (per-leg, matches the optimizer) ----------------------------------------------------------- The optimizer feeds *aerial* (straight-line) leg distance to `calculate_eta` (see route_optimizer.py — `step_dist` comes from `aerial_matrix`, no road factor). To learn a model that plugs in behind the same call, we reconstruct observations the same way: * Group a rider's completed deliveries by day, ordered by `deliverytime`. * For each pair of consecutive deliveries: leg_min = deliverytime[i] - deliverytime[i-1] (real door-to-door time) leg_km = aerial haversine(drop[i-1], drop[i]) (same metric as optimizer) * The first delivery of a group is the kitchen→first-drop leg: leg_min = deliverytime[0] - pickuptime[0] (distance unknown w/o kitchen coords, so it only feeds the non-distance keys). Only `droplat/droplon`, `pickuptime`, `deliverytime`, `pickupcustomer`, `userid` are needed — all confirmed-present columns (same set batch_analytics reads). AGGREGATION + LOOKUP (hierarchical, cold-start safe) ---------------------------------------------------- Each observation feeds several keys at decreasing specificity. At prediction time we walk the same hierarchy and use the first key with enough samples, falling back to the formula when history is too thin: kzd kitchen | drop_zone | traffic | dist_bucket (most specific) kz kitchen | drop_zone | traffic zd drop_zone | traffic | dist_bucket z drop_zone | traffic dt dist_bucket | traffic t traffic (least specific) → RealisticETACalculator formula (no data) `rkz` (rider | kitchen | zone | traffic) is also stored for Phase-2 learned rider affinity; it is not used by the default lookup yet. """ import logging import math import os import sqlite3 import threading import statistics import time from datetime import datetime, timedelta from collections import defaultdict from typing import Any, Dict, List, Optional, Tuple from app.services.routing.zone_service import ZoneService logger = logging.getLogger(__name__) _DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db") _WRITE_LOCK = threading.Lock() # Aerial leg-distance buckets (km) — must match the metric the optimizer feeds # to calculate_eta (pure haversine, no road factor). _DIST_BUCKETS: List[Tuple[float, float]] = [ (0.0, 1.0), (1.0, 2.0), (2.0, 3.0), (3.0, 5.0), (5.0, 8.0), (8.0, 12.0), (12.0, 1e9) ] # Sanity filters for reconstructed legs. _MAX_LEG_MIN = 60.0 # gaps longer than this are batch boundaries / idle, not a leg _MAX_LEG_KM = 40.0 # implausible single hop # --------------------------------------------------------------------------- # Pure helpers # --------------------------------------------------------------------------- def dist_bucket(km: Optional[float]) -> Optional[str]: """Aerial leg distance -> discrete bucket label, or None if unknown.""" if km is None: return None for lo, hi in _DIST_BUCKETS: if km <= hi: return f"{lo:g}-{hi:g}" if hi < 1e9 else f"{lo:g}+" return f"{_DIST_BUCKETS[-1][0]:g}+" def hour_to_traffic(hour: int) -> str: """Time-of-day -> traffic category, mirroring get_time_of_day_category().""" if (8 <= hour < 10) or (12 <= hour < 14) or (17 <= hour < 20): return "peak" if hour < 7 or hour >= 22: return "light" return "normal" def normalize_kitchen(name: Any) -> str: """Normalize a kitchen / pickup-customer name to a stable key token.""" if not name: return "?" return " ".join(str(name).strip().lower().split()) def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: """Great-circle (aerial) distance in km.""" try: rlat1, rlon1, rlat2, rlon2 = map(math.radians, (lat1, lon1, lat2, lon2)) dlat = rlat2 - rlat1 dlon = rlon2 - rlon1 a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 return 6371.0 * 2 * math.asin(min(1.0, math.sqrt(a))) except Exception: return 0.0 def _parse_dt(val: Any) -> Optional[datetime]: if val in (None, "", 0): return None if isinstance(val, datetime): return val s = str(val).strip() for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M"): try: return datetime.strptime(s.split("+")[0].strip(), fmt) except Exception: continue try: from dateutil.parser import parse as _du return _du(s) except Exception: return None # --------------------------------------------------------------------------- # Shared Postgres connector (single source of truth for nearledb creds) # --------------------------------------------------------------------------- def connect_nearledb(connect_timeout: int = 10): """ Open a connection to the external nearledb Postgres. Credentials come from DB_* env vars (same defaults batch_analytics used). Raises on failure — callers convert to their own error type. """ import psycopg2 # imported lazily so the app boots without it conn = psycopg2.connect( host=os.getenv("DB_HOST", "66.116.207.225"), port=int(os.getenv("DB_PORT", "6432")), dbname=os.getenv("DB_NAME", "nearledb"), user=os.getenv("DB_USER", "admin"), password=os.getenv("DB_PASSWORD", "Package@123#"), connect_timeout=connect_timeout, ) # Best-effort read-only session. (Port 6432 is pgbouncer, which rejects the # `options` startup param, so we set it post-connect; our code only issues # SELECTs regardless — we never write to or alter the source DB.) try: conn.set_session(readonly=True) except Exception: pass return conn # --------------------------------------------------------------------------- # Key construction (build-side and lookup-side share this so they stay in sync) # --------------------------------------------------------------------------- def _keys_for_observation(kitchen: str, zone: str, traffic: str, bucket: Optional[str], rider_id: Any) -> List[str]: """All aggregation keys an observation contributes to.""" keys = [ f"kz|{kitchen}|{zone}|{traffic}", f"z|{zone}|{traffic}", f"t|{traffic}", f"rkz|{rider_id}|{kitchen}|{zone}|{traffic}", ] if bucket is not None: keys += [ f"kzd|{kitchen}|{zone}|{traffic}|{bucket}", f"zd|{zone}|{traffic}|{bucket}", f"dt|{bucket}|{traffic}", ] return keys def _lookup_keys(kitchen: Optional[str], zone: str, traffic: str, bucket: Optional[str]) -> List[str]: """Ordered candidate keys, most specific first, for prediction-time lookup.""" ordered: List[str] = [] if kitchen and bucket is not None: ordered.append(f"kzd|{kitchen}|{zone}|{traffic}|{bucket}") if kitchen: ordered.append(f"kz|{kitchen}|{zone}|{traffic}") if bucket is not None: ordered.append(f"zd|{zone}|{traffic}|{bucket}") ordered.append(f"z|{zone}|{traffic}") if bucket is not None: ordered.append(f"dt|{bucket}|{traffic}") ordered.append(f"t|{traffic}") return ordered # --------------------------------------------------------------------------- # Service # --------------------------------------------------------------------------- class DeliveryHistoryService: """Ingests completed deliveries, learns empirical leg-time medians, serves lookups.""" def __init__(self): self._db_path = _DB_PATH self._zone = ZoneService() # cache: full_key -> (count, median_min, p75_min) self._cache: Dict[str, Tuple[int, float, float]] = {} self._last_refreshed: Optional[datetime] = None self._last_summary: Dict[str, Any] = {} self._refresh_lock = threading.Lock() self._refresh_attempt_at: Optional[datetime] = None self._scheduler_started = False self._ensure_db() self._load_cache() # -- schema ------------------------------------------------------------- def _ensure_db(self) -> None: try: os.makedirs(os.path.dirname(self._db_path) or ".", exist_ok=True) conn = sqlite3.connect(self._db_path) conn.execute(""" CREATE TABLE IF NOT EXISTS delivery_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_ts TEXT, rider_id TEXT, kitchen TEXT, drop_zone TEXT, traffic TEXT, dist_bucket TEXT, leg_km REAL, leg_min REAL, is_first INTEGER DEFAULT 0 ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS eta_stats ( key TEXT PRIMARY KEY, key_type TEXT, sample_count INTEGER, median_min REAL, p75_min REAL, updated_at TEXT ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_eta_stats_type ON eta_stats(key_type)") # Local mirror of the nearledb `deliveries` rows we care about. # The request path NEVER touches Postgres — only this table. # Deduped by deliveryid so incremental syncs are idempotent. conn.execute(""" CREATE TABLE IF NOT EXISTS delivery_raw ( deliveryid TEXT PRIMARY KEY, userid TEXT, pickupcustomer TEXT, pickuptime TEXT, deliverytime TEXT, dlat REAL, dlon REAL, plat REAL, plon REAL ) """) # Migration for stores created before pickup coords were added. for _ddl in ( "ALTER TABLE delivery_raw ADD COLUMN plat REAL", "ALTER TABLE delivery_raw ADD COLUMN plon REAL", ): try: conn.execute(_ddl) except Exception: pass conn.execute("CREATE INDEX IF NOT EXISTS idx_raw_dtime ON delivery_raw(deliverytime)") # Single-row sync watermark / bookkeeping. conn.execute(""" CREATE TABLE IF NOT EXISTS eta_sync_state ( id INTEGER PRIMARY KEY CHECK (id = 1), last_delivery_ts TEXT, last_synced_at TEXT, total_rows INTEGER DEFAULT 0 ) """) conn.execute("INSERT OR IGNORE INTO eta_sync_state (id, total_rows) VALUES (1, 0)") conn.commit() conn.close() except Exception as e: logger.error(f"[DeliveryHistory] DB init failed: {e}") # -- ingestion ---------------------------------------------------------- def fetch_completed_deliveries( self, days: int, tenant_id: int = 916, since: Optional[str] = None ) -> List[Dict[str, Any]]: """ READ-ONLY pull from nearledb `deliveries`. * `since` set -> incremental: only rows newer than the watermark. * `since` None -> initial backfill of the last `days`. Only the `deliveries` table is read; the session is read-only (no writes, no schema changes — enforced at the connection level). """ conn = connect_nearledb() try: cur = conn.cursor() if since: where_time = "d.deliverytime::timestamp > %s" time_param = since else: where_time = "d.deliverytime::timestamp >= %s" time_param = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") cur.execute( f""" SELECT d.deliveryid, d.userid, d.pickupcustomer, d.pickuptime, d.deliverytime, COALESCE(d.droplat, d.deliverylat) AS dlat, COALESCE(d.droplon, d.deliverylong) AS dlon, d.pickuplat AS plat, d.pickuplon AS plon FROM deliveries d WHERE d.tenantid = %s AND d.deliverytime IS NOT NULL AND d.pickuptime IS NOT NULL AND {where_time} AND COALESCE(d.droplat, d.deliverylat) IS NOT NULL AND d.userid IS NOT NULL ORDER BY d.deliverytime """, (tenant_id, time_param), ) cols = [c.name for c in cur.description] rows = [dict(zip(cols, r)) for r in cur.fetchall()] cur.close() return rows finally: conn.close() # -- watermark / local raw store --------------------------------------- def _get_watermark(self) -> Optional[str]: try: conn = sqlite3.connect(self._db_path) row = conn.execute( "SELECT last_delivery_ts FROM eta_sync_state WHERE id = 1" ).fetchone() conn.close() return row[0] if row and row[0] else None except Exception: return None def sync_from_db(self, days: int = 14, tenant_id: int = 916, full: bool = False) -> Dict[str, Any]: """ Pull NEW completed deliveries from nearledb into the local mirror. Idempotent (INSERT OR IGNORE on deliveryid). On the first run (empty watermark) or `full=True`, backfills the last `days`; afterwards only rows newer than the watermark are fetched — minimal DB load. """ watermark = None if full else self._get_watermark() rows = self.fetch_completed_deliveries(days, tenant_id, since=watermark) inserted = 0 max_ts = watermark conn = sqlite3.connect(self._db_path) try: if full: # A full sync truly rebuilds the local mirror (also backfills any # newly-added columns that INSERT OR IGNORE would otherwise skip). conn.execute("DELETE FROM delivery_raw") conn.execute("UPDATE eta_sync_state SET last_delivery_ts = NULL WHERE id = 1") max_ts = None for r in rows: dt = str(r.get("deliverytime") or "") try: cur = conn.execute( "INSERT OR IGNORE INTO delivery_raw " "(deliveryid, userid, pickupcustomer, pickuptime, deliverytime, dlat, dlon, plat, plon) " "VALUES (?,?,?,?,?,?,?,?,?)", (str(r.get("deliveryid")), str(r.get("userid")), r.get("pickupcustomer"), str(r.get("pickuptime") or ""), dt, r.get("dlat"), r.get("dlon"), r.get("plat"), r.get("plon")), ) inserted += cur.rowcount except Exception: continue if dt and (max_ts is None or dt > max_ts): max_ts = dt total = conn.execute("SELECT COUNT(*) FROM delivery_raw").fetchone()[0] conn.execute( "UPDATE eta_sync_state SET last_delivery_ts = ?, last_synced_at = ?, total_rows = ? WHERE id = 1", (max_ts, datetime.utcnow().isoformat(), total), ) conn.commit() finally: conn.close() return {"fetched": len(rows), "inserted": inserted, "local_total": total, "watermark": max_ts, "mode": "full" if full else "incremental"} def _prune_raw(self, days: int) -> int: """Drop local rows older than the rolling window to bound the store.""" cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") try: conn = sqlite3.connect(self._db_path) cur = conn.execute("DELETE FROM delivery_raw WHERE deliverytime < ?", (cutoff,)) deleted = cur.rowcount conn.commit() conn.close() return deleted except Exception: return 0 def _load_raw_rows(self, days: int) -> List[Dict[str, Any]]: """Read the local mirror within the rolling window (no DB hit).""" cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") conn = sqlite3.connect(self._db_path) rows = conn.execute( "SELECT deliveryid, userid, pickupcustomer, pickuptime, deliverytime, dlat, dlon, plat, plon " "FROM delivery_raw WHERE deliverytime >= ? ORDER BY deliverytime", (cutoff,), ).fetchall() conn.close() cols = ["deliveryid", "userid", "pickupcustomer", "pickuptime", "deliverytime", "dlat", "dlon", "plat", "plon"] return [dict(zip(cols, r)) for r in rows] def sample_batches(self, days: int = 14, min_drops: int = 4, max_drops: int = 15, limit: int = 10) -> List[Dict[str, Any]]: """ Real recent delivery batches from the local mirror for the road-sequencing decision agent. Grouped by (rider, day, kitchen); origin = that kitchen's pickup coords (centroid fallback). Most-recent batches first. Returns [{origin, drops, rider, day, kitchen}]. """ rows = self._load_raw_rows(days) groups: Dict[Tuple[str, str, str], List[Dict[str, Any]]] = defaultdict(list) for r in rows: dt = _parse_dt(r.get("deliverytime")) if dt is None: continue try: if not (float(r["dlat"]) and float(r["dlon"])): continue except (TypeError, ValueError): continue key = (str(r.get("userid")), dt.strftime("%Y-%m-%d"), normalize_kitchen(r.get("pickupcustomer"))) groups[key].append(r) batches: List[Dict[str, Any]] = [] for key in sorted(groups, key=lambda k: k[1], reverse=True): items = groups[key] if not (min_drops <= len(items) <= max_drops): continue drops = [(float(i["dlat"]), float(i["dlon"])) for i in items] origin = None for i in items: try: pla, plo = float(i["plat"]), float(i["plon"]) if pla and plo: origin = (pla, plo) break except (TypeError, ValueError): continue if origin is None: origin = (sum(d[0] for d in drops) / len(drops), sum(d[1] for d in drops) / len(drops)) batches.append({"origin": origin, "drops": drops, "rider": key[0], "day": key[1], "kitchen": key[2]}) if len(batches) >= limit: break return batches def _build_observations(self, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Reconstruct per-leg observations from ordered delivery rows.""" # Group by (rider, calendar day of delivery) groups: Dict[Tuple[str, str], List[Dict[str, Any]]] = defaultdict(list) for r in rows: dt = _parse_dt(r.get("deliverytime")) pt = _parse_dt(r.get("pickuptime")) if dt is None or pt is None: continue try: dlat = float(r.get("dlat")) dlon = float(r.get("dlon")) except (TypeError, ValueError): continue if dlat == 0 or dlon == 0: continue groups[(str(r.get("userid")), dt.strftime("%Y-%m-%d"))].append({ "dt": dt, "pt": pt, "lat": dlat, "lon": dlon, "kitchen": normalize_kitchen(r.get("pickupcustomer")), "rider": str(r.get("userid")), }) obs: List[Dict[str, Any]] = [] for _key, items in groups.items(): items.sort(key=lambda x: x["dt"]) prev = None for i, it in enumerate(items): zone = self._zone.determine_zone(it["lat"], it["lon"]) traffic = hour_to_traffic(it["dt"].hour) if i == 0: # kitchen -> first drop; distance unknown without kitchen coords leg_min = (it["dt"] - it["pt"]).total_seconds() / 60.0 leg_km = None is_first = 1 else: leg_min = (it["dt"] - prev["dt"]).total_seconds() / 60.0 leg_km = _haversine_km(prev["lat"], prev["lon"], it["lat"], it["lon"]) is_first = 0 prev = it # filter implausible legs if leg_min <= 0 or leg_min > _MAX_LEG_MIN: continue if leg_km is not None and (leg_km <= 0 or leg_km > _MAX_LEG_KM): continue obs.append({ "delivery_ts": it["dt"].strftime("%Y-%m-%d %H:%M:%S"), "rider_id": it["rider"], "kitchen": it["kitchen"], "drop_zone": zone, "traffic": traffic, "dist_bucket": dist_bucket(leg_km), "leg_km": leg_km, "leg_min": round(leg_min, 2), "is_first": is_first, }) return obs @staticmethod def _aggregate(observations: List[Dict[str, Any]]) -> Dict[str, Tuple[int, float, float]]: """Group observations by every key and compute (count, median, p75).""" buckets: Dict[str, List[float]] = defaultdict(list) for o in observations: for k in _keys_for_observation( o["kitchen"], o["drop_zone"], o["traffic"], o["dist_bucket"], o["rider_id"] ): buckets[k].append(o["leg_min"]) stats: Dict[str, Tuple[int, float, float]] = {} for k, vals in buckets.items(): vals.sort() n = len(vals) median = statistics.median(vals) p75 = vals[min(n - 1, int(math.ceil(0.75 * n)) - 1)] if n else median stats[k] = (n, round(median, 2), round(p75, 2)) return stats def rebuild_aggregates(self, days: int = 14) -> Dict[str, Any]: """ Rebuild empirical medians from the LOCAL mirror only (no DB hit). Reconstructs per-leg observations, aggregates, persists, reloads cache. """ rows = self._load_raw_rows(days) observations = self._build_observations(rows) stats = self._aggregate(observations) now = datetime.utcnow().isoformat() try: conn = sqlite3.connect(self._db_path) conn.execute("DELETE FROM delivery_history") conn.execute("DELETE FROM eta_stats") conn.executemany( "INSERT INTO delivery_history " "(delivery_ts, rider_id, kitchen, drop_zone, traffic, dist_bucket, leg_km, leg_min, is_first) " "VALUES (?,?,?,?,?,?,?,?,?)", [(o["delivery_ts"], o["rider_id"], o["kitchen"], o["drop_zone"], o["traffic"], o["dist_bucket"], o["leg_km"], o["leg_min"], o["is_first"]) for o in observations], ) conn.executemany( "INSERT INTO eta_stats (key, key_type, sample_count, median_min, p75_min, updated_at) " "VALUES (?,?,?,?,?,?)", [(k, k.split("|", 1)[0], n, med, p75, now) for k, (n, med, p75) in stats.items()], ) conn.commit() conn.close() except Exception as e: logger.error(f"[DeliveryHistory] persist failed: {e}", exc_info=True) return {"status": "persist_failed", "error": str(e)} self._load_cache() self._last_refreshed = datetime.utcnow() n_by_type: Dict[str, int] = defaultdict(int) for k in stats: n_by_type[k.split("|", 1)[0]] += 1 summary = { "status": "ok", "local_rows": len(rows), "observations": len(observations), "stat_keys": len(stats), "keys_by_type": dict(n_by_type), "history_days": days, "rebuilt_at": self._last_refreshed.isoformat(), } logger.info( f"[DeliveryHistory] aggregates rebuilt: local_rows={len(rows)} " f"obs={len(observations)} keys={len(stats)} ({dict(n_by_type)})" ) return summary def refresh_eta_stats(self, days: int = 14, tenant_id: int = 916, full: bool = False) -> Dict[str, Any]: """ Full pipeline: incremental DB sync -> prune local store -> rebuild aggregates locally. This is what the scheduler and the admin endpoint call. The DB is touched only by the sync step (new rows only). """ with _WRITE_LOCK: try: sync = self.sync_from_db(days=days, tenant_id=tenant_id, full=full) except Exception as e: logger.error(f"[DeliveryHistory] sync failed: {e}", exc_info=True) # Still try to serve whatever is already local. rebuilt = self.rebuild_aggregates(days) self._last_summary = {"status": "sync_failed", "error": str(e), "rebuild": rebuilt} return self._last_summary self._prune_raw(days) rebuilt = self.rebuild_aggregates(days) self._last_summary = {"status": "ok", "sync": sync, "rebuild": rebuilt} return self._last_summary # -- cache + lookup ----------------------------------------------------- def _load_cache(self) -> None: try: conn = sqlite3.connect(self._db_path) rows = conn.execute( "SELECT key, sample_count, median_min, p75_min FROM eta_stats" ).fetchall() conn.close() self._cache = {k: (int(n), float(med), float(p75)) for k, n, med, p75 in rows} if self._cache: logger.info(f"[DeliveryHistory] loaded {len(self._cache)} eta_stats keys into cache") except Exception as e: logger.warning(f"[DeliveryHistory] cache load failed: {e}") self._cache = {} def has_data(self) -> bool: return bool(self._cache) def maybe_background_refresh(self, days: int = 14, tenant_id: int = 916, cooldown_s: int = 600) -> bool: """ If the cache is empty, kick off a one-shot background refresh (at most once per cooldown). Keeps the prediction hot path non-blocking — the current call falls back to the formula; later calls use empirical data. Returns True if a refresh thread was started. """ if self._cache: return False with self._refresh_lock: if self._cache: return False now = datetime.utcnow() if (self._refresh_attempt_at is not None and (now - self._refresh_attempt_at).total_seconds() < cooldown_s): return False self._refresh_attempt_at = now def _run(): try: self.refresh_eta_stats(days, tenant_id) except Exception as e: logger.warning(f"[DeliveryHistory] background refresh failed: {e}") threading.Thread(target=_run, daemon=True, name="eta-refresh").start() return True def local_count(self) -> int: try: conn = sqlite3.connect(self._db_path) n = conn.execute("SELECT COUNT(*) FROM delivery_raw").fetchone()[0] conn.close() return int(n) except Exception: return 0 def ensure_background_sync(self, interval_hours: int = 6, days: int = 14, tenant_id: int = 916) -> bool: """ Start the autonomous sync agent (once per process). It refreshes immediately on startup (full backfill if the local store is empty, otherwise incremental), then re-syncs every `interval_hours`. Runs in a daemon thread so it never blocks the app; all request-path ETA lookups read the local SQLite mirror, never Postgres. """ with self._refresh_lock: if self._scheduler_started: return False self._scheduler_started = True def _loop(): logger.info( f"[ETA-Agent] autonomous sync started — interval={interval_hours}h, window={days}d" ) first = True while True: try: full = first and self.local_count() == 0 result = self.refresh_eta_stats(days=days, tenant_id=tenant_id, full=full) logger.info(f"[ETA-Agent] sync cycle done: {result.get('status')}") except Exception as e: logger.warning(f"[ETA-Agent] sync cycle failed (will retry): {e}") # Recompute learned rider affinity from the freshly-synced mirror. try: from app.services.routing.rider_affinity_service import get_rider_affinity get_rider_affinity().refresh(days=days) except Exception as e: logger.debug(f"[ETA-Agent] affinity refresh skipped: {e}") first = False time.sleep(max(1, int(interval_hours)) * 3600) threading.Thread(target=_loop, daemon=True, name="eta-sync-agent").start() return True def lookup( self, distance_km: float, traffic_cat: str, kitchen: Optional[str] = None, drop_coords: Optional[Tuple[float, float]] = None, min_samples: int = 20, stat: str = "median", ) -> Optional[Dict[str, Any]]: """ Return the empirical leg time (minutes) for a context, or None if no key has >= min_samples. Walks the specificity hierarchy. """ if not self._cache: return None zone = "Unknown" if drop_coords and drop_coords[0] and drop_coords[1]: zone = self._zone.determine_zone(float(drop_coords[0]), float(drop_coords[1])) bucket = dist_bucket(distance_km) if distance_km and distance_km > 0 else None k_norm = normalize_kitchen(kitchen) if kitchen else None for full_key in _lookup_keys(k_norm, zone, traffic_cat, bucket): hit = self._cache.get(full_key) if hit and hit[0] >= min_samples: count, median, p75 = hit value = p75 if stat == "p75" else median return { "value_min": value, "sample_count": count, "source_key": full_key, "key_type": full_key.split("|", 1)[0], } return None # -- diagnostics / backtest -------------------------------------------- def get_summary(self) -> Dict[str, Any]: return { "has_data": self.has_data(), "cache_keys": len(self._cache), "last_summary": self._last_summary, } def backtest(self, days: int = 14, tenant_id: int = 916, min_samples: int = 20, stat: str = "median") -> Dict[str, Any]: """ Honest time-split backtest: build empirical stats on the older 80% of observations, then compare formula vs empirical MAE on the most recent 20% (held out). Proves whether empirical beats the formula before trust. """ from app.services.routing.realistic_eta_calculator import RealisticETACalculator formula = RealisticETACalculator() # Backtest runs entirely on the local mirror — no DB hit. rows = self._load_raw_rows(days) obs = self._build_observations(rows) obs = [o for o in obs if o["is_first"] == 0] # need leg_km for both predictors if len(obs) < 50: return {"status": "insufficient_data", "observations": len(obs)} obs.sort(key=lambda o: o["delivery_ts"]) split = int(len(obs) * 0.8) train, test = obs[:split], obs[split:] train_stats = self._aggregate(train) def _empirical(o) -> Optional[float]: for full_key in _lookup_keys(o["kitchen"], o["drop_zone"], o["traffic"], o["dist_bucket"]): hit = train_stats.get(full_key) if hit and hit[0] >= min_samples: return hit[2] if stat == "p75" else hit[1] return None f_err, e_err, e_err_fallback, covered = [], [], [], 0 for o in test: actual = o["leg_min"] f_pred = formula.calculate_eta( distance_km=o["leg_km"], is_first_order=False, order_type="Economy", time_of_day=o["traffic"], ) f_err.append(abs(f_pred - actual)) emp = _empirical(o) if emp is not None: covered += 1 e_err.append(abs(emp - actual)) e_err_fallback.append(abs(emp - actual)) else: e_err_fallback.append(abs(f_pred - actual)) # fallback to formula def _mae(xs): return round(sum(xs) / len(xs), 2) if xs else None return { "status": "ok", "history_days": days, "observations": len(obs), "test_size": len(test), "empirical_coverage_pct": round(100.0 * covered / len(test), 1), "formula_mae_min": _mae(f_err), "empirical_mae_min_covered": _mae(e_err), "empirical_mae_min_with_fallback": _mae(e_err_fallback), "min_samples": min_samples, "stat": stat, "interpretation": ( "empirical_better" if (_mae(e_err_fallback) is not None and _mae(f_err) is not None and _mae(e_err_fallback) < _mae(f_err)) else "no_improvement" ), } # --------------------------------------------------------------------------- # Singleton # --------------------------------------------------------------------------- _service: Optional[DeliveryHistoryService] = None _service_lock = threading.Lock() def get_delivery_history_service() -> DeliveryHistoryService: """Get (and lazily build) the DeliveryHistoryService singleton.""" global _service with _service_lock: if _service is None: _service = DeliveryHistoryService() return _service