Initial commit

This commit is contained in:
2026-06-22 17:40:08 +05:30
commit c742ef0e53
308 changed files with 68519 additions and 0 deletions

175
app/services/__init__.py Normal file
View File

@@ -0,0 +1,175 @@
"""Services package."""
from __future__ import annotations
import json
import os
import logging
from typing import Any, Optional, Dict
try:
import redis # type: ignore
except Exception: # pragma: no cover
redis = None # type: ignore
logger = logging.getLogger(__name__)
class RedisCache:
"""Lightweight Redis cache wrapper with graceful in-memory fallback."""
def __init__(self, url_env: str = "REDIS_URL", default_ttl_seconds: Optional[int] = None) -> None:
import threading
self._lock = threading.Lock()
self._memory_cache: Dict[str, tuple[float, str]] = {} # key -> (expire_time, serialized_value)
# Allow TTL to be configurable via env var (default 300s = 5 min, or 86400 = 24h)
ttl_env = os.getenv("REDIS_CACHE_TTL_SECONDS")
if default_ttl_seconds is None:
default_ttl_seconds = int(ttl_env) if ttl_env else 300
self.default_ttl_seconds = default_ttl_seconds
self._enabled = False
self._client = None
self._stats = {"hits": 0, "misses": 0, "sets": 0}
url = os.getenv(url_env)
if not url or redis is None:
logger.warning("Redis not configured or client unavailable; falling back to local thread-safe in-memory cache")
return
try:
self._client = redis.Redis.from_url(url, decode_responses=True)
self._client.ping()
self._enabled = True
logger.info(f"Redis cache connected (TTL: {self.default_ttl_seconds}s)")
except Exception as exc:
logger.warning(f"Redis connection failed: {exc}; falling back to local thread-safe in-memory cache")
self._enabled = False
self._client = None
@property
def enabled(self) -> bool:
return self._enabled and self._client is not None
def get_json(self, key: str) -> Optional[Any]:
if self.enabled:
try:
raw = self._client.get(key) # type: ignore[union-attr]
if raw:
self._stats["hits"] += 1
return json.loads(raw)
else:
self._stats["misses"] += 1
return None
except Exception as exc:
logger.debug(f"Redis get_json error for key={key}: {exc}")
self._stats["misses"] += 1
return None
else:
import time
with self._lock:
if key in self._memory_cache:
expire_time, raw = self._memory_cache[key]
if expire_time < 0 or expire_time > time.time():
self._stats["hits"] += 1
return json.loads(raw)
else:
del self._memory_cache[key]
self._stats["misses"] += 1
return None
def set_json(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
payload = json.dumps(value, default=lambda o: getattr(o, "model_dump", lambda: o)())
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds
if self.enabled:
try:
if ttl > 0:
self._client.setex(key, ttl, payload) # type: ignore[union-attr]
else:
self._client.set(key, payload) # type: ignore[union-attr]
self._stats["sets"] += 1
except Exception as exc:
logger.debug(f"Redis set_json error for key={key}: {exc}")
else:
import time
expire_time = (time.time() + ttl) if ttl > 0 else -1.0
with self._lock:
# Evict oldest keys if cache grows too large to prevent leak
if len(self._memory_cache) >= 2000:
now = time.time()
expired_keys = [k for k, (exp, _) in self._memory_cache.items() if exp > 0 and exp < now]
for k in expired_keys:
del self._memory_cache[k]
if len(self._memory_cache) >= 2000:
first_key = next(iter(self._memory_cache))
del self._memory_cache[first_key]
self._memory_cache[key] = (expire_time, payload)
self._stats["sets"] += 1
def delete(self, pattern: str) -> int:
"""Delete keys matching pattern (e.g., 'routes:*'). Returns count deleted."""
if self.enabled:
try:
keys = list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr]
if keys:
return self._client.delete(*keys) # type: ignore[union-attr]
return 0
except Exception as exc:
logger.error(f"Redis delete error for pattern={pattern}: {exc}")
return 0
else:
import fnmatch
deleted = 0
with self._lock:
keys_to_del = [k for k in self._memory_cache.keys() if fnmatch.fnmatchcase(k, pattern)]
for k in keys_to_del:
del self._memory_cache[k]
deleted += 1
return deleted
def get_stats(self) -> Dict[str, Any]:
"""Get cache statistics."""
stats = self._stats.copy()
if self.enabled:
try:
# Count cache keys
route_keys = list(self._client.scan_iter(match="routes:*")) # type: ignore[union-attr]
stats["total_keys"] = len(route_keys)
stats["enabled"] = True
stats["type"] = "Redis"
except Exception:
stats["total_keys"] = 0
stats["enabled"] = True
stats["type"] = "Redis"
else:
import time
now = time.time()
with self._lock:
active_keys = [k for k, (exp, _) in self._memory_cache.items() if exp < 0 or exp > now]
stats["total_keys"] = len(active_keys)
stats["enabled"] = True
stats["type"] = "In-Memory Fallback"
return stats
def get_keys(self, pattern: str = "routes:*") -> list[str]:
"""Get list of cache keys matching pattern."""
if self.enabled:
try:
return list(self._client.scan_iter(match=pattern)) # type: ignore[union-attr]
except Exception as exc:
logger.error(f"Redis get_keys error for pattern={pattern}: {exc}")
return []
else:
import fnmatch
import time
now = time.time()
with self._lock:
return [
k for k, (exp, _) in self._memory_cache.items()
if (exp < 0 or exp > now) and fnmatch.fnmatchcase(k, pattern)
]
# Singleton cache instance for app
cache = RedisCache()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
"""
Feature band encoders
======================
Discrete bucketers for assignment-context features (distance, time-of-day, load,
order density). These are small pure helpers used by the Thompson-sampling
strategy bandit (`strategy_bandit.py`) and the /riderassign bandit context.
NOTE: The ID3 SUCCESS/RISK decision tree that used to live here has been retired —
it only ever produced response metadata and never affected any assignment. Only
the generic feature-band encoders remain.
"""
from datetime import datetime
def distance_band(km: float) -> str:
"""Total route distance -> discrete band."""
if km <= 5.0: return "SHORT"
if km <= 15.0: return "MID"
if km <= 30.0: return "LONG"
return "VERY_LONG"
def time_band(ts_str: str) -> str:
"""ISO timestamp -> time-of-day band."""
try:
hour = datetime.fromisoformat(ts_str).hour
if 6 <= hour < 10: return "MORNING_RUSH"
if 10 <= hour < 12: return "LATE_MORNING"
if 12 <= hour < 14: return "LUNCH_RUSH"
if 14 <= hour < 17: return "AFTERNOON"
if 17 <= hour < 20: return "EVENING_RUSH"
if 20 <= hour < 23: return "NIGHT"
return "LATE_NIGHT"
except Exception:
return "UNKNOWN"
def load_band(avg_load: float) -> str:
"""Average orders-per-rider -> load band."""
if avg_load <= 2.0: return "LIGHT"
if avg_load <= 5.0: return "MODERATE"
if avg_load <= 8.0: return "HEAVY"
return "OVERLOADED"
def order_density_band(num_orders: int, num_riders: int) -> str:
"""Orders per available rider -> density band."""
if num_riders == 0:
return "NO_RIDERS"
ratio = num_orders / num_riders
if ratio <= 2.0: return "SPARSE"
if ratio <= 5.0: return "NORMAL"
if ratio <= 9.0: return "DENSE"
return "OVERLOADED"

View File

@@ -0,0 +1,611 @@
"""
ML Data Collector - Production Grade
======================================
Logs every assignment call (inputs + outcomes) to SQLite.
Key upgrades over the original
--------------------------------
1. FROZEN historical scores - quality_score is written ONCE at log time.
get_training_data() returns scores as-is from the DB (no retroactive mutation).
2. Rich schema - zone_id, city_id, is_peak, weather_code,
sla_breached, avg_delivery_time_min for richer features.
3. SLA tracking - logs whether delivery SLA was breached.
4. Analytics API - get_hourly_stats(), get_strategy_comparison(),
get_quality_histogram(), get_zone_stats() for dashboard consumption.
5. Thread-safe writes - connection-per-write pattern for FastAPI workers.
6. Indexed columns - timestamp, ml_strategy, zone_id for fast queries.
"""
import csv
import io
import logging
import os
import sqlite3
import threading
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db")
_WRITE_LOCK = threading.Lock()
def _std(values: List[float]) -> float:
if len(values) < 2:
return 0.0
mean = sum(values) / len(values)
return (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5
class MLDataCollector:
"""
Event logger for assignment service calls.
Each log_assignment_event() call writes one row capturing:
- Operating context (time, orders, riders, zone, city)
- Active hyperparams (exact config snapshot for this call)
- Measured outcomes (quality score, SLA, latency, distances)
quality_score is computed once and FROZEN - never retroactively changed.
"""
def __init__(self):
self._db_path = _DB_PATH
self._ensure_db()
# ------------------------------------------------------------------
# Main logging API
# ------------------------------------------------------------------
def log_assignment_event(
self,
*,
num_orders: int,
num_riders: int,
hyperparams: Dict[str, Any],
assignments: Dict[int, List[Any]],
unassigned_count: int,
elapsed_ms: float,
zone_id: str = "default",
city_id: str = "default",
weather_code: str = "CLEAR",
sla_minutes: Optional[float] = None,
avg_delivery_time_min: Optional[float] = None,
) -> None:
"""
Log one assignment event.
Call this at the END of AssignmentService.assign_orders() once
outcomes are known.
"""
try:
now = datetime.utcnow()
hour = now.hour
day_of_week = now.weekday()
is_peak = int(hour in (7, 8, 9, 12, 13, 18, 19, 20))
rider_loads = [len(orders) for orders in assignments.values() if orders]
riders_used = len(rider_loads)
total_assigned = sum(rider_loads)
avg_load = total_assigned / riders_used if riders_used else 0.0
load_std = _std(rider_loads) if rider_loads else 0.0
all_orders = [
o for orders in assignments.values() if orders for o in orders
]
total_distance_km = sum(self._get_km(o) for o in all_orders)
ml_strategy = hyperparams.get("ml_strategy", "balanced")
max_opr = hyperparams.get("max_orders_per_rider", 12)
sla_breached = 0
if sla_minutes and avg_delivery_time_min:
sla_breached = int(avg_delivery_time_min > sla_minutes)
# Quality score - FROZEN at log time
quality_score = self._compute_quality_score(
num_orders=num_orders,
unassigned_count=unassigned_count,
load_std=load_std,
riders_used=riders_used,
num_riders=num_riders,
total_distance_km=total_distance_km,
max_orders_per_rider=max_opr,
ml_strategy=ml_strategy,
)
row = {
"timestamp": now.isoformat(),
"hour": hour,
"day_of_week": day_of_week,
"is_peak": is_peak,
"zone_id": zone_id,
"city_id": city_id,
"weather_code": weather_code,
"num_orders": num_orders,
"num_riders": num_riders,
"max_pickup_distance_km": hyperparams.get(
"max_pickup_distance_km", 10.0
),
"max_kitchen_distance_km": hyperparams.get(
"max_kitchen_distance_km", 3.0
),
"max_orders_per_rider": max_opr,
"ideal_load": hyperparams.get("ideal_load", 6),
"workload_balance_threshold": hyperparams.get(
"workload_balance_threshold", 0.7
),
"workload_penalty_weight": hyperparams.get(
"workload_penalty_weight", 100.0
),
"distance_penalty_weight": hyperparams.get(
"distance_penalty_weight", 2.0
),
"cluster_radius_km": hyperparams.get("cluster_radius_km", 3.0),
"search_time_limit_seconds": hyperparams.get(
"search_time_limit_seconds", 5
),
"road_factor": hyperparams.get("road_factor", 1.3),
"ml_strategy": ml_strategy,
"riders_used": riders_used,
"total_assigned": total_assigned,
"unassigned_count": unassigned_count,
"avg_load": round(avg_load, 3),
"load_std": round(load_std, 3),
"total_distance_km": round(total_distance_km, 2),
"elapsed_ms": round(elapsed_ms, 1),
"sla_breached": sla_breached,
"avg_delivery_time_min": round(avg_delivery_time_min or 0.0, 2),
"quality_score": round(quality_score, 2),
}
with _WRITE_LOCK:
self._insert(row)
logger.info(
f"[MLCollector] zone={zone_id} orders={num_orders} "
f"assigned={total_assigned} unassigned={unassigned_count} "
f"quality={quality_score:.1f} elapsed={elapsed_ms:.0f}ms"
)
return round(quality_score, 2)
except Exception as e:
logger.warning(f"[MLCollector] Logging failed (non-fatal): {e}")
return 50.0 # neutral fallback so bandit update still fires
# ------------------------------------------------------------------
# Data retrieval for training
# ------------------------------------------------------------------
def get_training_data(
self,
min_records: int = 30,
strategy_filter: Optional[str] = None,
since_hours: Optional[int] = None,
) -> Optional[List[Dict[str, Any]]]:
"""
Return logged rows for model training.
quality_score is returned AS-IS (frozen at log time - no re-scoring).
"""
try:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
query = "SELECT * FROM assignment_ml_log"
params: list = []
clauses: list = []
if strategy_filter:
clauses.append("ml_strategy = ?")
params.append(strategy_filter)
if since_hours:
cutoff = (datetime.utcnow() - timedelta(hours=since_hours)).isoformat()
clauses.append("timestamp >= ?")
params.append(cutoff)
if clauses:
query += " WHERE " + " AND ".join(clauses)
query += " ORDER BY id ASC"
rows = conn.execute(query, params).fetchall()
conn.close()
if len(rows) < min_records:
logger.info(
f"[MLCollector] {len(rows)} records < {min_records} minimum."
)
return None
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"[MLCollector] get_training_data failed: {e}")
return None
# ------------------------------------------------------------------
# Analytics API
# ------------------------------------------------------------------
def get_recent_quality_trend(self, last_n: int = 50) -> Dict[str, Any]:
"""Recent quality scores + series for sparkline charts."""
try:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"SELECT quality_score, timestamp, unassigned_count, elapsed_ms "
"FROM assignment_ml_log ORDER BY id DESC LIMIT ?",
(last_n,),
).fetchall()
conn.close()
if not rows:
return {"avg_quality": 0.0, "sample_size": 0, "history": []}
scores = [r[0] for r in rows]
return {
"avg_quality": round(sum(scores) / len(scores), 2),
"min_quality": round(min(scores), 2),
"max_quality": round(max(scores), 2),
"sample_size": len(scores),
"history": list(reversed(scores)),
"timestamps": list(reversed([r[1] for r in rows])),
"unassigned_series": list(reversed([r[2] for r in rows])),
"latency_series": list(reversed([r[3] for r in rows])),
}
except Exception:
return {"avg_quality": 0.0, "sample_size": 0, "history": []}
def get_hourly_stats(self, last_days: int = 7) -> List[Dict[str, Any]]:
"""Quality, SLA, and call volume aggregated by hour-of-day."""
try:
conn = sqlite3.connect(self._db_path)
cutoff = (datetime.utcnow() - timedelta(days=last_days)).isoformat()
rows = conn.execute(
"""
SELECT hour,
COUNT(*) AS call_count,
AVG(quality_score) AS avg_quality,
AVG(unassigned_count) AS avg_unassigned,
AVG(elapsed_ms) AS avg_latency_ms,
SUM(CASE WHEN sla_breached=1 THEN 1 ELSE 0 END) AS sla_breaches
FROM assignment_ml_log WHERE timestamp >= ?
GROUP BY hour ORDER BY hour
""",
(cutoff,),
).fetchall()
conn.close()
return [
{
"hour": r[0],
"call_count": r[1],
"avg_quality": round(r[2] or 0.0, 2),
"avg_unassigned": round(r[3] or 0.0, 2),
"avg_latency_ms": round(r[4] or 0.0, 1),
"sla_breaches": r[5],
}
for r in rows
]
except Exception as e:
logger.error(f"[MLCollector] get_hourly_stats: {e}")
return []
def get_strategy_comparison(self) -> List[Dict[str, Any]]:
"""Compare quality metrics across ml_strategy values."""
try:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"""
SELECT ml_strategy,
COUNT(*) AS call_count,
AVG(quality_score) AS avg_quality,
MIN(quality_score) AS min_quality,
MAX(quality_score) AS max_quality,
AVG(unassigned_count) AS avg_unassigned,
AVG(total_distance_km) AS avg_distance_km,
AVG(elapsed_ms) AS avg_latency_ms
FROM assignment_ml_log
GROUP BY ml_strategy ORDER BY avg_quality DESC
"""
).fetchall()
conn.close()
return [
{
"strategy": r[0],
"call_count": r[1],
"avg_quality": round(r[2] or 0.0, 2),
"min_quality": round(r[3] or 0.0, 2),
"max_quality": round(r[4] or 0.0, 2),
"avg_unassigned": round(r[5] or 0.0, 2),
"avg_distance_km": round(r[6] or 0.0, 2),
"avg_latency_ms": round(r[7] or 0.0, 1),
}
for r in rows
]
except Exception as e:
logger.error(f"[MLCollector] get_strategy_comparison: {e}")
return []
def get_quality_histogram(self, bins: int = 10) -> List[Dict[str, Any]]:
"""Quality score distribution for histogram chart."""
try:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"SELECT quality_score FROM assignment_ml_log"
).fetchall()
conn.close()
scores = [r[0] for r in rows if r[0] is not None]
if not scores:
return []
bin_width = 100.0 / bins
return [
{
"range": f"{i * bin_width:.0f}-{(i + 1) * bin_width:.0f}",
"count": sum(
1 for s in scores if i * bin_width <= s < (i + 1) * bin_width
),
}
for i in range(bins)
]
except Exception as e:
logger.error(f"[MLCollector] get_quality_histogram: {e}")
return []
def get_zone_stats(self) -> List[Dict[str, Any]]:
"""Quality and SLA stats grouped by zone."""
try:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"""
SELECT zone_id, COUNT(*) AS call_count,
AVG(quality_score) AS avg_quality,
SUM(sla_breached) AS sla_breaches,
AVG(total_distance_km) AS avg_distance_km
FROM assignment_ml_log
GROUP BY zone_id ORDER BY avg_quality DESC
"""
).fetchall()
conn.close()
return [
{
"zone_id": r[0],
"call_count": r[1],
"avg_quality": round(r[2] or 0.0, 2),
"sla_breaches": r[3],
"avg_distance_km": round(r[4] or 0.0, 2),
}
for r in rows
]
except Exception as e:
logger.error(f"[MLCollector] get_zone_stats: {e}")
return []
def count_records(self) -> int:
try:
conn = sqlite3.connect(self._db_path)
count = conn.execute("SELECT COUNT(*) FROM assignment_ml_log").fetchone()[0]
conn.close()
return count
except Exception:
return 0
def count_by_strategy(self) -> Dict[str, int]:
try:
conn = sqlite3.connect(self._db_path)
rows = conn.execute(
"SELECT ml_strategy, COUNT(*) FROM assignment_ml_log GROUP BY ml_strategy"
).fetchall()
conn.close()
return {r[0]: r[1] for r in rows}
except Exception:
return {}
def export_csv(self) -> str:
"""Export all records as CSV string."""
try:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM assignment_ml_log ORDER BY id ASC"
).fetchall()
conn.close()
if not rows:
return ""
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows([dict(r) for r in rows])
return buf.getvalue()
except Exception as e:
logger.error(f"[MLCollector] export_csv failed: {e}")
return ""
def purge_old_records(self, keep_days: int = 90) -> int:
"""Delete records older than keep_days. Returns count deleted."""
try:
cutoff = (datetime.utcnow() - timedelta(days=keep_days)).isoformat()
conn = sqlite3.connect(self._db_path)
cursor = conn.execute(
"DELETE FROM assignment_ml_log WHERE timestamp < ?", (cutoff,)
)
deleted = cursor.rowcount
conn.commit()
conn.close()
logger.info(
f"[MLCollector] Purged {deleted} records older than {keep_days} days."
)
return deleted
except Exception as e:
logger.error(f"[MLCollector] purge failed: {e}")
return 0
# ------------------------------------------------------------------
# Quality Score Formula (frozen at log time - do not change behavior)
# ------------------------------------------------------------------
@staticmethod
def _compute_quality_score(
num_orders: int,
unassigned_count: int,
load_std: float,
riders_used: int,
num_riders: int,
total_distance_km: float,
max_orders_per_rider: int,
ml_strategy: str = "balanced",
) -> float:
"""
Multi-dimensional quality score (0100, higher = better).
Components:
┌──────────────────────┬────────────────────────────────────────────────┐
│ assigned_ratio │ % of orders successfully assigned │
│ distance_ratio │ inverse of total km (shorter routes = better) │
│ balance_ratio │ load spread across riders (lower std = better) │
│ rider_efficiency │ reward using minimal riders for the batch size │
└──────────────────────┴────────────────────────────────────────────────┘
Strategy weights (w_assign, w_dist, w_balance, w_efficiency):
- balanced : (45, 20, 20, 15)
- aggressive_speed: (70, 15, 0, 15) — care about assignment + efficiency
- fuel_saver : (25, 60, 0, 15) — heavily penalise long routes
- zone_strict : (35, 25, 25, 15) — balanced with zone awareness
"""
import math
if num_orders == 0:
return 0.0
assigned = num_orders - unassigned_count
assigned_ratio = assigned / num_orders
max_std = max(1.0, max_orders_per_rider / 2.0)
if riders_used <= 1:
balance_ratio = 0.5 # spread is undefined for a single rider; use neutral
else:
balance_ratio = max(0.0, 1.0 - (load_std / max_std))
max_dist = max(1.0, float(assigned * 8.0))
distance_ratio = max(0.0, 1.0 - (total_distance_km / max_dist))
# Rider efficiency: 1.0 = used the theoretical minimum; drops as we
# use more riders than needed.
min_riders_needed = max(1, math.ceil(num_orders / max_orders_per_rider))
rider_efficiency = min(1.0, min_riders_needed / max(1, riders_used))
weights = {
# assign dist balance efficiency
"aggressive_speed": (70.0, 15.0, 0.0, 15.0),
"fuel_saver": (25.0, 60.0, 0.0, 15.0),
"zone_strict": (35.0, 25.0, 25.0, 15.0),
"balanced": (45.0, 20.0, 20.0, 15.0),
}
w_comp, w_dist, w_bal, w_eff = weights.get(ml_strategy, (45.0, 20.0, 20.0, 15.0))
return min(
assigned_ratio * w_comp
+ distance_ratio * w_dist
+ balance_ratio * w_bal
+ rider_efficiency * w_eff,
100.0,
)
@staticmethod
def _get_km(order: Any) -> float:
try:
return float(order.get("kms") or order.get("calculationDistanceKm") or 0.0)
except Exception:
return 0.0
# ------------------------------------------------------------------
# DB Bootstrap
# ------------------------------------------------------------------
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 assignment_ml_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
hour INTEGER,
day_of_week INTEGER,
is_peak INTEGER DEFAULT 0,
zone_id TEXT DEFAULT 'default',
city_id TEXT DEFAULT 'default',
weather_code TEXT DEFAULT 'CLEAR',
num_orders INTEGER,
num_riders INTEGER,
max_pickup_distance_km REAL,
max_kitchen_distance_km REAL,
max_orders_per_rider INTEGER,
ideal_load INTEGER,
workload_balance_threshold REAL,
workload_penalty_weight REAL,
distance_penalty_weight REAL,
cluster_radius_km REAL,
search_time_limit_seconds INTEGER,
road_factor REAL,
ml_strategy TEXT DEFAULT 'balanced',
riders_used INTEGER,
total_assigned INTEGER,
unassigned_count INTEGER,
avg_load REAL,
load_std REAL,
total_distance_km REAL DEFAULT 0.0,
elapsed_ms REAL,
sla_breached INTEGER DEFAULT 0,
avg_delivery_time_min REAL DEFAULT 0.0,
quality_score REAL
)
""")
migrations = [
"ALTER TABLE assignment_ml_log ADD COLUMN is_peak INTEGER DEFAULT 0",
"ALTER TABLE assignment_ml_log ADD COLUMN zone_id TEXT DEFAULT 'default'",
"ALTER TABLE assignment_ml_log ADD COLUMN city_id TEXT DEFAULT 'default'",
"ALTER TABLE assignment_ml_log ADD COLUMN weather_code TEXT DEFAULT 'CLEAR'",
"ALTER TABLE assignment_ml_log ADD COLUMN sla_breached INTEGER DEFAULT 0",
"ALTER TABLE assignment_ml_log ADD COLUMN avg_delivery_time_min REAL DEFAULT 0.0",
"ALTER TABLE assignment_ml_log ADD COLUMN ml_strategy TEXT DEFAULT 'balanced'",
"ALTER TABLE assignment_ml_log ADD COLUMN total_distance_km REAL DEFAULT 0.0",
]
for ddl in migrations:
try:
conn.execute(ddl)
except Exception:
pass
for idx in [
"CREATE INDEX IF NOT EXISTS idx_timestamp ON assignment_ml_log(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_strategy ON assignment_ml_log(ml_strategy)",
"CREATE INDEX IF NOT EXISTS idx_zone ON assignment_ml_log(zone_id)",
]:
conn.execute(idx)
conn.commit()
conn.close()
except Exception as e:
logger.error(f"[MLCollector] DB init failed: {e}")
def _insert(self, row: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(self._db_path) or ".", exist_ok=True)
conn = sqlite3.connect(self._db_path)
cols = ", ".join(row.keys())
placeholders = ", ".join(["?"] * len(row))
conn.execute(
f"INSERT INTO assignment_ml_log ({cols}) VALUES ({placeholders})",
list(row.values()),
)
conn.commit()
conn.close()
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_collector: Optional[MLDataCollector] = None
_collector_lock = threading.Lock()
def get_collector() -> MLDataCollector:
global _collector
if _collector is None:
with _collector_lock:
if _collector is None:
_collector = MLDataCollector()
return _collector

View File

@@ -0,0 +1,277 @@
"""
Thompson Sampling Contextual Bandit — Strategy Selector
=========================================================
Replaces the greedy SQL auto-tuner with proper online RL.
Problem
-------
The old greedy tuner picks the strategy with the highest *average* quality
across all history. It never explores alternatives once one strategy leads,
and it ignores context (the same strategy isn't best at all times of day
and all load levels).
Solution
--------
A contextual multi-armed bandit with Thompson Sampling:
Context = (time_band, load_band) — up to 7 × 4 = 28 states
Arms = 4 strategies — balanced / fuel_saver / aggressive_speed / zone_strict
Reward = quality_score / 100 — 0..1 float, already logged by MLDataCollector
How Thompson Sampling works
---------------------------
Each (context, arm) pair has a Beta(α, β) posterior where:
α = sum of rewards seen so far (high quality calls push α up)
β = sum of "anti-rewards" (low quality calls push β up)
To SELECT a strategy:
1. For each arm, sample θ ~ Beta(α, β)
2. Pick arm with highest θ
→ Naturally balances exploration (uncertain arms get sampled often)
with exploitation (well-known good arms dominate when confident)
To UPDATE after an assignment:
reward = quality_score / 100
α += reward
β += (1 - reward)
Bootstrap
---------
On first startup the entire historical SQLite DB is replayed to warm up
posteriors so the bandit starts informed, not blank.
If saved state already exists it is loaded from disk instead.
Persistence
-----------
ml_data/strategy_bandit.json — saved every 10 updates.
"""
import json
import logging
import os
import threading
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
_SAVE_PATH = os.getenv("BANDIT_PATH", "ml_data/strategy_bandit.json")
_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db")
ARMS = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"]
class ContextualBandit:
"""
Thompson Sampling bandit for ml_strategy selection.
Context key : "{time_band}|{load_band}"
Arms : ARMS list (4 strategies)
Prior : Beta(1, 1) — uniform, no initial preference
"""
def __init__(self):
self._lock = threading.Lock()
# _posteriors[ctx_key][arm] = [alpha, beta]
self._posteriors: Dict[str, Dict[str, List[float]]] = {}
self._update_count = 0
self._total_pulls = 0
self._load()
self._bootstrap_from_db()
# ── Public API ───────────────────────────────────────────────────────────
def select(self, time_band: str, load_band: str) -> str:
"""
Sample from Beta posteriors and return the strategy with the highest
sample. Explores uncertain arms naturally; exploits known-good arms
as confidence grows.
"""
ctx = self._ctx(time_band, load_band)
with self._lock:
samples = {
arm: float(np.random.beta(*self._ab(ctx, arm)))
for arm in ARMS
}
self._total_pulls += 1
chosen = max(samples, key=samples.__getitem__)
logger.debug(
f"[Bandit] ctx={ctx} "
f"samples={{{', '.join(f'{k}:{v:.3f}' for k, v in samples.items())}}} "
f"{chosen}"
)
return chosen
def update(self, time_band: str, load_band: str,
strategy: str, quality_score: float) -> None:
"""Update the Beta posterior for (context, arm) after observing quality."""
if strategy not in ARMS:
return
ctx = self._ctx(time_band, load_band)
reward = min(1.0, max(0.0, float(quality_score) / 100.0))
with self._lock:
ab = self._ab(ctx, strategy)
ab[0] += reward # alpha ← quality adds to success mass
ab[1] += (1.0 - reward) # beta ← (1-quality) adds to failure mass
self._update_count += 1
should_save = self._update_count % 10 == 0
if should_save:
self._save()
def best_arm(self, time_band: str, load_band: str) -> Tuple[str, float]:
"""
Return the arm with the highest posterior mean (pure exploitation, no
sampling noise). Used for logging and dashboard, not for live selection.
"""
ctx = self._ctx(time_band, load_band)
with self._lock:
means = {
arm: self._ab(ctx, arm)[0] / sum(self._ab(ctx, arm))
for arm in ARMS
}
best = max(means, key=means.__getitem__)
return best, round(means[best], 4)
def get_stats(self) -> Dict:
"""Return per-context arm statistics for the ML admin dashboard."""
with self._lock:
stats: Dict[str, Dict] = {}
for ctx, arms in sorted(self._posteriors.items()):
stats[ctx] = {}
for arm in ARMS:
if arm not in arms:
stats[ctx][arm] = {"mean_reward": 0.5, "observations": 0,
"alpha": 1.0, "beta": 1.0}
continue
alpha, beta = arms[arm]
n = alpha + beta - 2.0 # subtract the Beta(1,1) prior mass
mean = alpha / (alpha + beta)
stats[ctx][arm] = {
"mean_reward": round(mean, 4),
"observations": round(max(0, n), 1),
"alpha": round(alpha, 2),
"beta": round(beta, 2),
}
return {
"total_pulls": self._total_pulls,
"total_updates": self._update_count,
"context_count": len(self._posteriors),
"arms": ARMS,
"contexts": stats,
}
# ── Persistence ──────────────────────────────────────────────────────────
def _save(self) -> None:
try:
with self._lock:
snapshot = {
"posteriors": {
ctx: {arm: list(ab) for arm, ab in arms.items()}
for ctx, arms in self._posteriors.items()
},
"update_count": self._update_count,
"total_pulls": self._total_pulls,
}
os.makedirs(os.path.dirname(_SAVE_PATH) or ".", exist_ok=True)
with open(_SAVE_PATH, "w", encoding="utf-8") as f:
json.dump(snapshot, f, indent=2)
except Exception as e:
logger.warning(f"[Bandit] Save failed: {e}")
def _load(self) -> None:
try:
if not os.path.exists(_SAVE_PATH):
logger.info("[Bandit] No saved state — will bootstrap from DB.")
return
with open(_SAVE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
self._posteriors = data.get("posteriors", {})
self._update_count = data.get("update_count", 0)
self._total_pulls = data.get("total_pulls", 0)
logger.info(
f"[Bandit] Loaded from disk — "
f"{len(self._posteriors)} contexts, {self._update_count} updates"
)
except Exception as e:
logger.warning(f"[Bandit] Load failed (starting fresh): {e}")
# ── Bootstrap ────────────────────────────────────────────────────────────
def _bootstrap_from_db(self) -> None:
"""
Warm-up posteriors by replaying every historical assignment event.
Skipped if the saved JSON already reflects current DB data.
"""
if self._update_count > 0:
logger.info(
f"[Bandit] Already warmed ({self._update_count} updates) — "
"skipping DB bootstrap."
)
return
try:
import sqlite3
from app.services.ml.behavior_analyzer import (
time_band as _tb,
load_band as _lb,
)
conn = sqlite3.connect(_DB_PATH)
rows = conn.execute(
"SELECT timestamp, avg_load, ml_strategy, quality_score "
"FROM assignment_ml_log "
"WHERE ml_strategy IS NOT NULL AND quality_score IS NOT NULL "
"ORDER BY id ASC"
).fetchall()
conn.close()
if not rows:
logger.info("[Bandit] No historical data — starting with uniform priors.")
return
for ts, avg_load, strategy, quality in rows:
t_band = _tb(str(ts)) if ts else "UNKNOWN"
l_band = _lb(float(avg_load or 0))
self.update(t_band, l_band, strategy, float(quality or 50))
# Reset counter so we don't confuse bootstrap updates with live ones
with self._lock:
self._update_count = len(rows)
logger.info(
f"[Bandit] Bootstrapped from {len(rows)} historical events — "
f"{len(self._posteriors)} contexts, {len(ARMS)} arms."
)
self._save()
except Exception as e:
logger.warning(f"[Bandit] Bootstrap failed (non-fatal): {e}")
# ── Helpers ──────────────────────────────────────────────────────────────
@staticmethod
def _ctx(time_band: str, load_band: str) -> str:
return f"{time_band}|{load_band}"
def _ab(self, ctx: str, arm: str) -> List[float]:
"""Get or create Beta(1, 1) uniform prior for (ctx, arm). NOT thread-safe alone."""
if ctx not in self._posteriors:
self._posteriors[ctx] = {}
if arm not in self._posteriors[ctx]:
self._posteriors[ctx][arm] = [1.0, 1.0]
return self._posteriors[ctx][arm]
# ── Singleton ────────────────────────────────────────────────────────────────
_bandit_instance: Optional[ContextualBandit] = None
_bandit_lock = threading.Lock()
def get_bandit() -> ContextualBandit:
"""Return the process-level singleton ContextualBandit."""
global _bandit_instance
if _bandit_instance is None:
with _bandit_lock:
if _bandit_instance is None:
_bandit_instance = ContextualBandit()
return _bandit_instance

View File

@@ -0,0 +1,78 @@
import httpx
import logging
from datetime import datetime
from typing import List, Dict, Any
logger = logging.getLogger(__name__)
async def fetch_active_riders() -> List[Dict[str, Any]]:
"""
Fetch active rider logs from the external API for the current date.
Returns a list of rider log dictionaries.
"""
try:
today_str = datetime.now().strftime("%Y-%m-%d")
url = "https://jupiter.nearle.app/live/api/v2/partners/getriderlogs/"
params = {
"applocationid": 1,
"partnerid": 44,
"fromdate": today_str,
"todate": today_str,
"keyword": ""
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
if data and data.get("code") == 200 and data.get("details"):
# Filter riders who are in our preferences list and are 'active' or 'idle' (assuming we want online riders)
# The user's example showed "onduty": 1. We might want to filter by that.
# For now, returning all logs, filtering can happen in assignment logic or here.
# Let's return the raw list as requested, filtering logic will be applied during assignment.
return data.get("details", [])
logger.warning(f"Fetch active riders returned no details: {data}")
return []
except Exception as e:
logger.error(f"Error fetching active riders: {e}", exc_info=True)
return []
async def fetch_created_orders() -> List[Dict[str, Any]]:
"""
Fetch all orders in 'created' state for the current date.
"""
try:
today_str = datetime.now().strftime("%Y-%m-%d")
url = "https://jupiter.nearle.app/live/api/v1/orders/tenant/getorders/"
# Removed pagesize as per user request to fetch all
params = {
"applocationid": 0,
"tenantid": 0,
"locationid": 0,
"status": "created",
"fromdate": today_str,
"todate": today_str,
"keyword": "",
"pageno": 1
# "pagesize" intentionally omitted to fetch all
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
if data and data.get("code") == 200 and data.get("details"):
return data.get("details", [])
logger.warning(f"Fetch created orders returned no details: {data}")
return []
except Exception as e:
logger.error(f"Error fetching created orders: {e}", exc_info=True)
return []

View File

@@ -0,0 +1,68 @@
import os
import pickle
import logging
from datetime import datetime
from typing import Dict, Any
logger = logging.getLogger(__name__)
# Absolute path so this pickle is written to the same location regardless of
# which directory uvicorn is launched from.
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
HISTORY_FILE = os.path.join(_PROJECT_ROOT, "data", "rider_history.pkl")
class RiderHistoryService:
def __init__(self, history_file: str = HISTORY_FILE):
self.history_file = history_file
self.history = self._load_history()
def _load_history(self) -> Dict[int, Dict[str, float]]:
"""Load history from pickle file."""
os.makedirs(os.path.dirname(self.history_file), exist_ok=True)
if not os.path.exists(self.history_file):
return {}
try:
with open(self.history_file, 'rb') as f:
return pickle.load(f)
except Exception as e:
logger.error(f"Failed to load rider history: {e}")
return {}
def _save_history(self):
"""Save history to pickle file."""
try:
with open(self.history_file, 'wb') as f:
pickle.dump(self.history, f)
except Exception as e:
logger.error(f"Failed to save rider history: {e}")
def update_rider_stats(self, rider_id: int, distance_km: float, order_count: int):
"""Update cumulative stats for a rider."""
rider_id = int(rider_id)
if rider_id not in self.history:
self.history[rider_id] = {
"total_km": 0.0,
"total_orders": 0,
"last_updated": datetime.now().isoformat()
}
self.history[rider_id]["total_km"] += distance_km
self.history[rider_id]["total_orders"] += order_count
self.history[rider_id]["last_updated"] = datetime.now().isoformat()
# Auto-save on update
self._save_history()
def get_rider_score(self, rider_id: int) -> float:
"""
Get a score representing the rider's historical 'load' (KMs).
Higher Score = More KMs driven recently.
"""
rider_id = int(rider_id)
stats = self.history.get(rider_id, {})
return stats.get("total_km", 0.0)

View File

@@ -0,0 +1,120 @@
import os
import pickle
import logging
import threading
import time
from datetime import datetime
from typing import Dict, Any, List, Set
logger = logging.getLogger(__name__)
# Use an absolute path anchored to the project root (two levels up from this file)
# so the pickle is always written to the same place regardless of the working
# directory at startup time.
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
STATE_FILE = os.path.join(_PROJECT_ROOT, "data", "rider_active_state.pkl")
_FILE_LOCK = threading.Lock()
class RiderStateManager:
"""
Manages the 'Short-Term' Active State of Riders for session persistence.
Tracks:
- Minutes Committed (Remaining Workload)
- Active Kitchens (Unique Pickups in current queue)
- Last Planned Drop Location (for Daisy Chaining)
- Timestamp of last update (for Time Decay)
"""
def __init__(self, state_file: str = STATE_FILE):
self.state_file = state_file
self.states = self._load_states()
def _load_states(self) -> Dict[str, Any]:
"""Load states from pickle."""
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
if not os.path.exists(self.state_file):
return {}
try:
with _FILE_LOCK:
with open(self.state_file, 'rb') as f:
return pickle.load(f)
except Exception as e:
logger.error(f"Failed to load rider active states: {e}")
return {}
def _save_states(self):
"""Save states to pickle."""
try:
with _FILE_LOCK:
with open(self.state_file, 'wb') as f:
pickle.dump(self.states, f)
except Exception as e:
logger.error(f"Failed to save rider active states: {e}")
def get_rider_state(self, rider_id: int) -> Dict[str, Any]:
"""
Get the current active state of a rider with TIME DECAY applied.
If the server restarts after 30 mins, the 'minutes_committed' should reduce by 30.
"""
rider_id = int(rider_id)
raw_state = self.states.get(rider_id)
if not raw_state:
return {
'minutes_remaining': 0.0,
'last_drop_lat': None,
'last_drop_lon': None,
'active_kitchens': set(),
'last_updated_ts': time.time()
}
# Apply Time Decay
last_ts = raw_state.get('last_updated_ts', time.time())
current_ts = time.time()
elapsed_mins = (current_ts - last_ts) / 60.0
remaining = max(0.0, raw_state.get('minutes_remaining', 0.0) - elapsed_mins)
# If queue is empty, kitchens are cleared
kitchens = raw_state.get('active_kitchens', set())
if remaining <= 5.0: # Buffer: if almost done, free up kitchens
kitchens = set()
return {
'minutes_remaining': remaining,
'last_drop_lat': raw_state.get('last_drop_lat'),
'last_drop_lon': raw_state.get('last_drop_lon'),
'active_kitchens': kitchens,
'last_updated_ts': current_ts
}
def update_rider_state(self, rider_id: int, added_minutes: float, new_kitchens: Set[str], last_lat: float, last_lon: float):
"""
Update the state after a new assignment.
"""
rider_id = int(rider_id)
# Get current state (decayed)
current = self.get_rider_state(rider_id)
# Accumulate
updated_minutes = current['minutes_remaining'] + added_minutes
updated_kitchens = current['active_kitchens'].union(new_kitchens)
self.states[rider_id] = {
'minutes_remaining': updated_minutes,
'last_drop_lat': last_lat,
'last_drop_lon': last_lon,
'active_kitchens': updated_kitchens,
'last_updated_ts': time.time()
}
self._save_states()
def clear_state(self, rider_id: int):
rider_id = int(rider_id)
if rider_id in self.states:
del self.states[rider_id]
self._save_states()

View File

@@ -0,0 +1,163 @@
"""
Rider Substitution Service
==========================
Stores ops-team substitution records: when rider X is absent, rider Y
(the sub) covers X's kitchens and zone for that day.
On each dispatch call, AssignmentService loads today's map and merges the
absent rider's kitchen + zone affinity into the sub rider's profile for
that request only. No server restart needed. Automatically reverts the
next day because the lookup is date-keyed.
Storage: SQLite at data/substitutions.db
"""
import logging
import os
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
# All date comparisons use IST so the substitution fires on the correct
# calendar day regardless of what timezone the server (Docker/UTC) runs in.
_IST = ZoneInfo("Asia/Kolkata")
def _today_ist() -> str:
"""Current date in IST as YYYY-MM-DD string."""
return datetime.now(_IST).date().isoformat()
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)
))))
_DB_PATH = os.path.join(_PROJECT_ROOT, "data", "substitutions.db")
_DDL = """
CREATE TABLE IF NOT EXISTS rider_substitutions (
sub_date TEXT NOT NULL,
absent_rider_id INTEGER NOT NULL,
sub_rider_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (sub_date, absent_rider_id)
);
"""
class SubstitutionService:
def __init__(self, db_path: str = _DB_PATH):
self._db_path = db_path
self._lock = threading.Lock()
# In-memory cache for today's substitution map.
# Keyed by IST date string so it auto-invalidates at midnight IST.
self._cache: Dict[int, int] = {}
self._cache_date: str = ""
self._init_db()
def _init_db(self):
os.makedirs(os.path.dirname(self._db_path), exist_ok=True)
with sqlite3.connect(self._db_path) as conn:
conn.execute(_DDL)
conn.commit()
def _conn(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
return conn
def _invalidate_cache(self):
"""Clear the in-memory cache so the next get_today_map() re-reads from DB."""
self._cache_date = ""
def register(self, sub_date: str, absent_rider_id: int, sub_rider_id: int) -> Dict:
"""Register or update a substitution for a given date."""
created_at = datetime.now(timezone.utc).isoformat()
with self._lock:
with self._conn() as conn:
conn.execute(
"""
INSERT INTO rider_substitutions
(sub_date, absent_rider_id, sub_rider_id, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(sub_date, absent_rider_id)
DO UPDATE SET sub_rider_id = excluded.sub_rider_id,
created_at = excluded.created_at
""",
(sub_date, int(absent_rider_id), int(sub_rider_id), created_at),
)
conn.commit()
self._invalidate_cache()
logger.info(
f"[Substitution] Registered: {sub_date} — absent={absent_rider_id} sub={sub_rider_id}"
)
return {
"sub_date": sub_date,
"absent_rider_id": absent_rider_id,
"sub_rider_id": sub_rider_id,
"created_at": created_at,
}
def cancel(self, sub_date: str, absent_rider_id: int) -> bool:
"""Remove a substitution record. Returns True if a row was deleted."""
with self._lock:
with self._conn() as conn:
cur = conn.execute(
"DELETE FROM rider_substitutions WHERE sub_date=? AND absent_rider_id=?",
(sub_date, int(absent_rider_id)),
)
conn.commit()
self._invalidate_cache()
return cur.rowcount > 0
def get_today_map(self) -> Dict[int, int]:
"""
Return {absent_rider_id: sub_rider_id} for today (IST).
Result is cached in memory — one SQLite read per IST day (or after any
register/cancel call). Every dispatch call after the first is a plain
dict lookup with no DB I/O.
"""
today = _today_ist()
with self._lock:
if self._cache_date == today:
return dict(self._cache)
# Cache miss: query DB once, store result
with self._conn() as conn:
rows = conn.execute(
"SELECT absent_rider_id, sub_rider_id FROM rider_substitutions WHERE sub_date=?",
(today,),
).fetchall()
self._cache = {int(r["absent_rider_id"]): int(r["sub_rider_id"]) for r in rows}
self._cache_date = today
if self._cache:
logger.info(f"[Substitution] Cache loaded for {today}: {self._cache}")
return dict(self._cache)
def list_all(self, from_date: Optional[str] = None) -> List[Dict]:
"""Return all substitutions on or after from_date (default: today), ordered by date."""
cutoff = from_date or _today_ist()
with self._conn() as conn:
rows = conn.execute(
"""SELECT sub_date, absent_rider_id, sub_rider_id, created_at
FROM rider_substitutions
WHERE sub_date >= ?
ORDER BY sub_date, absent_rider_id""",
(cutoff,),
).fetchall()
return [dict(r) for r in rows]
_service: Optional[SubstitutionService] = None
_service_lock = threading.Lock()
def get_substitution_service() -> SubstitutionService:
global _service
if _service is None:
with _service_lock:
if _service is None:
_service = SubstitutionService()
return _service

View File

@@ -0,0 +1,860 @@
"""
Batch Efficiency Analyser
=========================
Pure-function service that analyses a delivery batch and returns:
- fleet_summary : aggregate metrics + load-balance scores
- rider_timelines : per-rider start/finish/pace/utilisation
- substitution_opportunities : ranked, scored transfer plans
- top_recommendation : best action with confidence, root-cause & risk factors
No DB access here. The route handler owns fetching; this layer is
fully testable with any list of dicts.
Expected delivery dict keys (all optional except userid):
userid : int rider id
pickupcustomer : str e.g. "Daily Grubs Bhuvaneshwari"
assigntime : str "YYYY-MM-DD HH:MM:SS"
pickuptime : str "YYYY-MM-DD HH:MM:SS" or None
deliverytime : str "YYYY-MM-DD HH:MM:SS" or None
dlat / droplat : float delivery lat (either key accepted)
dlon / droplon : float delivery lon (either key accepted)
deliveryid : int order id (for transfer manifests)
"""
from __future__ import annotations
import math
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_KITCHEN_COORDS: dict[str, tuple[float, float]] = {
"vidhya": (11.01633, 77.01478),
"jayanthi": (11.03887, 76.93008),
"nandhini": (11.04324, 77.00068),
"bhuvaneshwari": (11.00352, 76.95455),
"selvarani": (10.99274, 77.00535),
}
DEFAULT_KITCHEN_FRAGMENTS: list[str] = [
"bhuvaneshwari", "jayanthi", "nandhini", "vidhya", "selvarani"
]
DEFAULT_ROAD_KMH: float = 13.0
DEFAULT_IDLE_THRESHOLD_MIN: float = 30.0
DEFAULT_MAX_TRANSFER: int = 4
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _hav(la1: float, lo1: float, la2: float, lo2: float) -> float:
R = 6371.0
la1, lo1, la2, lo2 = map(math.radians, [la1, lo1, la2, lo2])
a = (math.sin((la2 - la1) / 2) ** 2
+ math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2)
return R * 2 * math.asin(math.sqrt(max(0.0, min(1.0, a))))
def _travel_min(km: float, kmh: float = DEFAULT_ROAD_KMH) -> float:
return km / kmh * 60.0
def _parse_ts(s: Any) -> datetime | None:
if not s:
return None
s = str(s).strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%H:%M:%S") if dt else None
def _get_coord(order: dict) -> tuple[float, float] | None:
lat = order.get("dlat") or order.get("droplat") or order.get("deliverylat")
lon = order.get("dlon") or order.get("droplon") or order.get("deliverylong")
try:
return float(lat), float(lon)
except (TypeError, ValueError):
return None
def _detect_kitchen(
pickupcustomer: str | None,
fragments: list[str],
) -> str | None:
kl = (pickupcustomer or "").lower()
for frag in fragments:
if frag in kl:
return frag
return None
def _stdev(values: list[float]) -> float:
"""Population stdev; returns 0 for fewer than 2 values."""
if len(values) < 2:
return 0.0
try:
return statistics.stdev(values)
except Exception:
return 0.0
def _score_candidate(
o: dict,
arrive_at_kitchen: datetime,
k_coord: tuple[float, float],
road_kmh: float,
) -> float:
"""
Score a candidate order for transfer to the idle rider.
Higher = better candidate.
Two components:
time_gain : minutes saved vs the original delivery time.
Positive means idle rider genuinely arrives earlier.
geo_penalty: haversine distance from kitchen to the order drop point.
Penalises far orders that inflate the idle rider's extra km.
Orders with negative time_gain (idle rider would be slower) still get a
score, allowing the caller to filter them out with a feasibility check.
"""
d_ts = _parse_ts(o.get("deliverytime"))
coord = _get_coord(o)
if not d_ts or not coord:
return -9999.0
dist_km = _hav(k_coord[0], k_coord[1], coord[0], coord[1])
est_deliver = arrive_at_kitchen + timedelta(minutes=_travel_min(dist_km, road_kmh))
time_gain_min = (d_ts - est_deliver).total_seconds() / 60.0
# Weight: time gain matters more than geography (70/30 split).
# Penalise 2 min per km of extra distance so nearby clusters float up.
return time_gain_min * 0.7 - dist_km * 2.0 * 0.3
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def analyse_batch(
deliveries: list[dict],
rider_names: dict[int, str] | None = None,
kitchen_coords: dict[str, tuple[float, float]] | None = None,
kitchen_fragments: list[str] | None = None,
road_kmh: float = DEFAULT_ROAD_KMH,
idle_threshold_min: float = DEFAULT_IDLE_THRESHOLD_MIN,
max_transfer: int = DEFAULT_MAX_TRANSFER,
) -> dict:
"""
Analyse a delivery batch and return substitution opportunities.
Parameters
----------
deliveries : list of order dicts (see module docstring)
rider_names : optional {userid: name} override
kitchen_coords : optional kitchen pickup coordinates override
kitchen_fragments : optional kitchen detection strings override
road_kmh : estimated loaded-rider road speed
idle_threshold_min: minimum idle window to flag as opportunity
max_transfer : maximum orders to suggest transferring to one rider
"""
if kitchen_coords is None:
kitchen_coords = DEFAULT_KITCHEN_COORDS
if kitchen_fragments is None:
kitchen_fragments = DEFAULT_KITCHEN_FRAGMENTS
if rider_names is None:
rider_names = {}
# ------------------------------------------------------------------
# 1. Group deliveries by rider
# ------------------------------------------------------------------
by_rider: dict[int, list[dict]] = defaultdict(list)
for o in deliveries:
uid = o.get("userid")
if uid is not None:
by_rider[int(uid)].append(o)
if not by_rider:
return {
"fleet_summary": {},
"rider_timelines": [],
"substitution_opportunities": [],
"top_recommendation": None,
"error": "No deliveries with valid userid found.",
}
# ------------------------------------------------------------------
# 2. Per-rider timeline
# ------------------------------------------------------------------
timelines: list[dict] = []
for uid, orders in by_rider.items():
# Detect primary kitchen by majority vote
kitchen_votes: dict[str, int] = defaultdict(int)
for o in orders:
k = _detect_kitchen(o.get("pickupcustomer"), kitchen_fragments)
if k:
kitchen_votes[k] += 1
primary_kitchen = (
max(kitchen_votes, key=kitchen_votes.get) if kitchen_votes else None
)
kitchen_confidence = (
round(kitchen_votes[primary_kitchen] / len(orders), 2)
if primary_kitchen else 0.0
)
# Timestamps
finish_ts = None
start_ts = None
last_coord: tuple[float, float] | None = None
completed_orders = 0
for o in orders:
a = _parse_ts(o.get("assigntime"))
d = _parse_ts(o.get("deliverytime"))
if a and (start_ts is None or a < start_ts):
start_ts = a
if d:
completed_orders += 1
if finish_ts is None or d > finish_ts:
finish_ts = d
coord = _get_coord(o)
if coord:
last_coord = coord
# Active duration and pace
active_minutes: float | None = None
pace_orders_per_hour: float | None = None
if start_ts and finish_ts and finish_ts > start_ts:
active_minutes = round((finish_ts - start_ts).total_seconds() / 60, 1)
pace_orders_per_hour = round(completed_orders / (active_minutes / 60), 1) if active_minutes else None
timelines.append({
"userid": uid,
"name": rider_names.get(uid, f"Rider {uid}"),
"kitchen": primary_kitchen,
"kitchen_confidence": kitchen_confidence,
"order_count": len(orders),
"completed_orders": completed_orders,
"pending_orders": len(orders) - completed_orders,
"started_at": _fmt(start_ts),
"finished_at": _fmt(finish_ts),
"active_minutes": active_minutes,
"pace_orders_per_hour": pace_orders_per_hour,
"_finish_dt": finish_ts,
"_start_dt": start_ts,
"last_position": (
{"lat": round(last_coord[0], 6), "lon": round(last_coord[1], 6)}
if last_coord else None
),
"_last_coord": last_coord,
})
# Sort by finish time
timelines.sort(key=lambda t: t["_finish_dt"] or datetime.min)
# ------------------------------------------------------------------
# 3. Fleet summary
# ------------------------------------------------------------------
valid_start = [t["_start_dt"] for t in timelines if t["_start_dt"]]
valid_finish = [t["_finish_dt"] for t in timelines if t["_finish_dt"]]
fleet_start = min(valid_start) if valid_start else None
fleet_done = max(valid_finish) if valid_finish else None
# Load balance: stdev of order counts and finish-time spread
order_counts = [t["order_count"] for t in timelines]
finish_offsets_min = (
[(f - fleet_start).total_seconds() / 60 for f in valid_finish]
if fleet_start and valid_finish else []
)
finish_spread_min = (
round((max(valid_finish) - min(valid_finish)).total_seconds() / 60)
if len(valid_finish) >= 2 else 0
)
load_balance_stdev = round(_stdev([float(c) for c in order_counts]), 2)
finish_time_stdev = round(_stdev(finish_offsets_min), 1)
# Utilisation: how much of the batch window each rider was actively delivering
batch_duration_min = (
round((fleet_done - fleet_start).total_seconds() / 60)
if fleet_start and fleet_done else None
)
avg_active_minutes = (
round(
sum(t["active_minutes"] for t in timelines if t["active_minutes"])
/ max(1, sum(1 for t in timelines if t["active_minutes"])),
1,
)
if any(t["active_minutes"] for t in timelines) else None
)
avg_utilisation_pct = (
round(avg_active_minutes / batch_duration_min * 100, 1)
if avg_active_minutes and batch_duration_min else None
)
fleet_summary = {
"total_orders": len(deliveries),
"total_riders": len(by_rider),
"fleet_start": _fmt(fleet_start),
"fleet_done": _fmt(fleet_done),
"total_duration_minutes": batch_duration_min,
"orders_per_rider_avg": round(len(deliveries) / len(by_rider), 1),
"load_balance_stdev": load_balance_stdev,
"finish_time_spread_minutes": finish_spread_min,
"finish_time_stdev_minutes": finish_time_stdev,
"avg_utilisation_pct": avg_utilisation_pct,
"avg_active_minutes": avg_active_minutes,
}
if not fleet_done:
return {
"fleet_summary": fleet_summary,
"rider_timelines": _clean_timelines(timelines, fleet_done),
"substitution_opportunities": [],
"top_recommendation": None,
"error": "No completed deliveries found.",
}
# Mark each rider's idle status and free window
for t in timelines:
fd = t["_finish_dt"]
if fd:
idle_min = (fleet_done - fd).total_seconds() / 60.0
t["idle_minutes"] = round(idle_min)
t["free_window_minutes"] = round(idle_min) # time available for substitution
t["status"] = "idle" if idle_min >= idle_threshold_min else "active"
else:
t["idle_minutes"] = 0
t["free_window_minutes"] = 0
t["status"] = "unknown"
# ------------------------------------------------------------------
# 4. Substitution opportunities
# ------------------------------------------------------------------
opportunities: list[dict] = []
idle_riders = [t for t in timelines if t["status"] == "idle" and t["_last_coord"]]
for idle in idle_riders:
idle_uid = idle["userid"]
idle_finish = idle["_finish_dt"]
idle_coord = idle["_last_coord"]
free_window_min = idle["free_window_minutes"]
if idle["kitchen"] is None:
continue # can't determine origin kitchen
for target_kitchen, k_coord in kitchen_coords.items():
if target_kitchen == idle["kitchen"]:
continue # skip own kitchen
# Travel from idle rider's last drop to the target kitchen
travel_km = _hav(idle_coord[0], idle_coord[1], k_coord[0], k_coord[1])
travel_min = _travel_min(travel_km, road_kmh)
# Skip if idle rider can't even reach the kitchen before fleet is done
if travel_min >= free_window_min:
continue
arrive_at_kitchen = idle_finish + timedelta(minutes=travel_min)
# ----------------------------------------------------------
# Candidate selection: score every order from this kitchen
# that was delivered after the idle rider could arrive.
# Score = time_gain (70%) + geo proximity (30%).
# This prefers orders where idle rider is genuinely faster
# AND that are close to the kitchen (less detour).
# ----------------------------------------------------------
candidate_orders: list[tuple[int, dict, float]] = [] # (rid, order, score)
for rid, r_orders in by_rider.items():
if rid == idle_uid:
continue
r_kitchen = next(
(t["kitchen"] for t in timelines if t["userid"] == rid), None
)
if r_kitchen != target_kitchen:
continue
for o in r_orders:
d_ts = _parse_ts(o.get("deliverytime"))
if not d_ts or d_ts <= arrive_at_kitchen:
continue
score = _score_candidate(o, arrive_at_kitchen, k_coord, road_kmh)
candidate_orders.append((rid, o, score))
if not candidate_orders:
continue
# Sort best candidates first (highest score = most time gained, closest)
candidate_orders.sort(key=lambda x: x[2], reverse=True)
take_pool = candidate_orders[:max_transfer]
# Greedy nearest-neighbour route from the kitchen through selected orders
unvisited = list(range(len(take_pool)))
curr_nn = k_coord
greedy_take: list[tuple[int, dict]] = []
while unvisited:
ni = min(
unvisited,
key=lambda i: (
_hav(curr_nn[0], curr_nn[1], *c)
if (c := _get_coord(take_pool[i][1])) else 999.0
),
)
unvisited.remove(ni)
greedy_take.append((take_pool[ni][0], take_pool[ni][1]))
coord = _get_coord(take_pool[ni][1])
if coord:
curr_nn = coord
# Simulate idle rider executing the greedy route
curr_pos = k_coord
est_time = arrive_at_kitchen
transfer_manifests: list[dict] = []
total_delivery_leg_min = 0.0
for orig_rid, o in greedy_take:
coord = _get_coord(o)
if not coord:
continue
d_km = _hav(curr_pos[0], curr_pos[1], coord[0], coord[1])
d_min = _travel_min(d_km, road_kmh)
total_delivery_leg_min += d_min
est_deliver = est_time + timedelta(minutes=d_min)
orig_deliver = _parse_ts(o.get("deliverytime"))
improvement = (
round((orig_deliver - est_deliver).total_seconds() / 60)
if orig_deliver and est_deliver else None
)
is_feasible = improvement is not None and improvement > 0
transfer_manifests.append({
"deliveryid": o.get("deliveryid"),
"from_rider_id": orig_rid,
"from_rider_name": rider_names.get(orig_rid, f"Rider {orig_rid}"),
"original_delivery_time": _fmt(orig_deliver),
"estimated_delivery_time": _fmt(est_deliver),
"improvement_minutes": improvement,
"is_feasible": is_feasible,
"location": {"lat": round(coord[0], 6), "lon": round(coord[1], 6)},
})
curr_pos = coord
est_time = est_deliver
# Only keep orders where idle rider is actually faster
feasible_manifests = [m for m in transfer_manifests if m["is_feasible"]]
if not feasible_manifests:
continue
idle_new_finish = est_time
# Check idle rider can complete within their free window
total_obligation_min = travel_min + total_delivery_leg_min
if total_obligation_min > free_window_min:
# Idle rider would finish after fleet, extending rather than helping
continue
# New fleet done after the transfer
taken_order_objs = {id(o) for (_, o) in greedy_take}
new_finish_by_rider: dict[int, datetime | None] = {}
for rid, r_orders in by_rider.items():
if rid == idle_uid:
new_finish_by_rider[rid] = idle_new_finish
continue
remaining = [o for o in r_orders if id(o) not in taken_order_objs]
finishes = [_parse_ts(o.get("deliverytime")) for o in remaining]
valid = [f for f in finishes if f]
new_finish_by_rider[rid] = max(valid) if valid else None
new_fleet_done = max(
(v for v in new_finish_by_rider.values() if v),
default=fleet_done,
)
fleet_improvement = round(
(fleet_done - new_fleet_done).total_seconds() / 60
)
# Most relieved rider
orig_last_by_rider = {
rid: max(
(f for f in [_parse_ts(o.get("deliverytime")) for o in r_o] if f),
default=None,
)
for rid, r_o in by_rider.items()
}
most_impacted_rid = max(
(rid for rid in {r for r, _ in greedy_take}),
key=lambda r: (orig_last_by_rider.get(r) or datetime.min),
default=None,
)
orig_overloaded_finish = orig_last_by_rider.get(most_impacted_rid)
new_overloaded_finish = new_finish_by_rider.get(most_impacted_rid)
time_saved = (
round((orig_overloaded_finish - new_overloaded_finish).total_seconds() / 60)
if orig_overloaded_finish and new_overloaded_finish else 0
)
# Total extra km for idle rider (idle→kitchen + all delivery legs)
total_extra_km = travel_km + sum(
_hav(
(k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[0],
(k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[1],
*(_get_coord(o) or k_coord),
)
for i, (_, o) in enumerate(greedy_take)
if _get_coord(o)
)
# ----------------------------------------------------------
# Confidence score (0-100)
# Measures how comfortable this transfer is given real constraints.
#
# Component 1 Slack ratio: how much free time the idle rider
# has beyond the time they'll spend doing the transfer.
# (free_window - total_obligation) / free_window → 0..1
#
# Component 2 Feasibility ratio: what fraction of the
# transferred orders actually deliver earlier than original.
# feasible_count / total_transferred → 0..1
#
# Component 3 Fleet gain ratio: minutes saved as a fraction
# of total batch duration. Capped at 20 min improvement for
# full score so small batches don't produce inflated scores.
# ----------------------------------------------------------
slack_ratio = max(0.0, (free_window_min - total_obligation_min) / free_window_min)
feasibility_ratio = len(feasible_manifests) / max(1, len(transfer_manifests))
fleet_gain_ratio = min(1.0, fleet_improvement / 20.0) if fleet_improvement > 0 else 0.0
confidence_score = round(
(slack_ratio * 0.4 + feasibility_ratio * 0.35 + fleet_gain_ratio * 0.25) * 100
)
efficiency_ratio = (
round(fleet_improvement / max(0.1, total_extra_km), 2)
if total_extra_km > 0 else 0.0
)
if efficiency_ratio >= 5:
efficiency_rating = "high"
elif efficiency_ratio >= 2:
efficiency_rating = "medium"
else:
efficiency_rating = "low"
opportunities.append({
"idle_rider": {
"userid": idle_uid,
"name": idle["name"],
"primary_kitchen": idle["kitchen"],
"order_count": idle["order_count"],
"finished_at": _fmt(idle_finish),
"idle_minutes": idle["idle_minutes"],
"free_window_minutes": free_window_min,
"last_position": idle["last_position"],
},
"target_kitchen": target_kitchen,
"travel_to_kitchen_km": round(travel_km, 1),
"travel_to_kitchen_minutes": round(travel_min),
"arrive_at_kitchen": _fmt(arrive_at_kitchen),
"orders_to_transfer": transfer_manifests,
"total_orders_transferred": len(feasible_manifests),
"feasible_orders_count": len(feasible_manifests),
"most_relieved_rider": {
"userid": most_impacted_rid,
"name": rider_names.get(most_impacted_rid, f"Rider {most_impacted_rid}"),
"original_finish": _fmt(orig_overloaded_finish),
"new_finish": _fmt(new_overloaded_finish),
"time_saved_minutes": time_saved,
},
"extra_km_for_idle_rider": round(total_extra_km, 1),
"total_obligation_minutes": round(total_obligation_min),
"idle_rider_new_finish": _fmt(idle_new_finish),
"original_fleet_done": _fmt(fleet_done),
"new_fleet_done": _fmt(new_fleet_done),
"fleet_improvement_minutes": fleet_improvement,
"confidence_score": confidence_score,
"efficiency_ratio": efficiency_ratio,
"efficiency_rating": efficiency_rating,
})
# Keep only net-positive opportunities
opportunities = [o for o in opportunities if o["fleet_improvement_minutes"] > 0]
# Sort: confidence first (overall quality), then fleet improvement, then rider time saved
opportunities.sort(
key=lambda x: (
-x["confidence_score"],
-x["fleet_improvement_minutes"],
-x["most_relieved_rider"]["time_saved_minutes"],
)
)
# ------------------------------------------------------------------
# 5. Top recommendation
# ------------------------------------------------------------------
top_recommendation = _build_recommendation(
opportunities, idle_threshold_min, fleet_done, timelines, fleet_summary
)
return {
"fleet_summary": fleet_summary,
"rider_timelines": _clean_timelines(timelines, fleet_done),
"substitution_opportunities": opportunities,
"top_recommendation": top_recommendation,
}
# ---------------------------------------------------------------------------
# Helpers for clean output
# ---------------------------------------------------------------------------
def _clean_timelines(
timelines: list[dict],
fleet_done: datetime | None,
) -> list[dict]:
out = []
for t in timelines:
out.append({
"userid": t["userid"],
"name": t["name"],
"kitchen": t["kitchen"],
"kitchen_confidence": t.get("kitchen_confidence", 0.0),
"order_count": t["order_count"],
"completed_orders": t.get("completed_orders", t["order_count"]),
"pending_orders": t.get("pending_orders", 0),
"started_at": t["started_at"],
"finished_at": t["finished_at"],
"active_minutes": t.get("active_minutes"),
"pace_orders_per_hour": t.get("pace_orders_per_hour"),
"idle_minutes": t.get("idle_minutes", 0),
"free_window_minutes": t.get("free_window_minutes", 0),
"status": t.get("status", "unknown"),
"last_position": t.get("last_position"),
})
return out
def _build_recommendation(
opportunities: list[dict],
idle_threshold: float,
fleet_done: datetime | None,
timelines: list[dict],
fleet_summary: dict,
) -> dict | None:
if not opportunities:
# Diagnose WHY there are no opportunities even if some riders were idle
idle_count = sum(1 for t in timelines if t.get("status") == "idle")
if idle_count == 0:
reason = "All riders finished within the idle threshold window — batch was well balanced."
else:
reason = (
f"{idle_count} rider(s) finished early but no feasible substitution found: "
"either travel time exceeds the idle window, or all candidate orders "
"would be delivered later by the idle rider than the original."
)
return {
"action": "none",
"reason": reason,
"fleet_balance_assessment": _balance_assessment(fleet_summary),
}
best = opportunities[0]
idle = best["idle_rider"]
target = best["target_kitchen"]
relieved = best["most_relieved_rider"]
primary_kitchen = idle["primary_kitchen"] or "unknown"
confidence = best["confidence_score"]
# Root cause: why was this rider idle?
root_cause = _diagnose_root_cause(idle, timelines, fleet_summary)
# Risk factors
risk_factors = _identify_risks(best, idle_threshold)
description = (
f"{idle['name']} ({primary_kitchen}) finished all {idle['order_count']} orders "
f"at {idle['finished_at']}{idle['idle_minutes']} min before the fleet finished. "
f"Assigning {best['feasible_orders_count']} {target} orders: "
f"travel {best['travel_to_kitchen_km']} km ({best['travel_to_kitchen_minutes']} min), "
f"arrive at {target} kitchen at {best['arrive_at_kitchen']}. "
f"Relieves {relieved['name']} by {relieved['time_saved_minutes']} min "
f"({relieved['original_finish']}{relieved['new_finish']}). "
f"Fleet finishes {best['fleet_improvement_minutes']} min earlier "
f"({best['original_fleet_done']}{best['new_fleet_done']}). "
f"Confidence: {confidence}/100."
)
# Dynamic thresholds derived from the actual batch data
idle_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == primary_kitchen]
target_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == target]
activate_idle_threshold = max(6, idle.get("order_count", 6) + 2)
activate_target_threshold = max(8, round(sum(target_rider_loads) / max(1, len(target_rider_loads)) * 1.1))
activate_rule = {
"condition": "AND",
"rules": [
{
"field": f"{primary_kitchen}_order_count",
"operator": "<=",
"value": activate_idle_threshold,
"reason": (
f"{idle['name']} had {idle['order_count']} orders today and was idle "
f"{idle['idle_minutes']} min. Dual-kitchen kicks in when their load "
f"stays at or below {activate_idle_threshold}."
),
},
{
"field": f"{target}_order_count",
"operator": ">=",
"value": activate_target_threshold,
"reason": (
f"{target.capitalize()} had enough orders today to justify the detour "
f"({sum(target_rider_loads)} total across {len(target_rider_loads)} rider(s)). "
f"Activate when {target} load is ≥ {activate_target_threshold}."
),
},
],
}
return {
"action": "dual_kitchen_assignment",
"idle_rider_id": idle["userid"],
"idle_rider_name": idle["name"],
"primary_kitchen": primary_kitchen,
"second_kitchen": target,
"second_kitchen_dispatch_after": best["arrive_at_kitchen"],
"description": description,
"fleet_improvement_minutes": best["fleet_improvement_minutes"],
"confidence_score": confidence,
"efficiency_rating": best["efficiency_rating"],
"root_cause": root_cause,
"risk_factors": risk_factors,
"activate_when": activate_rule,
"fleet_balance_assessment": _balance_assessment(fleet_summary),
"api_hint": {
"endpoint": "/api/v1/optimize",
"note": (
f"In the next batch, pre-assign the last "
f"{best['feasible_orders_count']} {target} orders to "
f"rider {idle['userid']} ({idle['name']}) with a "
f"dispatch-after time of {best['arrive_at_kitchen']}."
),
},
}
def _diagnose_root_cause(
idle: dict,
timelines: list[dict],
fleet_summary: dict,
) -> str:
"""
Explain WHY this rider finished early by comparing their load
against the fleet average and their kitchen's order volume.
"""
avg_orders = fleet_summary.get("orders_per_rider_avg", 0)
rider_orders = idle["order_count"]
kitchen = idle["primary_kitchen"] or "their kitchen"
name = idle["name"]
if avg_orders > 0 and rider_orders < avg_orders * 0.7:
shortfall = round(avg_orders - rider_orders, 1)
return (
f"{name} received {rider_orders} orders vs fleet average of {avg_orders:.1f} "
f"({shortfall:.1f}). {kitchen.capitalize()} kitchen generated fewer orders "
f"than the fleet needed to keep this rider fully utilised. "
f"This is a systematic under-loading of the {kitchen} kitchen in this batch."
)
elif rider_orders <= 4:
return (
f"{name} had only {rider_orders} orders — a very light load regardless of fleet average. "
f"Likely a short-demand window at {kitchen} kitchen. "
f"Dual-kitchen assignment is especially effective when primary kitchen load is ≤ 4 orders."
)
else:
spread = fleet_summary.get("finish_time_spread_minutes", 0)
return (
f"{name} is simply faster than peers — finished {idle['idle_minutes']} min ahead "
f"despite a normal load of {rider_orders} orders. "
f"Fleet finish-time spread is {spread} min, indicating uneven workload distribution."
)
def _identify_risks(best: dict, idle_threshold: float) -> list[str]:
"""
Enumerate operational risks for the recommended substitution.
"""
risks: list[str] = []
travel_min = best["travel_to_kitchen_minutes"]
obligation = best["total_obligation_minutes"]
free_window = best["idle_rider"]["free_window_minutes"]
confidence = best["confidence_score"]
extra_km = best["extra_km_for_idle_rider"]
slack_min = free_window - obligation
if slack_min < 10:
risks.append(
f"Tight schedule: only {slack_min} min of slack between idle rider's "
f"estimated finish and fleet completion. Any delay (traffic, kitchen wait) "
f"would eliminate the benefit."
)
if travel_min > 15:
risks.append(
f"Long commute to target kitchen ({travel_min} min). "
f"Kitchen departure time must be precise — a late start erodes time savings."
)
if extra_km > 8:
risks.append(
f"Extra {extra_km} km for the idle rider adds fuel cost and rider fatigue. "
f"Verify this is worthwhile if fleet improvement is marginal."
)
if confidence < 50:
risks.append(
f"Low confidence ({confidence}/100): limited slack or few feasible transfers. "
f"Consider this as a contingency plan rather than a guaranteed improvement."
)
if best.get("feasible_orders_count", 0) < best.get("total_orders_transferred", 1):
risks.append(
"Not all proposed transfers save time — some orders are included to fill "
"the idle rider's route but don't improve individual delivery times."
)
if not risks:
risks.append("No significant risks identified. Transfer looks operationally sound.")
return risks
def _balance_assessment(fleet_summary: dict) -> str:
"""Short human-readable verdict on batch balance quality."""
spread = fleet_summary.get("finish_time_spread_minutes", 0)
stdev = fleet_summary.get("load_balance_stdev", 0)
util = fleet_summary.get("avg_utilisation_pct")
if spread <= 10 and stdev <= 1:
verdict = "Excellent — riders finished close together with balanced loads."
elif spread <= 20 and stdev <= 2:
verdict = "Good — minor imbalance, acceptable for this fleet size."
elif spread <= 35:
verdict = f"Moderate imbalance — {spread} min spread between earliest and latest finish."
else:
verdict = (
f"High imbalance — {spread} min spread. Some riders sat idle while others overran. "
f"Pre-planning dual-kitchen assignments is strongly recommended."
)
if util is not None:
verdict += f" Average rider utilisation: {util}% of batch window."
return verdict

View File

@@ -0,0 +1,300 @@
"""
Geographic Clustering Service for Order Assignment
Uses K-means clustering to group orders by kitchen location.
Enhanced with Geohash encoding for spatial learning (idea.txt data encoding).
"""
import logging
import numpy as np
from typing import List, Dict, Any, Tuple
from collections import defaultdict
from math import radians, cos, sin, asin, sqrt
logger = logging.getLogger(__name__)
class GeohashEncoder:
"""
Geohash Encoding for spatial data.
Converts lat/lon coordinates to grid cell strings for locality capture.
Precision levels:
- 4 chars: ~156km x 156km (regional)
- 5 chars: ~39km x 19km (city-level)
- 6 chars: ~4.9km x 4.9km (neighborhood)
- 7 chars: ~1.2km x 609m (local zone)
"""
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
@classmethod
def encode(cls, lat: float, lon: float, precision: int = 6) -> str:
"""
Encode lat/lon to geohash string.
Args:
lat: Latitude (-90 to 90)
lon: Longitude (-180 to 180)
precision: Number of characters (4-12)
Returns:
Geohash string
"""
if lat == 0 and lon == 0:
return "unknown"
lat_min, lat_max = -90.0, 90.0
lon_min, lon_max = -180.0, 180.0
hash_chars = []
is_lon = True
for _ in range(precision):
char_bits = 0
for _ in range(5):
if is_lon:
mid = (lon_min + lon_max) / 2
if lon >= mid:
char_bits = (char_bits << 1) | 1
lon_min = mid
else:
char_bits = char_bits << 1
lon_max = mid
else:
mid = (lat_min + lat_max) / 2
if lat >= mid:
char_bits = (char_bits << 1) | 1
lat_min = mid
else:
char_bits = char_bits << 1
lat_max = mid
is_lon = not is_lon
hash_chars.append(cls.BASE32[char_bits])
return "".join(hash_chars)
@classmethod
def get_zone_from_geohash(cls, geohash: str) -> Dict[str, Any]:
"""
Extract zone metadata from geohash for ML features.
Returns:
Dict with zone info: zone_id, precision, cell_size, etc.
"""
if not geohash or geohash == "unknown":
return {"zone_id": "unknown", "precision": 0}
precision = len(geohash)
# Approximate cell sizes (in km)
lat_error = 180.0 / (2 ** (precision * 5 // 2)) / 2
lon_error = 360.0 / (2 ** (precision * 5 // 2 + 1)) / 2
# Cell size approximation
cell_width_km = lat_error * 111 # 1 degree lat ≈ 111km
cell_height_km = (
lon_error * 111 * cos(11 * 3.14159 / 180)
) # Adjust for Coimbatore lat
return {
"zone_id": geohash,
"precision": precision,
"cell_width_km": round(cell_width_km, 3),
"cell_height_km": round(cell_height_km, 3),
"prefix_4": geohash[:4] if len(geohash) >= 4 else geohash,
"prefix_5": geohash[:5] if len(geohash) >= 5 else geohash,
"prefix_6": geohash[:6] if len(geohash) >= 6 else geohash,
}
class ClusteringService:
"""Clusters orders geographically to enable balanced rider assignment."""
def __init__(self):
self.earth_radius_km = 6371
self.geohash_encoder = GeohashEncoder()
def haversine(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points in km."""
lon1, lat1, lon2, lat2 = map(
radians, [float(lon1), float(lat1), float(lon2), float(lat2)]
)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(min(1.0, sqrt(a)))
return c * self.earth_radius_km
def get_kitchen_location(self, order: Dict[str, Any]) -> Tuple[float, float]:
"""Extract kitchen coordinates from order."""
try:
lat = float(order.get("pickuplat", 0))
lon = float(order.get("pickuplon") or order.get("pickuplong", 0))
if lat != 0 and lon != 0:
return lat, lon
except (ValueError, TypeError):
pass
return 0.0, 0.0
def _encode_location_features(self, lat: float, lon: float) -> Dict[str, Any]:
"""
Encode location with multiple spatial features (Data Encoding: Geohash + Distance).
Returns:
Dict with geohash, zone info, distance features
"""
features = {}
# Primary geohash encoding (6 chars = ~5km zone for Coimbatore)
geohash_6 = self.geohash_encoder.encode(lat, lon, 6)
features["geohash_6"] = geohash_6
# Fine-grained geohash (7 chars = ~1.2km zone)
geohash_7 = self.geohash_encoder.encode(lat, lon, 7)
features["geohash_7"] = geohash_7
# Coarse geohash for regional grouping (4 chars = ~156km)
geohash_4 = self.geohash_encoder.encode(lat, lon, 4)
features["geohash_4"] = geohash_4
# Zone metadata
features["zone_info"] = self.geohash_encoder.get_zone_from_geohash(geohash_6)
return features
def cluster_orders_by_kitchen(
self, orders: List[Dict[str, Any]], max_cluster_radius_km: float = 3.0
) -> List[Dict[str, Any]]:
"""
Cluster orders by kitchen proximity.
Returns list of clusters, each containing:
- centroid: (lat, lon) of cluster center
- orders: list of orders in this cluster
- kitchen_names: set of kitchen names in cluster
- total_orders: count
- geohash_6: geohash encoding of centroid (NEW)
- zone_info: zone metadata (NEW)
- spatial_features: all location encodings (NEW)
"""
if not orders:
return []
# Group by kitchen location
kitchen_groups = defaultdict(list)
kitchen_coords = {}
for order in orders:
k_name = self._get_kitchen_name(order)
k_lat, k_lon = self.get_kitchen_location(order)
if k_lat == 0:
# Fallback: use delivery location if pickup missing
k_lat = float(order.get("deliverylat", 0))
k_lon = float(order.get("deliverylong", 0))
if k_lat != 0:
kitchen_groups[k_name].append(order)
kitchen_coords[k_name] = (k_lat, k_lon)
# Now cluster kitchens that are close together
clusters = []
processed_kitchens = set()
for k_name, k_orders in kitchen_groups.items():
if k_name in processed_kitchens:
continue
# Start a new cluster with this kitchen
cluster_kitchens = [k_name]
cluster_orders = k_orders[:]
processed_kitchens.add(k_name)
k_lat, k_lon = kitchen_coords[k_name]
# ── CHAIN-MERGE FIX ──────────────────────────────────────────
# Original bug: only checked against the SEED kitchen.
# Example: A→B = 2km, B→C = 2km, A→C = 4km (max_radius = 3km).
# Old code: C is NOT merged (A→C > 3km).
# New code: iteratively re-check remaining kitchens against the
# CURRENT centroid after each merge, so chains like A→B→C
# are correctly collapsed into one cluster.
# ─────────────────────────────────────────────────────────────
changed = True
while changed:
changed = False
# Recompute centroid of current cluster
c_lats = [kitchen_coords[n][0] for n in cluster_kitchens if n in kitchen_coords]
c_lons = [kitchen_coords[n][1] for n in cluster_kitchens if n in kitchen_coords]
c_lat = sum(c_lats) / len(c_lats) if c_lats else k_lat
c_lon = sum(c_lons) / len(c_lons) if c_lons else k_lon
for other_name, other_coords in kitchen_coords.items():
if other_name in processed_kitchens:
continue
other_lat, other_lon = other_coords
dist = self.haversine(c_lat, c_lon, other_lat, other_lon)
if dist <= max_cluster_radius_km:
cluster_kitchens.append(other_name)
cluster_orders.extend(kitchen_groups[other_name])
processed_kitchens.add(other_name)
changed = True # centroid shifted — re-scan remaining
# Calculate cluster centroid
lats = []
lons = []
for order in cluster_orders:
lat, lon = self.get_kitchen_location(order)
if lat != 0:
lats.append(lat)
lons.append(lon)
if lats:
centroid_lat = sum(lats) / len(lats)
centroid_lon = sum(lons) / len(lons)
else:
centroid_lat, centroid_lon = k_lat, k_lon
# ENHANCED: Add geohash encoding features
spatial_features = self._encode_location_features(
centroid_lat, centroid_lon
)
clusters.append(
{
"centroid": (centroid_lat, centroid_lon),
"orders": cluster_orders,
"kitchen_names": set(cluster_kitchens),
"total_orders": len(cluster_orders),
# NEW: Geohash encoding features
"geohash_6": spatial_features["geohash_6"],
"geohash_7": spatial_features["geohash_7"],
"geohash_4": spatial_features["geohash_4"],
"zone_info": spatial_features["zone_info"],
"spatial_features": spatial_features,
}
)
# Sort clusters by order count (largest first)
clusters.sort(key=lambda x: x["total_orders"], reverse=True)
logger.info(
f"Created {len(clusters)} clusters from {len(kitchen_groups)} kitchens with geohash encoding"
)
return clusters
def _get_kitchen_name(self, order: Dict[str, Any]) -> str:
"""Extract kitchen name from order."""
possible_keys = [
"pickupcustomer", # confirmed primary field in production orders
"locationname", # confirmed backup field in production orders
"storename", "store_name",
"restaurantname", "restaurant_name",
"kitchenname", "kitchen_name",
"partnername", "partner_name",
"tenantname",
]
for key in possible_keys:
if key in order and order[key]:
return str(order[key]).strip()
return "Unknown"

View File

@@ -0,0 +1,864 @@
"""
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

View 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

View File

@@ -0,0 +1,327 @@
"""
GPS Kalman Filter \u2014 rider-api
A 1D Kalman filter applied independently to latitude and longitude
to smooth noisy GPS coordinates from riders and delivery points.
Why Kalman for GPS?
- GPS readings contain measurement noise (\u00b15\u201315m typical, \u00b150m poor signal)
- Rider location pings can "jump" due to bad signal or device error
- Kalman filter gives an optimal estimate by balancing:
(1) Previous predicted position (process model)
(2) New GPS measurement (observation model)
Design:
- Separate filter instance per rider (stateful \u2014 preserves history)
- `CoordinateKalmanFilter` \u2014 single lat/lon smoother
- `GPSKalmanFilter` \u2014 wraps two CoordinateKalmanFilters (lat + lon)
- `RiderKalmanRegistry` \u2014 manages per-rider filter instances
- `smooth_coordinates()` \u2014 stateless single-shot smoother for delivery coords
Usage:
# Stateless (one-shot, no history \u2014 for delivery coords):
smooth_lat, smooth_lon = smooth_coordinates(raw_lat, raw_lon)
# Stateful (per-rider, preserves motion history):
registry = RiderKalmanRegistry()
lat, lon = registry.update(rider_id=1116, lat=11.0067, lon=76.9558)
"""
import logging
import time
from typing import Dict, Optional, Tuple
logger = logging.getLogger(__name__)
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# CORE 1D KALMAN FILTER
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
class CoordinateKalmanFilter:
"""
1-dimensional Kalman filter for a single GPS coordinate (lat or lon).
State model: position only (constant position with random walk).
Equations:
Prediction: x\u0302\u2096\u207b = x\u0302\u2096\u208b\u2081 (no movement assumed between pings)
P\u0302\u2096\u207b = P\u2096\u208b\u2081 + Q (uncertainty grows over time)
Update: K\u2096 = P\u0302\u2096\u207b / (P\u0302\u2096\u207b + R) (Kalman gain)
x\u0302\u2096 = x\u0302\u2096\u207b + K\u2096\u00b7(z\u2096 - x\u0302\u2096\u207b) (weighted fusion)
P\u2096 = (1 - K\u2096)\u00b7P\u0302\u2096\u207b (update uncertainty)
Parameters:
process_noise (Q): How much position can change between measurements.
Higher = filter trusts new measurements more (less smoothing).
measurement_noise (R): GPS measurement uncertainty.
Higher = filter trusts history more (more smoothing).
"""
def __init__(
self,
process_noise: float = 1e-4,
measurement_noise: float = 0.01,
initial_uncertainty: float = 1.0,
):
self.Q = process_noise
self.R = measurement_noise
self._x: Optional[float] = None
self._P: float = initial_uncertainty
@property
def initialized(self) -> bool:
return self._x is not None
def update(self, measurement: float) -> float:
"""Process one new measurement and return the filtered estimate."""
if not self.initialized:
self._x = measurement
return self._x
# Predict
x_prior = self._x
P_prior = self._P + self.Q
# Update
K = P_prior / (P_prior + self.R)
self._x = x_prior + K * (measurement - x_prior)
self._P = (1.0 - K) * P_prior
return self._x
def reset(self):
self._x = None
self._P = 1.0
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# 2D GPS KALMAN FILTER (lat + lon)
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
class GPSKalmanFilter:
"""
Two-dimensional GPS smoother using independent 1D Kalman filters
for latitude and longitude.
"""
def __init__(
self,
process_noise: float = 1e-4,
measurement_noise: float = 0.01,
):
self.lat_filter = CoordinateKalmanFilter(process_noise, measurement_noise)
self.lon_filter = CoordinateKalmanFilter(process_noise, measurement_noise)
self.last_updated: float = time.time()
self.update_count: int = 0
def update(self, lat: float, lon: float) -> Tuple[float, float]:
"""Feed a new GPS reading and get the smoothed (lat, lon)."""
if not self._is_valid_coord(lat, lon):
if self.lat_filter.initialized:
return self.lat_filter._x, self.lon_filter._x
return lat, lon
smooth_lat = self.lat_filter.update(lat)
smooth_lon = self.lon_filter.update(lon)
self.last_updated = time.time()
self.update_count += 1
return smooth_lat, smooth_lon
def get_estimate(self) -> Optional[Tuple[float, float]]:
if self.lat_filter.initialized:
return self.lat_filter._x, self.lon_filter._x
return None
def reset(self):
self.lat_filter.reset()
self.lon_filter.reset()
self.update_count = 0
@staticmethod
def _is_valid_coord(lat: float, lon: float) -> bool:
try:
lat, lon = float(lat), float(lon)
return (
-90.0 <= lat <= 90.0
and -180.0 <= lon <= 180.0
and not (lat == 0.0 and lon == 0.0)
)
except (TypeError, ValueError):
return False
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# PER-RIDER FILTER REGISTRY
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
class RiderKalmanRegistry:
"""
Maintains per-rider Kalman filter instances across calls.
Stale filters (> 30 min silence) are automatically reset.
"""
def __init__(
self,
process_noise: float = 1e-4,
measurement_noise: float = 0.01,
stale_seconds: float = 1800.0,
):
self._filters: Dict[str, GPSKalmanFilter] = {}
self._process_noise = process_noise
self._measurement_noise = measurement_noise
self._stale_seconds = stale_seconds
def _get_or_create(self, rider_id) -> GPSKalmanFilter:
key = str(rider_id)
now = time.time()
if key in self._filters:
f = self._filters[key]
if now - f.last_updated > self._stale_seconds:
f.reset()
return f
self._filters[key] = GPSKalmanFilter(
process_noise=self._process_noise,
measurement_noise=self._measurement_noise,
)
return self._filters[key]
def update(self, rider_id, lat: float, lon: float) -> Tuple[float, float]:
return self._get_or_create(rider_id).update(lat, lon)
def get_estimate(self, rider_id) -> Optional[Tuple[float, float]]:
key = str(rider_id)
if key in self._filters:
return self._filters[key].get_estimate()
return None
def reset_rider(self, rider_id):
key = str(rider_id)
if key in self._filters:
self._filters[key].reset()
def clear_all(self):
self._filters.clear()
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# GLOBAL REGISTRY (process-level singleton)
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
_global_registry = RiderKalmanRegistry()
def get_registry() -> RiderKalmanRegistry:
"""Get the process-level rider Kalman filter registry."""
return _global_registry
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# STATELESS COORDINATE SMOOTHER
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
def smooth_coordinates(
lat: float,
lon: float,
*,
prior_lat: Optional[float] = None,
prior_lon: Optional[float] = None,
process_noise: float = 1e-4,
measurement_noise: float = 0.01,
) -> Tuple[float, float]:
"""
Stateless single-shot GPS smoother.
If a prior is provided, blends the new reading towards it.
"""
f = GPSKalmanFilter(process_noise=process_noise, measurement_noise=measurement_noise)
if prior_lat is not None and prior_lon is not None:
try:
_flat = float(prior_lat)
_flon = float(prior_lon)
if GPSKalmanFilter._is_valid_coord(_flat, _flon):
f.update(_flat, _flon)
except (TypeError, ValueError):
pass
return f.update(lat, lon)
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
# BATCH SMOOTHERS
# \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
def smooth_rider_locations(riders: list) -> list:
"""
Apply Kalman smoothing to a list of rider dicts in-place using
the global per-rider registry (history preserved across calls).
Reads/writes: latitude, longitude (and currentlat/currentlong if present).
Adds: _kalman_smoothed = True on each processed rider.
"""
registry = get_registry()
for rider in riders:
try:
rider_id = (
rider.get("userid") or rider.get("riderid") or
rider.get("id") or "unknown"
)
raw_lat = float(rider.get("latitude") or rider.get("currentlat") or 0)
raw_lon = float(rider.get("longitude") or rider.get("currentlong") or 0)
if raw_lat == 0.0 and raw_lon == 0.0:
continue
smooth_lat, smooth_lon = registry.update(rider_id, raw_lat, raw_lon)
# Cast back to string for Go compatibility
s_lat, s_lon = str(round(smooth_lat, 8)), str(round(smooth_lon, 8))
rider["latitude"] = s_lat
rider["longitude"] = s_lon
if "currentlat" in rider:
rider["currentlat"] = s_lat
if "currentlong" in rider:
rider["currentlong"] = s_lon
rider["_kalman_smoothed"] = True
except Exception as e:
logger.debug(f"Kalman rider smoothing skipped: {e}")
return riders
def smooth_order_coordinates(orders: list) -> list:
"""
Validate and lightly normalise delivery coordinates in a list of order dicts.
DESIGN NOTE — why we do NOT use Kalman filtering here:
─────────────────────────────────────────────────────
Kalman filtering is a *temporal* smoother: it blends successive measurements
from the same sensor over time. Delivery coordinates are a single static
point (one measurement). Feeding the kitchen location as a "prior" would
pull the customer's address toward the kitchen — exactly wrong.
Per-customer GPS accuracy is handled upstream by the FAISS coordinate store
(verified historical rider-confirmed delivery points). This function only
normalises the coordinate fields to floats so downstream code never sees
raw strings or None values.
Modifies orders in-place. Returns the same list.
"""
for order in orders:
try:
dlat_raw = order.get("deliverylat") or order.get("droplat")
dlon_raw = order.get("deliverylong") or order.get("droplon")
if dlat_raw is None or dlon_raw is None:
continue
dlat = float(dlat_raw)
dlon = float(dlon_raw)
if not GPSKalmanFilter._is_valid_coord(dlat, dlon):
continue
# Normalise to string (Go service expects string coordinates)
s_lat = str(round(dlat, 8))
s_lon = str(round(dlon, 8))
order["deliverylat"] = s_lat
order["deliverylong"] = s_lon
if "droplat" in order:
order["droplat"] = s_lat
if "droplon" in order:
order["droplon"] = s_lon
except Exception as e:
logger.debug(f"Coordinate normalisation skipped: {e}")
return orders

View File

@@ -0,0 +1,127 @@
"""
Realistic ETA Calculator for Delivery Operations
Accounts for:
- City traffic conditions
- Stop time at pickup/delivery
- Navigation time
- Parking/finding address time
- Different speeds for different order types
"""
import logging
from typing import Dict, Any
logger = logging.getLogger(__name__)
class RealisticETACalculator:
"""
Calculates realistic ETAs accounting for real-world delivery conditions.
"""
def __init__(self):
from app.config.dynamic_config import get_config
cfg = get_config()
# BASE SPEED (km/h) - Driven by the DB configuration
base_speed = cfg.get("avg_speed_kmh", 18.0)
# REALISTIC SPEEDS based on time of day
self.CITY_SPEED_HEAVY_TRAFFIC = base_speed * 0.7 # Usually ~12 km/h
self.CITY_SPEED_MODERATE = base_speed # Usually ~18 km/h
self.CITY_SPEED_LIGHT = base_speed * 1.2 # Usually ~21.6 km/h
# TIME BUFFERS (minutes)
self.PICKUP_TIME = cfg.get("eta_pickup_time_min", 3.0)
self.DELIVERY_TIME = cfg.get("eta_delivery_time_min", 4.0)
self.NAVIGATION_BUFFER = cfg.get("eta_navigation_buffer_min", 1.5)
# DISTANCE-BASED SPEED SELECTION
# Short distances (<2km) are slower due to more stops/starts
# Long distances (>8km) might have highway portions
self.SHORT_TRIP_FACTOR = cfg.get("eta_short_trip_factor", 0.8)
self.LONG_TRIP_FACTOR = cfg.get("eta_long_trip_factor", 1.1)
def calculate_eta(self,
distance_km: float,
is_first_order: bool = False,
order_type: str = "Economy",
time_of_day: str = "peak") -> int:
"""
Calculate realistic ETA in minutes.
Args:
distance_km: Distance to travel in kilometers
is_first_order: If True, includes pickup time
order_type: "Economy", "Premium", or "Risky"
time_of_day: "peak", "normal", or "light" traffic
Returns:
ETA in minutes (rounded up for safety)
"""
if distance_km <= 0:
return 0
# 1. SELECT SPEED BASED ON CONDITIONS
if time_of_day == "peak":
base_speed = self.CITY_SPEED_HEAVY_TRAFFIC
elif time_of_day == "light":
base_speed = self.CITY_SPEED_LIGHT
else:
base_speed = self.CITY_SPEED_MODERATE
# 2. ADJUST SPEED BASED ON DISTANCE
# Short trips are slower (more intersections, traffic lights)
if distance_km < 2.0:
effective_speed = base_speed * self.SHORT_TRIP_FACTOR
elif distance_km > 8.0:
effective_speed = base_speed * self.LONG_TRIP_FACTOR
else:
effective_speed = base_speed
# 3. CALCULATE TRAVEL TIME
travel_time = (distance_km / effective_speed) * 60 # Convert to minutes
# 4. ADD BUFFERS
total_time = travel_time
# Pickup time (only for first order in sequence)
if is_first_order:
total_time += self.PICKUP_TIME
# Delivery time (always)
total_time += self.DELIVERY_TIME
# Navigation buffer (proportional to distance)
if distance_km > 3.0:
total_time += self.NAVIGATION_BUFFER
# 5. SAFETY MARGIN (Round up to next minute)
# Riders prefer to arrive early than late
eta_minutes = int(total_time) + 1
return eta_minutes
def get_time_of_day_category() -> str:
"""
Determine current traffic conditions based on time.
Returns:
"peak", "normal", or "light"
"""
from datetime import datetime
current_hour = datetime.now().hour
# Peak hours: 8-10 AM, 12-2 PM, 5-8 PM
if (8 <= current_hour < 10) or (12 <= current_hour < 14) or (17 <= current_hour < 20):
return "peak"
# Light traffic: Late night/early morning
elif current_hour < 7 or current_hour >= 22:
return "light"
else:
return "normal"

View File

@@ -0,0 +1,171 @@
"""
Rider Affinity Service (learned, agentic)
=========================================
Learns each rider's real kitchen affinity and operating area from the local
delivery mirror (`delivery_raw`) and exposes them MERGED with the curated config
in `app/config/rider_preferences.py`:
* get_preferred_kitchens() = curated RIDER_PREFERRED_KITCHENS learned
(rider served a kitchen >= `rider_affinity_min_deliveries` times). Union only —
never drops a curated owner.
* get_home_locations() = curated RIDER_HOME_LOCATIONS, with a learned drop
centroid filled in ONLY for riders missing from the config.
These feed SOFT steering (preference discount / home bonus / distance bypass) in
the optimizer + assignment service. HARD kitchen locks and BLOCKED_RIDERS remain
sourced from the curated config — learned data can only broaden preference, never
change who is *eligible* for a kitchen. Recomputed by the autonomous agent loop.
"""
import logging
import threading
from collections import defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from app.config.rider_preferences import RIDER_PREFERRED_KITCHENS, RIDER_HOME_LOCATIONS
logger = logging.getLogger(__name__)
def _to_int_rid(v: Any) -> Optional[int]:
try:
return int(v)
except (TypeError, ValueError):
return None
class RiderAffinityService:
def __init__(self):
self._learned_pref: Dict[int, List[str]] = {}
self._learned_home: Dict[int, Tuple[float, float]] = {}
self._last_refreshed: Optional[datetime] = None
self._lock = threading.Lock()
# ------------------------------------------------------------------
def refresh(self, days: Optional[int] = None) -> Dict[str, Any]:
"""Recompute learned affinity from the local delivery mirror (no DB hit)."""
from app.config.dynamic_config import get_config
from app.services.routing.delivery_history_service import (
get_delivery_history_service, normalize_kitchen,
)
cfg = get_config()
window = int(days if days is not None else cfg.get("eta_history_days", 14))
min_n = int(cfg.get("rider_affinity_min_deliveries", 10))
rows = get_delivery_history_service()._load_raw_rows(window)
counts: Dict[int, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
coords: Dict[int, List[Tuple[float, float]]] = defaultdict(list)
for r in rows:
rid = _to_int_rid(r.get("userid"))
if rid is None:
continue
kitchen = normalize_kitchen(r.get("pickupcustomer"))
if kitchen and kitchen != "?":
counts[rid][kitchen] += 1
try:
la, lo = float(r.get("dlat")), float(r.get("dlon"))
if la and lo:
coords[rid].append((la, lo))
except (TypeError, ValueError):
continue
learned_pref = {
rid: [k for k, n in kc.items() if n >= min_n]
for rid, kc in counts.items()
}
learned_pref = {rid: ks for rid, ks in learned_pref.items() if ks}
learned_home = {
rid: (round(sum(p[0] for p in pts) / len(pts), 6),
round(sum(p[1] for p in pts) / len(pts), 6))
for rid, pts in coords.items() if pts
}
with self._lock:
self._learned_pref = learned_pref
self._learned_home = learned_home
self._last_refreshed = datetime.utcnow()
summary = {
"status": "ok",
"riders_with_learned_kitchens": len(learned_pref),
"riders_with_learned_home": len(learned_home),
"min_deliveries": min_n,
"window_days": window,
"refreshed_at": self._last_refreshed.isoformat(),
}
logger.info(
f"[Affinity] learned kitchens for {len(learned_pref)} riders, "
f"home for {len(learned_home)} (min_deliveries={min_n})"
)
return summary
def _ensure_loaded(self) -> None:
if self._last_refreshed is None:
try:
self.refresh()
except Exception as e:
logger.debug(f"[Affinity] lazy refresh failed: {e}")
# ------------------------------------------------------------------
def get_preferred_kitchens(self) -> Dict[int, List[str]]:
"""Curated config learned (union — never drops a curated owner)."""
from app.config.dynamic_config import get_config
if not bool(get_config().get("rider_affinity_enabled", True)):
return {rid: list(v) for rid, v in RIDER_PREFERRED_KITCHENS.items()}
self._ensure_loaded()
merged: Dict[int, List[str]] = {rid: list(v) for rid, v in RIDER_PREFERRED_KITCHENS.items()}
with self._lock:
learned = {rid: list(v) for rid, v in self._learned_pref.items()}
for rid, kitchens in learned.items():
base = merged.setdefault(rid, [])
base_lower = {b.lower() for b in base}
for k in kitchens:
if k.lower() not in base_lower:
base.append(k)
return merged
def get_home_locations(self) -> Dict[int, Tuple[float, float]]:
"""Curated home primary; learned drop centroid only for riders absent from config."""
from app.config.dynamic_config import get_config
if not bool(get_config().get("rider_affinity_enabled", True)):
return dict(RIDER_HOME_LOCATIONS)
self._ensure_loaded()
merged: Dict[int, Tuple[float, float]] = dict(RIDER_HOME_LOCATIONS)
with self._lock:
learned = dict(self._learned_home)
for rid, home in learned.items():
if rid not in merged or merged.get(rid) in (None, (0.0, 0.0)):
merged[rid] = home
return merged
def get_summary(self) -> Dict[str, Any]:
"""Learned-vs-config diff for the admin endpoint."""
self._ensure_loaded()
with self._lock:
learned_pref = {rid: list(v) for rid, v in self._learned_pref.items()}
learned_home = dict(self._learned_home)
config_riders = set(RIDER_PREFERRED_KITCHENS)
new_pref_riders = sorted(set(learned_pref) - config_riders)
return {
"last_refreshed": self._last_refreshed.isoformat() if self._last_refreshed else None,
"config_preferred_riders": sorted(config_riders),
"learned_preferred": {str(k): v for k, v in sorted(learned_pref.items())},
"riders_newly_learned_not_in_config": new_pref_riders,
"learned_home_count": len(learned_home),
}
_affinity: Optional[RiderAffinityService] = None
_affinity_lock = threading.Lock()
def get_rider_affinity() -> RiderAffinityService:
global _affinity
with _affinity_lock:
if _affinity is None:
_affinity = RiderAffinityService()
return _affinity

View File

@@ -0,0 +1,233 @@
"""
Road-Sequencing Decision Agent
==============================
Autonomous controller for the `routing_use_road_distance` feature. Instead of a
human flipping a config flag, this agent periodically *measures* whether road-aware
sequencing (road travel-time matrix + OR-Tools open-TSP) actually beats the default
straight-line ordering on REAL recent batches, and turns the feature on or off by
itself — re-checking every cycle so it self-corrects if the gain ever disappears.
Decision (with hysteresis, so it doesn't flap):
mean travel-time gain >= routing_auto_enable_gain_pct -> enable
mean travel-time gain < routing_auto_disable_gain_pct -> disable
in between -> leave as-is
Every decision is stored (auditable) in DynamicConfig under `routing_road_eval`
and exposed via GET /api/v1/ml/road-eval. Cost is bounded: it evaluates only
`routing_eval_sample_batches` batches once per `routing_eval_interval_hours`.
"""
import asyncio
import json
import logging
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import numpy as np
logger = logging.getLogger(__name__)
def _total(order_idx: List[int], matrix: List[List[float]]) -> float:
"""Total open-route cost: origin(0) -> drops in `order_idx` (1-based into matrix)."""
seq = [0] + [i + 1 for i in order_idx]
return sum(matrix[seq[k]][seq[k + 1]] for k in range(len(seq) - 1))
class RoadSequencingAgent:
"""Measures road-vs-aerial sequencing on real batches and auto-toggles the flag."""
def __init__(self):
self._opt = None # lazy RouteOptimizer
self._scheduler_started = False
self._lock = threading.Lock()
self.last_decision: Dict[str, Any] = {}
def _optimizer(self):
if self._opt is None:
from app.services.routing.route_optimizer import RouteOptimizer
self._opt = RouteOptimizer()
return self._opt
async def evaluate(self, sample_batches: int = 8, days: int = 14) -> Dict[str, Any]:
"""
Compare aerial-order vs road-order travel time on sampled real batches.
Returns mean gain % and per-batch detail. No flag changes here.
"""
from app.services.routing.delivery_history_service import get_delivery_history_service
from app.core.arrow_utils import calculate_haversine_matrix_vectorized
opt = self._optimizer()
if not opt.use_google_maps:
return {"evaluated": 0, "reason": "no_google_key", "mean_gain_pct": 0.0}
batches = get_delivery_history_service().sample_batches(
days=days, limit=sample_batches
)
if not batches:
return {"evaluated": 0, "reason": "no_local_batches", "mean_gain_pct": 0.0}
per_batch: List[Dict[str, Any]] = []
for b in batches:
origin, drops = b["origin"], b["drops"]
locs = [origin] + drops
matrix = await opt._road_duration_matrix(locs)
if matrix is None:
continue
# aerial order (current production behaviour)
lats = np.array([p[0] for p in locs])
lons = np.array([p[1] for p in locs])
aerial = calculate_haversine_matrix_vectorized(lats, lons)
aer = [i - 1 for i in opt._two_opt_improve(opt._solve_greedy(locs, aerial), aerial) if i != 0]
# road order (OR-Tools open-TSP on the road travel-time matrix)
road = [i - 1 for i in opt._solve_tsp_ortools(locs, matrix) if i != 0]
# Human's ACTUAL route: drops are already in delivered order
# (_load_raw_rows is ORDER BY deliverytime), so identity = what the
# rider really drove. This is the "do we beat the humans?" baseline.
human = list(range(len(drops)))
t_aer = _total(aer, matrix)
t_road = _total(road, matrix)
t_human = _total(human, matrix)
gain = (100.0 * (t_aer - t_road) / t_aer) if t_aer > 0 else 0.0
gain_vs_human = (100.0 * (t_human - t_road) / t_human) if t_human > 0 else 0.0
per_batch.append({
"rider": b["rider"], "day": b["day"], "drops": len(drops),
"aerial_min": round(t_aer, 1), "road_min": round(t_road, 1),
"human_min": round(t_human, 1),
"gain_pct": round(gain, 1),
"gain_vs_human_pct": round(gain_vs_human, 1),
"beat_human": t_road <= t_human + 1e-9,
})
if not per_batch:
return {"evaluated": 0, "reason": "matrix_unavailable", "mean_gain_pct": 0.0}
n = len(per_batch)
mean_gain = sum(x["gain_pct"] for x in per_batch) / n
mean_vs_human = sum(x["gain_vs_human_pct"] for x in per_batch) / n
beats = sum(1 for x in per_batch if x["beat_human"])
return {
"evaluated": n,
"mean_gain_pct": round(mean_gain, 2),
"median_gain_pct": round(sorted(x["gain_pct"] for x in per_batch)[n // 2], 2),
"human_beat_rate_pct": round(100.0 * beats / n, 1),
"human_beat_count": f"{beats}/{n}",
"mean_gain_vs_human_pct": round(mean_vs_human, 2),
"per_batch": per_batch,
}
def decide_and_apply(self) -> Dict[str, Any]:
"""Run an evaluation and autonomously enable/disable road sequencing."""
from app.config.dynamic_config import get_config
cfg = get_config()
sample = int(cfg.get("routing_eval_sample_batches", 8))
days = int(cfg.get("routing_eval_days", 14))
enable_thr = float(cfg.get("routing_auto_enable_gain_pct", 3.0))
disable_thr = float(cfg.get("routing_auto_disable_gain_pct", 1.0))
min_batches = int(cfg.get("routing_eval_min_batches", 3))
auto_manage = bool(cfg.get("routing_auto_manage", True))
try:
result = asyncio.run(self.evaluate(sample_batches=sample, days=days))
except RuntimeError:
# An event loop is already running in this thread — use a fresh one.
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(self.evaluate(sample_batches=sample, days=days))
finally:
loop.close()
current = bool(cfg.get("routing_use_road_distance", False))
evaluated = result.get("evaluated", 0)
gain = result.get("mean_gain_pct", 0.0)
action = "kept"
new_state = current
if not auto_manage:
action = "auto_manage_off"
elif evaluated < min_batches:
action = "insufficient_data"
else:
if gain >= enable_thr and not current:
new_state = True
cfg.set("routing_use_road_distance", True, source="road_agent")
action = "enabled"
elif gain < disable_thr and current:
new_state = False
cfg.set("routing_use_road_distance", False, source="road_agent")
action = "disabled"
decision = {
"decided_at": datetime.utcnow().isoformat(),
"action": action,
"flag_before": current,
"flag_after": new_state,
"mean_gain_pct": gain,
"enable_threshold_pct": enable_thr,
"disable_threshold_pct": disable_thr,
"evaluation": result,
}
self.last_decision = decision
try:
# Persist a compact copy for audit (without the full per-batch list).
compact = {k: v for k, v in decision.items() if k != "evaluation"}
compact["evaluated"] = evaluated
compact["human_beat_rate_pct"] = result.get("human_beat_rate_pct")
compact["mean_gain_vs_human_pct"] = result.get("mean_gain_vs_human_pct")
cfg.set("routing_road_eval", compact, source="road_agent")
except Exception:
pass
logger.info(
f"[RoadAgent] action={action} gain_vs_aerial={gain}% evaluated={evaluated} "
f"flag {current}->{new_state}"
)
if evaluated:
logger.info(
f"[RoadAgent] beat humans on {result.get('human_beat_count', '?')} batches "
f"(avg {result.get('mean_gain_vs_human_pct', 0)}% faster than actual delivered order)"
)
return decision
def ensure_background_agent(self, interval_hours: int = 24,
warmup_seconds: int = 120) -> bool:
"""Start the autonomous decision loop once (daemon thread)."""
with self._lock:
if self._scheduler_started:
return False
self._scheduler_started = True
def _loop():
# Let the ETA sync agent populate the local mirror first.
time.sleep(max(0, warmup_seconds))
logger.info(f"[RoadAgent] autonomous road-sequencing decision loop started "
f"(interval={interval_hours}h)")
while True:
try:
self.decide_and_apply()
except Exception as e:
logger.warning(f"[RoadAgent] decision cycle failed (will retry): {e}")
from app.config.dynamic_config import get_config
hrs = int(get_config().get("routing_eval_interval_hours", interval_hours))
time.sleep(max(1, hrs) * 3600)
threading.Thread(target=_loop, daemon=True, name="road-seq-agent").start()
return True
_agent: Optional[RoadSequencingAgent] = None
_agent_lock = threading.Lock()
def get_road_agent() -> RoadSequencingAgent:
global _agent
with _agent_lock:
if _agent is None:
_agent = RoadSequencingAgent()
return _agent

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
import logging
from typing import List, Dict, Any
logger = logging.getLogger(__name__)
class ZoneService:
"""
Service to classify orders and riders into geographic zones.
Defaulting to Coimbatore logic as per user context.
"""
# Approximate Center of Coimbatore (Gandhipuram/Bus Stand area)
CENTER_LAT = 11.0168
CENTER_LON = 76.9558
def __init__(self):
pass
def determine_zone(self, lat: float, lon: float) -> str:
"""
Determine the zone (North, South, East, West, etc.) based on coordinates.
"""
if lat == 0 or lon == 0:
return "Unknown"
lat_diff = lat - self.CENTER_LAT
lon_diff = lon - self.CENTER_LON
# Simple Quadrant Logic
# North: +Lat
# South: -Lat
# East: +Lon
# West: -Lon
# Define a small central buffer (0.01 degrees ~ 1.1km)
buffer = 0.010
is_north = lat_diff > buffer
is_south = lat_diff < -buffer
is_east = lon_diff > buffer
is_west = lon_diff < -buffer
zone_parts = []
if is_north: zone_parts.append("North")
elif is_south: zone_parts.append("South")
if is_east: zone_parts.append("East")
elif is_west: zone_parts.append("West")
if not zone_parts:
return "Central"
return " ".join(zone_parts)
def group_by_zones(self, flat_orders: List[Dict[str, Any]], unassigned_orders: List[Dict[str, Any]] = None, fuel_charge: float = 2.5, base_pay: float = 0.0) -> Dict[str, Any]:
"""
Group a flat list of optimized orders into Zones -> Riders -> Orders.
Calculates profit per order and per zone.
"""
zones_map = {} # "North East": { "riders": { rider_id: [orders] } }
unassigned_orders = unassigned_orders or []
# Merge both for initial processing if you want everything zoned
all_to_process = []
for o in flat_orders:
all_to_process.append((o, True))
for o in unassigned_orders:
all_to_process.append((o, False))
for order, is_assigned in all_to_process:
# 1. Extract Coords
try:
# Prefer Delivery location for zoning (where the customer is)
lat = float(order.get("deliverylat") or order.get("droplat") or 0)
lon = float(order.get("deliverylong") or order.get("droplon") or 0)
pincode = str(order.get("deliveryzip") or "")
except:
lat, lon, pincode = 0, 0, ""
# 2. Get Zone
zone_name = self.determine_zone(lat, lon)
order["zone_name"] = zone_name
# 3. Initialize Zone Bucket
if zone_name not in zones_map:
zones_map[zone_name] = {
"riders_map": {},
"total_orders": 0,
"assigned_orders": 0,
"unassigned_orders": [],
"total_kms": 0.0,
"total_profit": 0.0
}
# 4. Add to Rider bucket within Zone
rider_id = order.get("userid") or order.get("_id")
# Track kms and profit for this zone
try:
# 'actualkms' is preferred for delivery distance
dist = float(order.get("actualkms", order.get("previouskms", 0)))
zones_map[zone_name]["total_kms"] += dist
# Individual charge for this order: Fixed Base + Variable Distance
order_amount = float(order.get("orderamount") or order.get("deliveryamount") or 0)
rider_payment = dist * fuel_charge
profit = order_amount - rider_payment
order["rider_charge"] = round(rider_payment, 2)
order["profit"] = round(profit, 2)
# ── PROFIT CLASS (separate from ordertype) ───────────────
# `ordertype` is set by route_optimizer based on delivery
# DISTANCE (Economy ≤5km, Premium ≤12km, Risky >12km).
# Overwriting it here with a profit-based label broke ETA
# calculations and downstream routing logic.
# Use `profit_class` for profit-based analytics instead.
if profit <= 0:
order["profit_class"] = "Loss"
elif profit <= 5:
order["profit_class"] = "Marginal"
elif profit <= 10:
order["profit_class"] = "Profitable"
else:
order["profit_class"] = "HighMargin"
zones_map[zone_name]["total_profit"] += profit
except:
pass
# If strictly unassigned order (no rider), put in unassigned
if not is_assigned:
zones_map[zone_name]["unassigned_orders"].append(order)
else:
str_rid = str(rider_id)
if str_rid not in zones_map[zone_name]["riders_map"]:
zones_map[zone_name]["riders_map"][str_rid] = {
"rider_details": {
"id": str_rid,
"name": order.get("username", "Unknown")
},
"orders": []
}
zones_map[zone_name]["riders_map"][str_rid]["orders"].append(order)
zones_map[zone_name]["assigned_orders"] += 1
zones_map[zone_name]["total_orders"] += 1
# 5. Restructure for API Response
output_zones = []
zone_metrics = []
sorted_zone_names = sorted(zones_map.keys())
for z_name in sorted_zone_names:
z_data = zones_map[z_name]
# Flatten riders map
riders_list = []
for r_id, r_data in z_data["riders_map"].items():
riders_list.append({
"rider_id": r_data["rider_details"]["id"],
"rider_name": r_data["rider_details"]["name"],
"orders_count": len(r_data["orders"]),
"orders": r_data["orders"]
})
# Create the flat metric summary
metrics = {
"zone_name": z_name,
"total_orders": z_data["total_orders"],
"assigned_orders": z_data["assigned_orders"],
"unassigned_orders_count": len(z_data["unassigned_orders"]),
"active_riders_count": len(riders_list),
"total_delivery_kms": round(z_data["total_kms"], 2),
"total_profit": round(z_data["total_profit"], 2)
}
zone_metrics.append(metrics)
# Create the detailed zone object with flattened metrics
zone_obj = {
"zone_name": z_name,
"total_orders": metrics["total_orders"],
"active_riders_count": metrics["active_riders_count"],
"assigned_orders": metrics["assigned_orders"],
"unassigned_orders_count": metrics["unassigned_orders_count"],
"total_delivery_kms": metrics["total_delivery_kms"],
"total_profit": metrics["total_profit"],
"riders": riders_list,
"unassigned_orders": z_data["unassigned_orders"]
}
output_zones.append(zone_obj)
return {
"detailed_zones": output_zones,
"zone_analysis": zone_metrics
}

View File

@@ -0,0 +1 @@
"""Vector services package."""

View File

@@ -0,0 +1,766 @@
"""
Delivery History Store
======================
Pattern-first vector rider lookup using 30-day delivery history.
Works on any platform — no faiss-cpu dependency.
How it works
------------
At startup the CSV is parsed once and two structures are built:
1. PATTERN TABLE (primary, O(1) lookup)
City divided into ~1.1 km grid cells (round coords to 2 d.p.).
For every (kitchen, zone_cell) pair we count how many times each
rider delivered there. A "clear pattern" = one rider owns ≥ 60 %
of all deliveries in that cell AND ≥ 3 total deliveries.
From the 3 500-row CSV:
• 177 unique (kitchen, zone) cells
• 121 clear dominant-rider patterns (68 %)
• 42 cells where 1 rider owns 100 %
2. VECTOR INDEX (fallback, ~0.5 ms)
Pure-NumPy brute-force L2 search over 4D vectors:
[pickuplat, pickuplon, deliverylat, deliverylong]
Used ONLY when the pattern table has no clear answer.
If faiss-cpu is installed it is used instead of NumPy (faster
for very large indexes), but NumPy is the default.
Disk persistence
----------------
ml_data/faiss_history/
delivery_history_vectors.npy float32 (N, 4) array
delivery_history_records.pkl list of record dicts
delivery_history.meta JSON: csv_mtime, counts
Priority chain in the assignment endpoint:
1. Pattern table (dominant rider for this kitchen + delivery zone)
2. Vector K-NN (nearest-neighbour vote, fallback for new areas)
3. rider_preferences.py hard lock
4. VRP proximity solver
"""
import csv
import json
import logging
import os
import pickle
from collections import Counter, defaultdict
from threading import Lock
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
CSV_PATH = os.getenv("DELIVERY_HISTORY_CSV", "delivery_details.csv")
CORRECTIONS_PATH = os.getenv("DELIVERY_CORRECTIONS_CSV", "delivery_corrections.csv")
# Each correction record is already written N times in the corrections CSV
# (currently 5× per unique delivery) so they naturally outweigh noisy history.
# Setting CORRECTION_WEIGHT > 1 here adds a further runtime multiplier on top.
_CORRECTION_WEIGHT = int(os.getenv("CORRECTION_WEIGHT", "1"))
_STORE_DIR = os.getenv("FAISS_HISTORY_DIR", "ml_data/faiss_history")
_VECTORS_PATH = os.path.join(_STORE_DIR, "delivery_history_vectors.npy")
_RECORDS_PATH = os.path.join(_STORE_DIR, "delivery_history_records.pkl")
_META_PATH = os.path.join(_STORE_DIR, "delivery_history.meta")
# ---------------------------------------------------------------------------
# Thresholds
# ---------------------------------------------------------------------------
_K_NEIGHBORS = 15
_MIN_KNN_CONFIDENCE = 0.50 # vector fallback: top rider wins ≥ 50 % of votes
_MIN_PATTERN_DOMINANCE = 0.60 # pattern table: rider owns ≥ 60 % of zone
_MIN_PATTERN_VOLUME = 3 # pattern table: at least 3 deliveries in zone
# ---------------------------------------------------------------------------
# Pure-NumPy L2 index — drop-in replacement for faiss.IndexFlatL2
# ---------------------------------------------------------------------------
class _NumpyL2Index:
"""
Brute-force L2 nearest-neighbour search using NumPy.
Same interface as faiss.IndexFlatL2 so both paths share one code.
For 3 0005 000 vectors each query takes < 1 ms — fast enough.
"""
def __init__(self, d: int):
self.d = d
self.ntotal = 0
self._vecs: Optional[np.ndarray] = None # shape (N, d) float32
def add(self, vectors: np.ndarray) -> None:
self._vecs = vectors.astype(np.float32)
self.ntotal = len(self._vecs)
def search(self, query: np.ndarray, k: int) -> Tuple[np.ndarray, np.ndarray]:
"""Return (distances, indices) shaped (1, k), same as faiss."""
if self._vecs is None or self.ntotal == 0:
return (np.array([[]], dtype=np.float32),
np.array([[-1]], dtype=np.int64))
q = query.astype(np.float32) # (1, d)
diff = self._vecs - q # (N, d) broadcasting
sq_dists = np.einsum("ij,ij->i", diff, diff) # (N,) squared L2
actual_k = min(k, self.ntotal)
if actual_k < self.ntotal:
part = np.argpartition(sq_dists, actual_k)[:actual_k]
top_idxs = part[np.argsort(sq_dists[part])]
else:
top_idxs = np.argsort(sq_dists)
return (sq_dists[top_idxs].reshape(1, -1),
top_idxs.reshape(1, -1).astype(np.int64))
def _build_index(vectors: np.ndarray) -> Any:
"""
Build the best available index for the given float32 vector array.
Tries faiss first; falls back to NumPy silently.
"""
try:
import faiss as _faiss
idx = _faiss.IndexFlatL2(vectors.shape[1])
idx.add(vectors)
logger.info("[DeliveryHistory] Using faiss-cpu index.")
return idx
except Exception:
idx = _NumpyL2Index(vectors.shape[1])
idx.add(vectors)
logger.info("[DeliveryHistory] Using NumPy L2 index (faiss-cpu not available).")
return idx
# ---------------------------------------------------------------------------
# Main store
# ---------------------------------------------------------------------------
class DeliveryHistoryStore:
"""
Pattern-first, vector-fallback rider lookup.
Public API
----------
store.find_rider(kitchen, plat, plon, dlat, dlon) -> dict | None
store.record_count() -> int
store.pattern_count() -> int
store.get_pattern_stats() -> list
store.reload_from_csv() -> int
"""
def __init__(self):
self._lock = Lock()
self._records: List[Dict] = []
self._index = None
# Pattern table: (kitchen_lower, zone_lat, zone_lon) → pattern dict
self._patterns: Dict[Tuple, Dict] = {}
# Zone index: (zone_lat, zone_lon) → [pattern keys at that cell]
self._zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
self._load()
# ------------------------------------------------------------------
# Startup
# ------------------------------------------------------------------
def _load(self) -> None:
os.makedirs(_STORE_DIR, exist_ok=True)
if self._saved_files_are_current():
if self._load_from_disk():
return
logger.warning(
"[DeliveryHistory] Saved files corrupt — rebuilding from CSV."
)
records = self._parse_csv()
if not records:
return
self._build_and_save(records)
def _saved_files_are_current(self) -> bool:
for path in (_VECTORS_PATH, _RECORDS_PATH, _META_PATH):
if not os.path.isfile(path):
return False
try:
with open(_META_PATH, "r", encoding="utf-8") as f:
meta = json.load(f)
if not os.path.isfile(CSV_PATH):
return True
return abs(os.path.getmtime(CSV_PATH) - meta.get("csv_mtime", 0)) < 1.0
except Exception:
return False
def _load_from_disk(self) -> bool:
try:
vectors = np.load(_VECTORS_PATH) # (N, 4) float32
with open(_RECORDS_PATH, "rb") as f:
records = pickle.load(f)
if not records or len(vectors) != len(records):
return False
index = _build_index(vectors)
patterns, zone_index = self._compute_patterns(records)
with self._lock:
self._records = records
self._index = index
self._patterns = patterns
self._zone_index = zone_index
clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
logger.info(
f"[DeliveryHistory] Loaded {len(records)} records from disk. "
f"Pattern table: {len(patterns)} zones, {clear} with clear dominant rider."
)
return True
except Exception as e:
logger.warning(f"[DeliveryHistory] Disk load failed: {e}")
return False
def _parse_csv_file(self, path: str, label: str = "CSV") -> List[Dict]:
"""Parse any delivery CSV (main history or corrections) into records list."""
records: List[Dict] = []
for encoding in ("utf-8", "latin-1", "cp1252"):
try:
with open(path, newline="", encoding=encoding) as fh:
for row in csv.DictReader(fh):
try:
plat = float(row.get("pickuplat") or 0)
plon = float(row.get("pickuplon") or 0)
dlat = float(row.get("deliverylat") or 0)
dlon = float(row.get("deliverylong") or 0)
uid = int(float(row.get("userid") or 0))
if not plat or not dlat or uid == 0:
continue
# Skip rows where the delivery destination IS a kitchen
dcust = (row.get("deliverycustomer") or "").strip().lower()
if "kitchen" in dcust or "selvarani" in dcust:
continue
records.append({
"kitchen": (row.get("pickupcustomer") or "").strip().lower(),
"pickuplat": plat, "pickuplon": plon,
"deliverylat": dlat, "deliverylong": dlon,
"userid": uid,
"ridername": (row.get("ridername") or "").strip(),
})
except Exception:
continue
break
except (UnicodeDecodeError, FileNotFoundError) as e:
if isinstance(e, FileNotFoundError):
logger.warning(f"[DeliveryHistory] {label} not found at '{path}'.")
return []
records = []
if records:
logger.info(f"[DeliveryHistory] Parsed {len(records)} rows from {label} '{path}'.")
else:
logger.warning(f"[DeliveryHistory] {label} at '{path}' contained no valid rows.")
return records
def _parse_csv(self) -> List[Dict]:
"""Parse main history CSV, then merge manually-corrected records on top."""
main_records = self._parse_csv_file(CSV_PATH, label="Main CSV")
if not main_records:
return []
# Load manually-corrected assignments and merge with runtime weight multiplier
if os.path.isfile(CORRECTIONS_PATH):
corr = self._parse_csv_file(CORRECTIONS_PATH, label="Corrections CSV")
if corr:
weighted = corr * _CORRECTION_WEIGHT # extra amplification if configured
main_records = main_records + weighted
logger.info(
f"[DeliveryHistory] Merged {len(corr)} correction rows "
f"(×{_CORRECTION_WEIGHT} weight) into {len(main_records)} total records."
)
else:
logger.debug(
f"[DeliveryHistory] No corrections file at '{CORRECTIONS_PATH}'"
"using main CSV only."
)
return main_records
def _compute_patterns(
self, records: List[Dict]
) -> Tuple[Dict, Dict]:
"""
Build pattern table from records.
Pattern dict keys
-----------------
userid, ridername, dominance, total_deliveries,
top_deliveries, pattern_score, all_riders
"""
zone_counts: Dict[Tuple, Dict[int, int]] = defaultdict(lambda: defaultdict(int))
zone_names: Dict[Tuple, Dict[int, str]] = defaultdict(lambda: defaultdict(str))
for rec in records:
key = (rec["kitchen"],
round(rec["deliverylat"], 2),
round(rec["deliverylong"], 2))
zone_counts[key][rec["userid"]] += 1
zone_names[key][rec["userid"]] = rec["ridername"]
patterns: Dict[Tuple, Dict] = {}
zone_index: Dict[Tuple, List[Tuple]] = defaultdict(list)
for key, rider_counts in zone_counts.items():
kitchen, zone_lat, zone_lon = key
total = sum(rider_counts.values())
top_rid = max(rider_counts, key=rider_counts.get)
top_count = rider_counts[top_rid]
dominance = top_count / total
# Penalise thin data: full score only at ≥ 5 deliveries
pattern_score = dominance * min(1.0, total / 5.0)
patterns[key] = {
"userid": top_rid,
"ridername": zone_names[key].get(top_rid, ""),
"dominance": round(dominance, 4),
"total_deliveries": total,
"top_deliveries": top_count,
"pattern_score": round(pattern_score, 4),
"all_riders": dict(rider_counts),
}
zone_index[(zone_lat, zone_lon)].append(key)
return patterns, zone_index
def _build_and_save(self, records: List[Dict]) -> None:
vectors = np.array(
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
for r in records],
dtype=np.float32,
)
index = _build_index(vectors)
patterns, zone_index = self._compute_patterns(records)
with self._lock:
self._records = records
self._index = index
self._patterns = patterns
self._zone_index = zone_index
clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
sole = sum(
1 for p in patterns.values()
if p["dominance"] == 1.0
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
logger.info(
f"[DeliveryHistory] Pattern table: {len(patterns)} zones, "
f"{clear} clear patterns (≥60% dominance), {sole} sole-owner zones."
)
try:
np.save(_VECTORS_PATH, vectors)
with open(_RECORDS_PATH, "wb") as f:
pickle.dump(records, f, protocol=pickle.HIGHEST_PROTOCOL)
csv_mtime = os.path.getmtime(CSV_PATH) if os.path.isfile(CSV_PATH) else 0
with open(_META_PATH, "w", encoding="utf-8") as f:
json.dump({
"csv_mtime": csv_mtime,
"record_count": len(records),
"pattern_zones": len(patterns),
"clear_patterns": clear,
}, f, indent=2)
logger.info(
f"[DeliveryHistory] Saved to '{_STORE_DIR}'. "
"Next startup loads from disk."
)
except Exception as e:
logger.warning(
f"[DeliveryHistory] Could not save to disk (non-fatal): {e}"
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def find_rider(
self,
kitchen_name: str,
pickup_lat: float,
pickup_lon: float,
delivery_lat: float,
delivery_lon: float,
k: int = _K_NEIGHBORS,
min_confidence: float = _MIN_KNN_CONFIDENCE,
) -> Optional[Dict[str, Any]]:
"""
Return the best historical rider, or None.
Step 1 — pattern table (O(1)):
Clear dominant rider for this kitchen + 1.1 km delivery zone.
Step 2 — vector K-NN (fallback):
Brute-force 4D search, kitchen-filtered vote.
Return keys: userid, ridername, confidence, match_count, top_votes, source
"""
if not delivery_lat:
return None
kitchen_lower = (kitchen_name or "").strip().lower()
result = self._pattern_lookup(kitchen_lower, delivery_lat, delivery_lon)
if result:
return result
if self._index is None or not self._records:
return None
if not pickup_lat:
return None
return self._vector_knn(
kitchen_lower, pickup_lat, pickup_lon,
delivery_lat, delivery_lon, k, min_confidence
)
def _pattern_lookup(
self,
kitchen_lower: str,
delivery_lat: float,
delivery_lon: float,
) -> Optional[Dict[str, Any]]:
zone_pos = (round(delivery_lat, 2), round(delivery_lon, 2))
with self._lock:
candidate_keys = self._zone_index.get(zone_pos, [])
best_pattern = None
best_score = -1.0
for key in candidate_keys:
key_kitchen = key[0]
if kitchen_lower and key_kitchen:
if (kitchen_lower not in key_kitchen
and key_kitchen not in kitchen_lower):
continue
with self._lock:
pat = self._patterns.get(key)
if not pat:
continue
if (pat["dominance"] >= _MIN_PATTERN_DOMINANCE
and pat["total_deliveries"] >= _MIN_PATTERN_VOLUME
and pat["pattern_score"] > best_score):
best_pattern = pat
best_score = pat["pattern_score"]
if not best_pattern:
return None
return {
"userid": best_pattern["userid"],
"ridername": best_pattern["ridername"],
"confidence": best_pattern["dominance"],
"match_count": best_pattern["total_deliveries"],
"top_votes": best_pattern["top_deliveries"],
"source": "pattern",
}
def _vector_knn(
self,
kitchen_lower: str,
pickup_lat: float,
pickup_lon: float,
delivery_lat: float,
delivery_lon: float,
k: int,
min_confidence: float,
) -> Optional[Dict[str, Any]]:
with self._lock:
query = np.array(
[[pickup_lat, pickup_lon, delivery_lat, delivery_lon]],
dtype=np.float32,
)
actual_k = min(k, len(self._records))
_, indices = self._index.search(query, actual_k)
matched: List[Dict] = []
for idx in indices[0]:
idx = int(idx)
if idx < 0 or idx >= len(self._records):
continue
rec = self._records[idx]
if kitchen_lower and rec["kitchen"]:
if (kitchen_lower not in rec["kitchen"]
and rec["kitchen"] not in kitchen_lower):
continue
matched.append(rec)
if not matched:
return None
votes = Counter(rec["userid"] for rec in matched)
top_uid, top_count = votes.most_common(1)[0]
confidence = top_count / len(matched)
if confidence < min_confidence:
return None
ridername = next(
(r["ridername"] for r in matched if r["userid"] == top_uid), ""
)
return {
"userid": top_uid,
"ridername": ridername,
"confidence": round(confidence, 3),
"match_count": len(matched),
"top_votes": top_count,
"source": "vector_knn",
}
# ------------------------------------------------------------------
# Analytics
# ------------------------------------------------------------------
def record_count(self) -> int:
return len(self._records)
def pattern_count(self) -> int:
return sum(
1 for p in self._patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
def get_pattern_stats(self) -> List[Dict]:
stats = []
with self._lock:
for (kitchen, zlat, zlon), pat in self._patterns.items():
if (pat["dominance"] < _MIN_PATTERN_DOMINANCE
or pat["total_deliveries"] < _MIN_PATTERN_VOLUME):
continue
stats.append({
"kitchen": kitchen,
"zone_lat": zlat,
"zone_lon": zlon,
"userid": pat["userid"],
"ridername": pat["ridername"],
"dominance": pat["dominance"],
"total_deliveries": pat["total_deliveries"],
"top_deliveries": pat["top_deliveries"],
"pattern_score": pat["pattern_score"],
"all_riders": pat["all_riders"],
})
return sorted(stats, key=lambda x: x["pattern_score"], reverse=True)
# ------------------------------------------------------------------
# Rider Efficiency Scores
# ------------------------------------------------------------------
def get_rider_efficiency_scores(self) -> Dict[int, Dict]:
"""
Compute a per-rider efficiency score from the 30-day CSV history.
Metrics per rider
-----------------
delivery_count — total deliveries in CSV
avg_km — average Haversine distance (pickup → delivery)
unique_zones — number of distinct 1.1 km delivery cells covered
efficiency_score — normalised 0..1 composite score:
more deliveries × lower avg km × more zones = higher score
A rider who completes many deliveries efficiently across many zones
scores highest. Used as a tiebreaker during solo consolidation
and Phase-0 host selection.
"""
from math import radians, cos, sin, asin, sqrt as _sqrt
def _hav(la1, lo1, la2, lo2):
try:
la1, lo1, la2, lo2 = map(radians, [la1, lo1, la2, lo2])
a = sin((la2-la1)/2)**2 + cos(la1)*cos(la2)*sin((lo2-lo1)/2)**2
return 2 * asin(min(1.0, _sqrt(a))) * 6371.0
except Exception:
return 0.0
with self._lock:
records = list(self._records) # snapshot under lock
if not records:
return {}
stats: Dict[int, Dict] = {}
for rec in records:
uid = rec["userid"]
km = _hav(rec["pickuplat"], rec["pickuplon"],
rec["deliverylat"], rec["deliverylong"])
zone = (round(rec["deliverylat"], 2), round(rec["deliverylong"], 2))
if uid not in stats:
stats[uid] = {
"ridername": rec["ridername"],
"delivery_count": 0,
"total_km": 0.0,
"zones": set(),
}
s = stats[uid]
s["delivery_count"] += 1
s["total_km"] += km
s["zones"].add(zone)
# Build scores
result: Dict[int, Dict] = {}
raw_scores: Dict[int, float] = {}
for uid, s in stats.items():
n = s["delivery_count"]
avg_km = s["total_km"] / n if n else 0.0
zone_count = len(s["zones"])
# Raw: many deliveries, low km per delivery, wide zone coverage
raw = n / (1.0 + avg_km) * (1.0 + zone_count ** 0.5)
raw_scores[uid] = raw
result[uid] = {
"ridername": s["ridername"],
"delivery_count": n,
"avg_km": round(avg_km, 2),
"unique_zones": zone_count,
}
# Normalise 0..1
max_raw = max(raw_scores.values()) if raw_scores else 1.0
for uid in result:
result[uid]["efficiency_score"] = round(
raw_scores[uid] / max_raw, 4
)
return result
def get_rider_score(self, rider_id: int) -> float:
"""Convenience: return a single rider's efficiency_score (0..1), or 0.5 if unknown."""
scores = self.get_rider_efficiency_scores()
return scores.get(rider_id, {}).get("efficiency_score", 0.5)
# ------------------------------------------------------------------
# Reload
# ------------------------------------------------------------------
def reload_from_csv(self) -> int:
"""Rebuild index from main CSV + corrections (if present)."""
logger.info("[DeliveryHistory] Force-reload from CSV requested.")
records = self._parse_csv() # already merges corrections internally
if not records:
return 0
self._build_and_save(records)
return len(records)
def inject_corrections(self, corrections_path: str = CORRECTIONS_PATH,
weight: int = 5) -> Dict:
"""
Hot-inject a correction CSV into the live store without a full CSV reload.
Each row in the corrections file is counted `weight` times so manually
verified assignments quickly dominate the pattern table for those zones.
Returns a summary dict with old/new record counts and pattern changes.
"""
if not os.path.isfile(corrections_path):
raise FileNotFoundError(f"Corrections file not found: {corrections_path}")
corr = self._parse_csv_file(corrections_path, label="Corrections")
if not corr:
return {"status": "error", "message": "No valid rows in corrections file."}
weighted = corr * weight
with self._lock:
old_count = len(self._records)
# Remove any existing correction rows for the same zones to avoid
# double-injection (identify by source: corrections have no
# "source" field — we use a tag approach).
# Simplest safe approach: just append (first inject from scratch).
merged = list(self._records) + weighted
patterns, zone_index = self._compute_patterns(merged)
old_clear = sum(
1 for p in self._patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
new_clear = sum(
1 for p in patterns.values()
if p["dominance"] >= _MIN_PATTERN_DOMINANCE
and p["total_deliveries"] >= _MIN_PATTERN_VOLUME
)
vectors = np.array(
[[r["pickuplat"], r["pickuplon"], r["deliverylat"], r["deliverylong"]]
for r in merged],
dtype=np.float32,
)
index = _build_index(vectors)
with self._lock:
self._records = merged
self._index = index
self._patterns = patterns
self._zone_index = zone_index
# Save to disk so next restart includes corrections
try:
np.save(_VECTORS_PATH, vectors)
with open(_RECORDS_PATH, "wb") as f:
pickle.dump(merged, f)
csv_mtime = os.path.getmtime(CSV_PATH) if os.path.isfile(CSV_PATH) else 0
with open(_META_PATH, "w", encoding="utf-8") as f:
json.dump({"csv_mtime": csv_mtime, "record_count": len(merged),
"pattern_count": len(patterns)}, f)
logger.info(
f"[DeliveryHistory] Corrections injected and saved — "
f"records: {old_count}{len(merged)}, "
f"clear patterns: {old_clear}{new_clear}"
)
except Exception as e:
logger.warning(f"[DeliveryHistory] Save after inject failed: {e}")
return {
"status": "ok",
"unique_corrections": len(corr),
"weight": weight,
"records_before": old_count,
"records_after": len(merged),
"clear_patterns_before": old_clear,
"clear_patterns_after": new_clear,
"zones_total": len(patterns),
}
# ------------------------------------------------------------------
# Module-level singleton
# ------------------------------------------------------------------
_store: Optional[DeliveryHistoryStore] = None
_store_lock: Lock = Lock()
def get_delivery_history_store() -> DeliveryHistoryStore:
global _store
if _store is None:
with _store_lock:
if _store is None:
_store = DeliveryHistoryStore()
return _store

View File

@@ -0,0 +1,182 @@
"""
Customer coordinate store.
Purpose:
For each incoming order, look up the customer's phone number.
If a previous verified delivery exists (rider coords on file), use those
coords instead of the (possibly noisy) input delivery coords.
If no record exists, create one so future calls benefit from it.
Storage:
Pure dict + JSON — no FAISS dependency.
phone_meta.json maps phone -> {name, rider_lat, rider_lon}.
Coord lookup key is phone number, not coordinates, so vector search
was never needed; FAISS has been removed.
"""
import os
import json
import logging
from math import radians, cos, sin, asin, sqrt
from threading import Lock
logger = logging.getLogger(__name__)
STORE_DIR = os.getenv("CUSTOMER_STORE_DIR", "ml_data/faiss_customer")
META_PATH = os.path.join(STORE_DIR, "phone_meta.json")
# Within this distance the stored rider coords are considered the real delivery point
SIMILARITY_THRESHOLD_KM = float(os.getenv("FAISS_SIMILARITY_KM", "0.5"))
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in km."""
try:
lon1, lat1, lon2, lat2 = map(radians, [float(lon1), float(lat1), float(lon2), float(lat2)])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
return 2 * asin(min(1.0, sqrt(a))) * 6371
except Exception:
return float("inf")
class CustomerCoordStore:
"""
Thread-safe customer delivery coordinate store (dict + JSON backend).
Primary flow (called per order before assignment):
verified_lat, verified_lon, corrected = store.get_verified_coords(
phone, customer_name, input_lat, input_lon
)
"""
def __init__(self):
self._lock = Lock()
os.makedirs(STORE_DIR, exist_ok=True)
self._meta: dict = {} # phone -> {name, rider_lat, rider_lon}
self._load()
# ------------------------------------------------------------------
# Persistence
# ------------------------------------------------------------------
def _load(self):
if os.path.exists(META_PATH):
try:
with open(META_PATH, "r", encoding="utf-8") as f:
self._meta = json.load(f)
logger.info(f"[CustomerStore] Loaded {len(self._meta)} customer records.")
except Exception as e:
logger.warning(f"[CustomerStore] Could not load {META_PATH}: {e} — starting fresh.")
self._meta = {}
else:
self._meta = {}
logger.info("[CustomerStore] No existing store found — starting fresh.")
def _save(self):
try:
with open(META_PATH, "w", encoding="utf-8") as f:
json.dump(self._meta, f, indent=2)
except Exception as e:
logger.warning(f"[CustomerStore] Save failed: {e}")
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_verified_coords(
self,
phone: str,
customer_name: str,
input_lat: float,
input_lon: float,
) -> tuple:
"""
Verify / correct delivery coordinates using stored history.
Returns:
(lat, lon, was_corrected)
was_corrected=True → coords replaced with verified stored coords
was_corrected=False → input coords used (new customer or new location)
"""
if not phone or not input_lat or not input_lon:
return input_lat, input_lon, False
phone = str(phone).strip()
with self._lock:
record = self._meta.get(phone)
if record:
stored_lat = record["rider_lat"]
stored_lon = record["rider_lon"]
dist_km = _haversine(stored_lat, stored_lon, input_lat, input_lon)
if dist_km <= SIMILARITY_THRESHOLD_KM:
# Stored rider coords match input → they represent the real door
logger.info(
f"[CustomerStore] {phone}: verified coords used "
f"(stored↔input dist={dist_km:.3f}km, within {SIMILARITY_THRESHOLD_KM}km)"
)
return stored_lat, stored_lon, True
else:
# Customer location changed significantly, trust new input
logger.info(
f"[CustomerStore] {phone}: location changed "
f"(dist={dist_km:.3f}km > {SIMILARITY_THRESHOLD_KM}km), using input coords"
)
return input_lat, input_lon, False
else:
# New customer — create record with input coords
self._create_record(phone, customer_name, input_lat, input_lon)
logger.info(f"[CustomerStore] {phone}: new record created ({input_lat}, {input_lon})")
return input_lat, input_lon, False
def update_rider_coords(self, phone: str, rider_lat: float, rider_lon: float):
"""
Called when a delivery is confirmed to update with actual rider GPS.
This is what makes stored coords progressively more accurate.
"""
phone = str(phone).strip()
with self._lock:
if phone in self._meta:
record = self._meta[phone]
record["rider_lat"] = rider_lat
record["rider_lon"] = rider_lon
self._save()
logger.info(f"[CustomerStore] {phone}: rider coords updated ({rider_lat}, {rider_lon})")
def record_count(self) -> int:
return len(self._meta)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _create_record(self, phone: str, name: str, lat: float, lon: float):
self._meta[phone] = {
"name": name,
"rider_lat": lat,
"rider_lon": lon,
}
self._save()
# Singleton — keep old name for backward compatibility with any imports
_store_instance: "CustomerCoordStore | None" = None
_store_lock = Lock()
def get_faiss_store() -> CustomerCoordStore:
"""Returns the singleton CustomerCoordStore (backward-compat name)."""
global _store_instance
if _store_instance is None:
with _store_lock:
if _store_instance is None:
_store_instance = CustomerCoordStore()
return _store_instance
# Also expose under new name
get_customer_store = get_faiss_store