Initial commit
This commit is contained in:
58
.dockerignore
Normal file
58
.dockerignore
Normal file
@@ -0,0 +1,58 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Test files
|
||||
test_*.py
|
||||
*_test.py
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
9
.env
Normal file
9
.env
Normal file
@@ -0,0 +1,9 @@
|
||||
REDIS_PASSWORD=Package@324969#
|
||||
GOOGLE_MAPS_API_KEY=AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q
|
||||
|
||||
# nearledb (read-only source for empirical ETA — `deliveries` table only)
|
||||
DB_HOST=66.116.207.225
|
||||
DB_PORT=6432
|
||||
DB_NAME=nearledb
|
||||
DB_USER=admin
|
||||
DB_PASSWORD=Package@123#
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app ./app
|
||||
COPY start.py ./start.py
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8002
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
2213
Untitled (2)
Normal file
2213
Untitled (2)
Normal file
File diff suppressed because it is too large
Load Diff
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Delivery Route Optimization API
|
||||
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
BIN
app/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
1
app/config/__init__.py
Normal file
1
app/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Configuration package for mobile delivery optimization."""
|
||||
BIN
app/config/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/config/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/config/__pycache__/dynamic_config.cpython-312.pyc
Normal file
BIN
app/config/__pycache__/dynamic_config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/config/__pycache__/rider_preferences.cpython-312.pyc
Normal file
BIN
app/config/__pycache__/rider_preferences.cpython-312.pyc
Normal file
Binary file not shown.
408
app/config/dynamic_config.py
Normal file
408
app/config/dynamic_config.py
Normal file
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
Dynamic Configuration - rider-api
|
||||
|
||||
Replaces all hardcoded hyperparameters with DB-backed values.
|
||||
The ML hypertuner writes optimal values here; services read from here.
|
||||
|
||||
Fallback: If DB is unavailable or no tuned values exist, defaults are used.
|
||||
This means zero risk - the system works day 1 with no data.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# --- DB Path ------------------------------------------------------------------
|
||||
_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-request strategy override (async-safe via contextvars).
|
||||
# Each FastAPI request/asyncio task gets its own copy — no cross-request leaks.
|
||||
# Usage:
|
||||
# set_request_strategy("fuel_saver") → override active for this request
|
||||
# clear_request_strategy() → restore to DB value
|
||||
# ---------------------------------------------------------------------------
|
||||
_strategy_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
||||
"strategy_override", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_request_strategy(strategy: Optional[str]) -> None:
|
||||
"""Override ml_strategy for the current async task only (thread-safe)."""
|
||||
_strategy_override.set(strategy)
|
||||
|
||||
|
||||
def clear_request_strategy() -> None:
|
||||
"""Remove the per-request strategy override for the current async task."""
|
||||
_strategy_override.set(None)
|
||||
|
||||
|
||||
# --- Hard Defaults (What the system used before ML) ---------------------------
|
||||
DEFAULTS: Dict[str, Any] = {
|
||||
# System Strategy / Prompt
|
||||
"ml_strategy": "balanced",
|
||||
# AssignmentService
|
||||
"max_pickup_distance_km": 10.0,
|
||||
"max_kitchen_distance_km": 3.0,
|
||||
"max_orders_per_rider": 12,
|
||||
"ideal_load": 6,
|
||||
"workload_balance_threshold": 0.7,
|
||||
"workload_penalty_weight": 100.0,
|
||||
"distance_penalty_weight": 2.0,
|
||||
"preference_bonus": -15.0,
|
||||
"home_zone_bonus_4km": -3.0,
|
||||
"home_zone_bonus_2km": -5.0,
|
||||
"emergency_load_penalty": 3.0, # km penalty per order in emergency assign
|
||||
# RouteOptimizer
|
||||
"search_time_limit_seconds": 5,
|
||||
"avg_speed_kmh": 18.0,
|
||||
"road_factor": 1.3,
|
||||
# ClusteringService
|
||||
"cluster_radius_km": 3.0,
|
||||
# KalmanFilter
|
||||
"kalman_process_noise": 1e-4,
|
||||
"kalman_measurement_noise": 0.01,
|
||||
# RealisticETACalculator
|
||||
"eta_pickup_time_min": 3.0,
|
||||
"eta_delivery_time_min": 4.0,
|
||||
"eta_navigation_buffer_min": 1.5,
|
||||
"eta_short_trip_factor": 0.8, # speed multiplier for dist < 2km
|
||||
"eta_long_trip_factor": 1.1, # speed multiplier for dist > 8km
|
||||
# EmpiricalETACalculator (learned ETAs from actual delivery times)
|
||||
"eta_empirical_enabled": True, # False -> instantly revert to the formula
|
||||
"eta_min_samples": 10, # min history samples a key needs before trust
|
||||
# (backtest on live 14d data: 10 -> MAE 4.85 vs
|
||||
# formula 5.73; 20 -> 5.64. 10 wins on held-out.)
|
||||
"eta_history_days": 14, # rolling window pulled from nearledb
|
||||
"eta_stat": "median", # "median" or "p75" (p75 = more conservative)
|
||||
"eta_sync_interval_hours": 6, # autonomous background sync cadence
|
||||
# Road-aware sequencing (Phase 2). OFF by default: enabling adds a Google
|
||||
# Directions call (cost + latency) to the route hot path. Results are cached.
|
||||
# Only the *visiting order* changes; step/ETA metrics stay aerial-based.
|
||||
"routing_use_road_distance": False, # AGENT-MANAGED (see routing_auto_manage)
|
||||
"routing_road_cache_ttl_seconds": 86400, # road geometry is stable; cache 24h
|
||||
"routing_road_max_stops": 25, # Google distance-matrix practical cap
|
||||
# Autonomous road-sequencing decision agent: measures road-vs-aerial travel
|
||||
# time on real batches and flips routing_use_road_distance on its own.
|
||||
"routing_auto_manage": True, # False -> humans own the flag
|
||||
"routing_auto_enable_gain_pct": 3.0, # enable when mean gain >= this
|
||||
"routing_auto_disable_gain_pct": 1.0, # disable when mean gain < this (hysteresis)
|
||||
"routing_eval_sample_batches": 8, # batches measured per cycle (cost bound)
|
||||
"routing_eval_min_batches": 3, # need >= this evaluated to decide
|
||||
"routing_eval_interval_hours": 24, # decision cadence
|
||||
"routing_eval_days": 14, # window sampled from the local mirror
|
||||
# Learned rider->kitchen affinity (soft steering only; union with curated config).
|
||||
"rider_affinity_enabled": True, # False -> pure curated config
|
||||
"rider_affinity_min_deliveries": 10, # learned owner needs >= this many deliveries
|
||||
"rider_affinity_refresh_hours": 6, # recompute cadence (piggybacks the agent)
|
||||
}
|
||||
|
||||
|
||||
class DynamicConfig:
|
||||
"""
|
||||
Thread-safe, DB-backed configuration store.
|
||||
|
||||
Usage:
|
||||
cfg = DynamicConfig()
|
||||
max_dist = cfg.get("max_pickup_distance_km")
|
||||
all_params = cfg.get_all()
|
||||
"""
|
||||
|
||||
_instance: Optional["DynamicConfig"] = None
|
||||
|
||||
def __new__(cls) -> "DynamicConfig":
|
||||
"""Singleton - one config per process."""
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._last_loaded: Optional[datetime] = None
|
||||
self._ensure_db()
|
||||
self._load()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Public API
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a config value. Returns ML-tuned value if available, else default.
|
||||
|
||||
For 'ml_strategy' specifically, a per-request ContextVar override takes
|
||||
precedence so that hypertuning_params requests don't mutate the shared
|
||||
singleton (thread-safe for concurrent FastAPI requests).
|
||||
"""
|
||||
self._maybe_reload()
|
||||
# Per-request strategy override (async-safe, no cross-request leaks)
|
||||
if key == "ml_strategy":
|
||||
override = _strategy_override.get()
|
||||
if override is not None:
|
||||
return override
|
||||
val = self._cache.get(key)
|
||||
if val is not None:
|
||||
return val
|
||||
fallback = default if default is not None else DEFAULTS.get(key)
|
||||
return fallback
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""Return all current config values (ML-tuned + defaults for missing keys)."""
|
||||
self._maybe_reload()
|
||||
result = dict(DEFAULTS)
|
||||
result.update(self._cache)
|
||||
return result
|
||||
|
||||
def set(self, key: str, value: Any, source: str = "manual") -> None:
|
||||
"""Write a config value to DB (used by hypertuner)."""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_DB_PATH) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dynamic_config (key, value, source, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value=excluded.value,
|
||||
source=excluded.source,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(key, json.dumps(value), source, datetime.utcnow().isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self._cache[key] = value
|
||||
logger.info(f"[DynamicConfig] Set {key}={value} (source={source})")
|
||||
except Exception as e:
|
||||
logger.error(f"[DynamicConfig] Failed to set {key}: {e}")
|
||||
|
||||
def set_bulk(self, params: Dict[str, Any], source: str = "ml_hypertuner") -> None:
|
||||
"""Write multiple config values at once (called after each Optuna study)."""
|
||||
for key, value in params.items():
|
||||
self.set(key, value, source=source)
|
||||
logger.info(f"[DynamicConfig] Bulk update: {len(params)} params from {source}")
|
||||
|
||||
def reset_to_defaults(self) -> None:
|
||||
"""Wipe all ML-tuned values, revert to hardcoded defaults."""
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.execute("DELETE FROM dynamic_config")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self._cache.clear()
|
||||
logger.warning("[DynamicConfig] Reset to factory defaults.")
|
||||
except Exception as e:
|
||||
logger.error(f"[DynamicConfig] Reset failed: {e}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Internal
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _ensure_db(self) -> None:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_DB_PATH) or ".", exist_ok=True)
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS dynamic_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
source TEXT DEFAULT 'manual',
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS kitchen_encoding (
|
||||
kitchen_name TEXT PRIMARY KEY,
|
||||
label_id INTEGER NOT NULL,
|
||||
frequency REAL DEFAULT 0.0,
|
||||
avg_profit REAL DEFAULT 0.0,
|
||||
order_count INTEGER DEFAULT 0,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.error(f"[DynamicConfig] DB init failed: {e}")
|
||||
|
||||
def _load(self) -> None:
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
rows = conn.execute("SELECT key, value FROM dynamic_config").fetchall()
|
||||
conn.close()
|
||||
self._cache = {}
|
||||
for key, raw in rows:
|
||||
try:
|
||||
self._cache[key] = json.loads(raw)
|
||||
except Exception:
|
||||
self._cache[key] = raw
|
||||
self._last_loaded = datetime.utcnow()
|
||||
if self._cache:
|
||||
logger.info(
|
||||
f"[DynamicConfig] Loaded {len(self._cache)} ML-tuned params from DB"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[DynamicConfig] Could not load from DB (using defaults): {e}"
|
||||
)
|
||||
self._cache = {}
|
||||
|
||||
def _maybe_reload(self, interval_seconds: int = 300) -> None:
|
||||
"""Reload from DB every 5 minutes - picks up new tuned params without restart."""
|
||||
if self._last_loaded is None:
|
||||
self._load()
|
||||
return
|
||||
delta = (datetime.utcnow() - self._last_loaded).total_seconds()
|
||||
if delta > interval_seconds:
|
||||
self._load()
|
||||
|
||||
|
||||
# --- Module-level convenience singleton ---------------------------------------
|
||||
_cfg = DynamicConfig()
|
||||
|
||||
|
||||
def get_config() -> DynamicConfig:
|
||||
"""Get the global DynamicConfig singleton."""
|
||||
return _cfg
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DynamicConfig",
|
||||
"get_config",
|
||||
"set_request_strategy",
|
||||
"clear_request_strategy",
|
||||
"get_kitchen_label_id",
|
||||
"get_kitchen_frequency",
|
||||
"update_kitchen_stats",
|
||||
"get_kitchen_avg_profit_smoothed",
|
||||
]
|
||||
|
||||
|
||||
# --- Kitchen Encoding Persistence ---------------------------------------------
|
||||
def get_kitchen_label_id(kitchen_name: str) -> int:
|
||||
"""Get or create persistent label ID for a kitchen."""
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT label_id FROM kitchen_encoding WHERE kitchen_name = ?",
|
||||
(kitchen_name,),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.close()
|
||||
return row[0]
|
||||
|
||||
max_id = conn.execute(
|
||||
"SELECT COALESCE(MAX(label_id), -1) FROM kitchen_encoding"
|
||||
).fetchone()[0]
|
||||
new_id = max_id + 1
|
||||
conn.close()
|
||||
return new_id
|
||||
except Exception as e:
|
||||
logger.warning(f"[KitchenEncoding] Failed to get label_id: {e}")
|
||||
return hash(kitchen_name.lower().strip()) % 10000
|
||||
|
||||
|
||||
def get_kitchen_frequency(kitchen_name: str) -> float:
|
||||
"""Get frequency ratio for a kitchen from DB."""
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT frequency FROM kitchen_encoding WHERE kitchen_name = ?",
|
||||
(kitchen_name,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row[0] if row else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def update_kitchen_stats(kitchen_name: str, profit: float):
|
||||
"""Update kitchen stats after order completion."""
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT order_count, avg_profit FROM kitchen_encoding WHERE kitchen_name = ?",
|
||||
(kitchen_name,),
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
count, avg = row
|
||||
new_count = count + 1
|
||||
new_avg = ((avg * count) + profit) / new_count
|
||||
new_freq = new_count / (
|
||||
conn.execute(
|
||||
"SELECT SUM(order_count) FROM kitchen_encoding"
|
||||
).fetchone()[0]
|
||||
or 1
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE kitchen_encoding SET order_count = ?, avg_profit = ?, frequency = ?, updated_at = ? WHERE kitchen_name = ?",
|
||||
(
|
||||
new_count,
|
||||
new_avg,
|
||||
new_freq,
|
||||
datetime.utcnow().isoformat(),
|
||||
kitchen_name,
|
||||
),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO kitchen_encoding (kitchen_name, label_id, frequency, avg_profit, order_count, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
kitchen_name,
|
||||
get_kitchen_label_id(kitchen_name),
|
||||
1.0,
|
||||
profit,
|
||||
1,
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[KitchenEncoding] Failed to update stats: {e}")
|
||||
|
||||
|
||||
def get_kitchen_avg_profit_smoothed(
|
||||
kitchen_name: str, global_avg: float = 40.0, min_samples: int = 5
|
||||
) -> float:
|
||||
"""
|
||||
Get smoothed average profit for a kitchen using Bayesian smoothing.
|
||||
Reduces noise for kitchens with few orders.
|
||||
|
||||
Formula: smoothed = (kitchen_count * kitchen_avg + min_samples * global_avg) / (kitchen_count + min_samples)
|
||||
|
||||
This means:
|
||||
- Kitchen with many samples -> uses its own avg
|
||||
- Kitchen with few samples -> pulls toward global avg
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
row = conn.execute(
|
||||
"SELECT order_count, avg_profit FROM kitchen_encoding WHERE kitchen_name = ?",
|
||||
(kitchen_name,),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if row and row[0] > 0:
|
||||
count, avg = row
|
||||
if count >= min_samples:
|
||||
return avg
|
||||
# Bayesian smoothing
|
||||
smoothed = ((count * avg) + (min_samples * global_avg)) / (
|
||||
count + min_samples
|
||||
)
|
||||
return smoothed
|
||||
|
||||
return global_avg # Unknown kitchen defaults to global avg
|
||||
except Exception:
|
||||
return global_avg
|
||||
33
app/config/mobile_config.py
Normal file
33
app/config/mobile_config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Mobile-specific configuration for delivery route optimization."""
|
||||
|
||||
# Mobile optimization settings
|
||||
MOBILE_CONFIG = {
|
||||
"default_algorithm": "greedy",
|
||||
"max_deliveries": 100,
|
||||
"timeout_seconds": 5,
|
||||
"response_compression": True,
|
||||
"performance_monitoring": True,
|
||||
"mobile_headers": True
|
||||
}
|
||||
|
||||
# Performance targets for mobile
|
||||
PERFORMANCE_TARGETS = {
|
||||
"greedy_algorithm": {
|
||||
"max_response_time": 0.1, # 100ms
|
||||
"max_deliveries": 50,
|
||||
"description": "Ultra-fast for real-time mobile apps"
|
||||
},
|
||||
"tsp_algorithm": {
|
||||
"max_response_time": 3.0, # 3 seconds
|
||||
"max_deliveries": 30,
|
||||
"description": "Optimal but slower, good for planning"
|
||||
}
|
||||
}
|
||||
|
||||
# Mobile app recommendations
|
||||
MOBILE_RECOMMENDATIONS = {
|
||||
"real_time_delivery": "greedy",
|
||||
"route_planning": "tsp",
|
||||
"large_batches": "greedy",
|
||||
"cost_optimization": "tsp"
|
||||
}
|
||||
51
app/config/rider_preferences.py
Normal file
51
app/config/rider_preferences.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Rider Preferred Kitchens Configuration
|
||||
Mapping of Rider ID (int) to list of preferred Kitchen names (str).
|
||||
Updated based on Deployment Plan.
|
||||
"""
|
||||
|
||||
RIDER_PREFERRED_KITCHENS = {
|
||||
# 1. VIGNESH - RS PURAM, TOWNHALL, PODANUR, KUNIAMUTHUR
|
||||
1036: ["Bhuvaneshwari kitchen"],
|
||||
|
||||
# 2. VARUN EDWARD - KAVUNDAMPALAYAM, THUDIYALUR
|
||||
897: ["Daily grubs(jayanthi kitchen)"],
|
||||
|
||||
# 3. JAYASABESH - GANAPATHY, SARAVANAMPATTI
|
||||
950: ["Daily grubs nandhini"],
|
||||
|
||||
# 4. TAMILAZHAGAN - GANDHIMA NAGAR, NEHRU NAGAR, SITRA
|
||||
1114: ["Daily grubs nandhini"],
|
||||
|
||||
# 5. RAJAN - PEELAMEDU, P N PALAYAM
|
||||
883: ["Daily grubs nandhini", "Vidhya kitchen"],
|
||||
|
||||
# 6. MANIKANDAN - SINGANALLUR
|
||||
753: ["Vidhya kitchen"],
|
||||
|
||||
# 7. NAGALAKSHMI - SAIBABA COLONY, R S PURAM, GANDHIPURAM
|
||||
1062: ["Daily grubs(jayanthi kitchen)"],
|
||||
|
||||
# 8. MURALI - RAMANATHAPURAM, PODANUR, RACE COURSE
|
||||
1111: ["Vidhya kitchen"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocked / test rider IDs — SINGLE source of truth.
|
||||
# Import this set wherever blocked-rider filtering is needed instead of
|
||||
# hard-coding the list in multiple files.
|
||||
# ---------------------------------------------------------------------------
|
||||
BLOCKED_RIDERS: frozenset = frozenset({1242, 1266, 1245, 1232, 1240, 1007})
|
||||
|
||||
# Anchor Coordinates for Riders (Based on Area Name)
|
||||
# Used as fallback if GPS is missing, or to bias assignment to their Home Zone.
|
||||
RIDER_HOME_LOCATIONS = {
|
||||
1036: (11.0067, 76.9558), # VIGNESH: RS PURAM, TOWNHALL, PODANUR, KUNIAMUTHUR
|
||||
897: (11.0430, 76.9380), # VARUN EDWARD: KAVUNDAMPALAYAM, THUDIYALUR
|
||||
950: (11.0330, 76.9800), # JAYASABESH: GANAPATHY, SARAVANAMPATTI
|
||||
1114: (11.0450, 77.0000), # TAMILAZHAGAN: GANDHIMA NAGAR, NEHRU NAGAR, SITRA
|
||||
883: (11.0200, 77.0000), # RAJAN: PEELAMEDU, P N PALAYAM
|
||||
753: (11.0000, 77.0300), # MANIKANDAN: SINGANALLUR
|
||||
1062: (11.0250, 76.9450), # NAGALAKSHMI: SAIBABA COLONY, R S PURAM, GANDHIPURAM
|
||||
1111: (10.9950, 77.0000), # MURALI: RAMANATHAPURAM, PODANUR, RACE COURSE
|
||||
}
|
||||
5
app/controllers/__init__.py
Normal file
5
app/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Controllers package."""
|
||||
|
||||
from .route_controller import RouteController
|
||||
|
||||
__all__ = ["RouteController"]
|
||||
BIN
app/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/controllers/__pycache__/route_controller.cpython-312.pyc
Normal file
BIN
app/controllers/__pycache__/route_controller.cpython-312.pyc
Normal file
Binary file not shown.
104
app/controllers/route_controller.py
Normal file
104
app/controllers/route_controller.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Controller for provider payload optimization and forwarding."""
|
||||
|
||||
import logging
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.exceptions import ValidationError, APIException
|
||||
from app.services.routing.route_optimizer import RouteOptimizer
|
||||
from app.services import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouteController:
|
||||
"""Controller for optimizing provider payloads and forwarding upstream."""
|
||||
|
||||
def __init__(self):
|
||||
self.route_optimizer = RouteOptimizer()
|
||||
|
||||
def _hash_key(self, prefix: str, payload: Dict[str, Any]) -> str:
|
||||
"""Create a stable cache key from a dict payload."""
|
||||
# ensure deterministic json by sorting keys
|
||||
serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
digest = hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
||||
return f"routes:{prefix}:{digest}"
|
||||
|
||||
async def optimize_and_forward_provider_payload(self, orders: list[dict], forward_url: str) -> dict:
|
||||
"""Optimize provider payload and return it (forwarding paused).
|
||||
|
||||
- Input: list of provider orders (dicts)
|
||||
- Output: {code, details, message, status} where details is the optimized array
|
||||
"""
|
||||
try:
|
||||
if not isinstance(orders, list) or not orders:
|
||||
raise ValidationError("Orders array is required", field="body")
|
||||
|
||||
# Create a cache key from the orders payload
|
||||
cache_key = self._hash_key("provider_payload", {"orders": orders})
|
||||
|
||||
# Check cache
|
||||
cached_result = cache.get_json(cache_key)
|
||||
if cached_result:
|
||||
logger.info(f"Cache hit for key: {cache_key}")
|
||||
return cached_result
|
||||
|
||||
optimized = await self.route_optimizer.optimize_provider_payload(orders)
|
||||
|
||||
# Debug sample of optimized payload (first 3 items, select keys)
|
||||
try:
|
||||
sample = [
|
||||
{
|
||||
k: item.get(k)
|
||||
for k in ("orderheaderid", "orderid", "deliverycustomerid", "step", "previouskms", "cumulativekms", "eta")
|
||||
}
|
||||
for item in optimized[:3]
|
||||
]
|
||||
logger.debug(f"Optimized payload sample: {sample}")
|
||||
trace = [
|
||||
{
|
||||
"orderid": item.get("orderid"),
|
||||
"step": item.get("step"),
|
||||
"prev": item.get("previouskms"),
|
||||
"cum": item.get("cumulativekms"),
|
||||
}
|
||||
for item in optimized
|
||||
]
|
||||
logger.debug(f"Optimized order trace: {trace}")
|
||||
except Exception:
|
||||
logger.debug("Optimized payload sample logging failed")
|
||||
|
||||
# Forwarding paused: return optimized payload directly
|
||||
result = {
|
||||
"code": 200,
|
||||
"details": optimized,
|
||||
"message": "Success",
|
||||
"status": True,
|
||||
}
|
||||
|
||||
# Store in cache
|
||||
try:
|
||||
cache.set_json(cache_key, result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cache optimization result: {e}")
|
||||
|
||||
return result
|
||||
except ValidationError:
|
||||
raise
|
||||
except httpx.HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
body_text = e.response.text
|
||||
logger.error(f"Forwarding failed: {status_code} - {body_text}")
|
||||
# Surface upstream details to the client for faster debugging
|
||||
raise APIException(
|
||||
status_code=502,
|
||||
message=f"Upstream service error (status {status_code}): {body_text}",
|
||||
code="UPSTREAM_ERROR"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error optimizing/forwarding provider payload: {e}", exc_info=True)
|
||||
raise APIException(status_code=500, message="Internal server error", code="INTERNAL_ERROR")
|
||||
# Batch routes removed - use single-route optimization for each pickup location
|
||||
2
app/core/__init__.py
Normal file
2
app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core application components."""
|
||||
|
||||
BIN
app/core/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/arrow_utils.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/arrow_utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/exception_handlers.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/exception_handlers.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/exceptions.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/exceptions.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/core/__pycache__/log_context.cpython-312.pyc
Normal file
BIN
app/core/__pycache__/log_context.cpython-312.pyc
Normal file
Binary file not shown.
63
app/core/arrow_utils.py
Normal file
63
app/core/arrow_utils.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
High-performance utilities using Apache Arrow and NumPy for geographic data.
|
||||
Provides vectorized operations for distances and coordinate processing.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def calculate_haversine_matrix_vectorized(lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Calculate an N x N distance matrix using the Haversine formula.
|
||||
Fully vectorized using NumPy for O(N^2) speed improvement over Python loops.
|
||||
"""
|
||||
# Earth's radius in kilometers
|
||||
R = 6371.0
|
||||
|
||||
# Convert degrees to radians
|
||||
lats_rad = np.radians(lats)
|
||||
lons_rad = np.radians(lons)
|
||||
|
||||
# Create meshgrids for pairwise differences
|
||||
# lats.reshape(-1, 1) creates a column vector
|
||||
# lats.reshape(1, -1) creates a row vector
|
||||
# Subtracting them creates an N x N matrix of differences
|
||||
dlat = lats_rad.reshape(-1, 1) - lats_rad.reshape(1, -1)
|
||||
dlon = lons_rad.reshape(-1, 1) - lons_rad.reshape(1, -1)
|
||||
|
||||
# Haversine formula
|
||||
a = np.sin(dlat / 2)**2 + np.cos(lats_rad.reshape(-1, 1)) * np.cos(lats_rad.reshape(1, -1)) * np.sin(dlon / 2)**2
|
||||
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
def orders_to_arrow_table(orders: List[Dict[str, Any]]) -> pa.Table:
|
||||
"""
|
||||
Convert a list of order dictionaries to an Apache Arrow Table.
|
||||
This enables zero-copy operations and efficient columnar storage.
|
||||
"""
|
||||
return pa.Table.from_pylist(orders)
|
||||
|
||||
def save_optimized_route_parquet(orders: List[Dict[str, Any]], filename: str):
|
||||
"""
|
||||
Save optimized route data to a Parquet file for high-speed analysis.
|
||||
Useful for logging and historical simulation replays.
|
||||
"""
|
||||
try:
|
||||
table = orders_to_arrow_table(orders)
|
||||
pq.write_table(table, filename)
|
||||
logger.info(f" Saved route data to Parquet: {filename}")
|
||||
except Exception as e:
|
||||
logger.error(f" Failed to save Parquet: {e}")
|
||||
|
||||
def load_route_parquet(filename: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Load route data from a Parquet file and return as a list of dicts.
|
||||
"""
|
||||
table = pq.read_table(filename)
|
||||
return table.to_pylist()
|
||||
26
app/core/constants.py
Normal file
26
app/core/constants.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""API constants and configuration."""
|
||||
|
||||
# API Configuration
|
||||
API_VERSION = "2.0.0"
|
||||
API_TITLE = "Route Optimization API"
|
||||
API_DESCRIPTION = "Professional API for delivery route optimization"
|
||||
|
||||
# Route Optimization Limits
|
||||
MAX_DELIVERIES = 50
|
||||
MIN_DELIVERIES = 1
|
||||
|
||||
# Coordinate Validation
|
||||
MIN_LATITUDE = -90
|
||||
MAX_LATITUDE = 90
|
||||
MIN_LONGITUDE = -180
|
||||
MAX_LONGITUDE = 180
|
||||
|
||||
# Algorithm Types
|
||||
ALGORITHM_GREEDY = "greedy"
|
||||
ALGORITHM_TSP = "tsp"
|
||||
|
||||
# Response Messages
|
||||
MESSAGE_SUCCESS = "Route optimized successfully"
|
||||
MESSAGE_VALIDATION_ERROR = "Request validation failed"
|
||||
MESSAGE_INTERNAL_ERROR = "An unexpected error occurred"
|
||||
|
||||
112
app/core/exception_handlers.py
Normal file
112
app/core/exception_handlers.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Professional exception handlers for the API."""
|
||||
|
||||
import logging
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.exceptions import APIException
|
||||
from app.models.errors import ErrorResponse, ErrorDetail
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def api_exception_handler(request: Request, exc: APIException) -> JSONResponse:
|
||||
"""Handle custom API exceptions."""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
error_response = ErrorResponse(
|
||||
success=False,
|
||||
error=ErrorDetail(
|
||||
field=exc.field,
|
||||
message=exc.message,
|
||||
code=exc.code
|
||||
),
|
||||
path=request.url.path,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
logger.warning(f"API Exception: {exc.code} - {exc.message} (Request ID: {request_id})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_response.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""Handle HTTP exceptions."""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
error_response = ErrorResponse(
|
||||
success=False,
|
||||
error=ErrorDetail(
|
||||
message=exc.detail,
|
||||
code="HTTP_ERROR"
|
||||
),
|
||||
path=request.url.path,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
logger.warning(f"HTTP Exception: {exc.status_code} - {exc.detail} (Request ID: {request_id})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_response.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""Handle validation errors with detailed field information."""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
errors = exc.errors()
|
||||
if errors:
|
||||
first_error = errors[0]
|
||||
field = ".".join(str(loc) for loc in first_error.get("loc", []))
|
||||
message = first_error.get("msg", "Validation error")
|
||||
else:
|
||||
field = None
|
||||
message = "Validation error"
|
||||
|
||||
error_response = ErrorResponse(
|
||||
success=False,
|
||||
error=ErrorDetail(
|
||||
field=field,
|
||||
message=message,
|
||||
code="VALIDATION_ERROR"
|
||||
),
|
||||
path=request.url.path,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
logger.warning(f"Validation Error: {message} (Field: {field}, Request ID: {request_id})")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content=error_response.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
|
||||
async def general_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle unexpected exceptions."""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
error_response = ErrorResponse(
|
||||
success=False,
|
||||
error=ErrorDetail(
|
||||
message="An unexpected error occurred. Please try again later.",
|
||||
code="INTERNAL_SERVER_ERROR"
|
||||
),
|
||||
path=request.url.path,
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
logger.error(f"Unexpected Error: {str(exc)} (Request ID: {request_id})", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content=error_response.model_dump(exclude_none=True)
|
||||
)
|
||||
|
||||
70
app/core/exceptions.py
Normal file
70
app/core/exceptions.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Custom exceptions for the API."""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
class APIException(HTTPException):
|
||||
"""Base API exception with structured error format."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
field: str = None,
|
||||
code: str = None,
|
||||
detail: str = None
|
||||
):
|
||||
self.message = message
|
||||
self.field = field
|
||||
self.code = code or self._get_default_code(status_code)
|
||||
super().__init__(status_code=status_code, detail=detail or message)
|
||||
|
||||
def _get_default_code(self, status_code: int) -> str:
|
||||
"""Get default error code based on status code."""
|
||||
codes = {
|
||||
400: "BAD_REQUEST",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
409: "CONFLICT",
|
||||
422: "VALIDATION_ERROR",
|
||||
429: "RATE_LIMIT_EXCEEDED",
|
||||
500: "INTERNAL_SERVER_ERROR",
|
||||
503: "SERVICE_UNAVAILABLE"
|
||||
}
|
||||
return codes.get(status_code, "UNKNOWN_ERROR")
|
||||
|
||||
|
||||
class ValidationError(APIException):
|
||||
"""Validation error exception."""
|
||||
|
||||
def __init__(self, message: str, field: str = None):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
message=message,
|
||||
field=field,
|
||||
code="VALIDATION_ERROR"
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(APIException):
|
||||
"""Resource not found exception."""
|
||||
|
||||
def __init__(self, message: str = "Resource not found"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
message=message,
|
||||
code="NOT_FOUND"
|
||||
)
|
||||
|
||||
|
||||
class RateLimitError(APIException):
|
||||
"""Rate limit exceeded exception."""
|
||||
|
||||
def __init__(self, message: str = "Rate limit exceeded"):
|
||||
super().__init__(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
message=message,
|
||||
code="RATE_LIMIT_EXCEEDED"
|
||||
)
|
||||
|
||||
30
app/core/log_context.py
Normal file
30
app/core/log_context.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Per-request logging context.
|
||||
|
||||
Carries the request id (the same one `RequestIDMiddleware` puts on the
|
||||
`X-Request-ID` header) into every log record via a ContextVar + logging.Filter,
|
||||
so a whole request's log lines can be grepped by one id — essential when
|
||||
watching the live API.
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
|
||||
# Default "-" so non-request logs (startup, background agents) still format cleanly.
|
||||
_request_id: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
|
||||
|
||||
|
||||
def set_request_id(request_id: str) -> None:
|
||||
_request_id.set(request_id or "-")
|
||||
|
||||
|
||||
def get_request_id() -> str:
|
||||
return _request_id.get()
|
||||
|
||||
|
||||
class RequestIdFilter(logging.Filter):
|
||||
"""Injects `request_id` onto every LogRecord so the format string can use it."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.request_id = _request_id.get()
|
||||
return True
|
||||
192
app/main.py
Normal file
192
app/main.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""FastAPI application — Daily Grubs Rider Dispatch API."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
# Load .env (DB_*, REDIS_*, GOOGLE_MAPS_API_KEY) into the environment early,
|
||||
# before any service reads os.getenv at import time.
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.exception_handlers import (
|
||||
api_exception_handler,
|
||||
general_exception_handler,
|
||||
http_exception_handler,
|
||||
validation_exception_handler,
|
||||
)
|
||||
from app.core.exceptions import APIException
|
||||
from app.middleware.request_id import RequestIDMiddleware
|
||||
from app.routes import cache_router, health_router, ml_router, ml_web_router, optimization_router, batch_analytics_router, riders_router
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logging
|
||||
# ---------------------------------------------------------------------------
|
||||
from app.core.log_context import RequestIdFilter
|
||||
|
||||
_log_level = getattr(logging, os.getenv("LOG_LEVEL", "INFO").upper(), logging.INFO)
|
||||
_handler = logging.StreamHandler(sys.stdout)
|
||||
_handler.addFilter(RequestIdFilter())
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - [req:%(request_id)s] - [%(filename)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
handlers=[_handler],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
for _lib in ("httpx", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logging.getLogger(_lib).setLevel(_log_level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan (startup / shutdown)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("[START] Route Optimization API starting up...")
|
||||
|
||||
# Warm up the delivery-history FAISS store in the background
|
||||
try:
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
store = get_delivery_history_store()
|
||||
logger.info(
|
||||
f"[DeliveryHistory] FAISS store ready — {store.record_count()} historical records loaded."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[DeliveryHistory] Store warm-up failed (non-fatal): {e}")
|
||||
|
||||
# Warm up the customer-coord FAISS store
|
||||
try:
|
||||
from app.services.vector.faiss_customer_store import get_faiss_store
|
||||
cs = get_faiss_store()
|
||||
logger.info(f"[FAISSCoords] Customer coord store ready — {cs.record_count()} records.")
|
||||
except Exception as e:
|
||||
logger.warning(f"[FAISSCoords] Store warm-up failed (non-fatal): {e}")
|
||||
|
||||
# Log how many historical assignment events we have for analytics
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
n = get_collector().count_records()
|
||||
logger.info(f"[Analytics] {n} assignment events in the analytics DB.")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Analytics] DB check failed (non-fatal): {e}")
|
||||
|
||||
# Warm up the Thompson Sampling bandit (bootstraps from historical DB)
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit
|
||||
bandit = get_bandit()
|
||||
stats = bandit.get_stats()
|
||||
logger.info(
|
||||
f"[Bandit] RL strategy bandit ready — "
|
||||
f"{stats['context_count']} contexts, "
|
||||
f"{stats['total_updates']} historical updates loaded."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Bandit] Warm-up failed (non-fatal): {e}")
|
||||
|
||||
# Start the autonomous empirical-ETA sync agent (daemon thread).
|
||||
# It mirrors completed deliveries locally and rebuilds learned ETAs on a
|
||||
# schedule, so the request path never touches Postgres.
|
||||
try:
|
||||
from app.services.routing.delivery_history_service import get_delivery_history_service
|
||||
from app.config.dynamic_config import get_config
|
||||
cfg = get_config()
|
||||
get_delivery_history_service().ensure_background_sync(
|
||||
interval_hours=int(cfg.get("eta_sync_interval_hours", 6)),
|
||||
days=int(cfg.get("eta_history_days", 14)),
|
||||
)
|
||||
logger.info("[ETA-Agent] autonomous empirical-ETA sync scheduled.")
|
||||
except Exception as e:
|
||||
logger.warning(f"[ETA-Agent] Sync agent start failed (non-fatal): {e}")
|
||||
|
||||
# Start the autonomous road-sequencing decision agent. It measures road vs
|
||||
# aerial sequencing on real batches and turns routing_use_road_distance on/off
|
||||
# by itself — no manual flag flip needed.
|
||||
try:
|
||||
from app.services.routing.road_sequencing_agent import get_road_agent
|
||||
from app.config.dynamic_config import get_config
|
||||
get_road_agent().ensure_background_agent(
|
||||
interval_hours=int(get_config().get("routing_eval_interval_hours", 24)),
|
||||
)
|
||||
logger.info("[RoadAgent] autonomous road-sequencing decision agent scheduled.")
|
||||
except Exception as e:
|
||||
logger.warning(f"[RoadAgent] Decision agent start failed (non-fatal): {e}")
|
||||
|
||||
logger.info("[OK] Application ready.")
|
||||
yield
|
||||
logger.info("[STOP] Route Optimization API shutting down.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastAPI(
|
||||
title="Route Optimization API",
|
||||
version="2.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/api/v1/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(RequestIDMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Request-ID", "X-Process-Time"],
|
||||
)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_process_time_header(request: Request, call_next):
|
||||
start = time.time()
|
||||
response = await call_next(request)
|
||||
response.headers["X-Process-Time"] = str(round(time.time() - start, 4))
|
||||
response.headers["X-API-Version"] = "2.0.0"
|
||||
return response
|
||||
|
||||
|
||||
app.add_exception_handler(APIException, api_exception_handler)
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler)
|
||||
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
app.add_exception_handler(Exception, general_exception_handler)
|
||||
|
||||
app.include_router(optimization_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(cache_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(ml_web_router)
|
||||
app.include_router(batch_analytics_router)
|
||||
app.include_router(riders_router)
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root(request: Request):
|
||||
return {
|
||||
"service": "Route Optimization API",
|
||||
"version": "2.0.0",
|
||||
"status": "operational",
|
||||
"docs": "/docs",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8002, reload=True)
|
||||
2
app/middleware/__init__.py
Normal file
2
app/middleware/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Middleware components."""
|
||||
|
||||
BIN
app/middleware/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/middleware/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/middleware/__pycache__/request_id.cpython-312.pyc
Normal file
BIN
app/middleware/__pycache__/request_id.cpython-312.pyc
Normal file
Binary file not shown.
30
app/middleware/request_id.py
Normal file
30
app/middleware/request_id.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Request ID middleware for request tracing."""
|
||||
|
||||
import uuid
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.log_context import set_request_id
|
||||
|
||||
|
||||
class RequestIDMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to add unique request ID to each request."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Generate or retrieve request ID (short form keeps logs readable)
|
||||
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex[:8]
|
||||
|
||||
# Add request ID to request state + logging context (so every log line
|
||||
# for this request carries it — grep one id to follow the whole request).
|
||||
request.state.request_id = request_id
|
||||
set_request_id(request_id)
|
||||
|
||||
# Process request
|
||||
response = await call_next(request)
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
21
app/models/__init__.py
Normal file
21
app/models/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Models package."""
|
||||
|
||||
from .schemas import (
|
||||
Location,
|
||||
Delivery,
|
||||
RouteOptimizationRequest,
|
||||
RouteStep,
|
||||
OptimizedRoute,
|
||||
PickupLocation,
|
||||
DeliveryLocation
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Location",
|
||||
"Delivery",
|
||||
"RouteOptimizationRequest",
|
||||
"RouteStep",
|
||||
"OptimizedRoute",
|
||||
"PickupLocation",
|
||||
"DeliveryLocation"
|
||||
]
|
||||
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/errors.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/errors.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/models/__pycache__/schemas.cpython-312.pyc
Normal file
BIN
app/models/__pycache__/schemas.cpython-312.pyc
Normal file
Binary file not shown.
45
app/models/errors.py
Normal file
45
app/models/errors.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Professional error response models for API."""
|
||||
|
||||
from typing import Optional, Any, Dict
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
"""Detailed error information."""
|
||||
field: Optional[str] = Field(None, description="Field name that caused the error")
|
||||
message: str = Field(..., description="Error message")
|
||||
code: Optional[str] = Field(None, description="Error code")
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Standardized error response model."""
|
||||
success: bool = Field(False, description="Request success status")
|
||||
error: ErrorDetail = Field(..., description="Error details")
|
||||
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat(), description="Error timestamp")
|
||||
path: Optional[str] = Field(None, description="Request path")
|
||||
request_id: Optional[str] = Field(None, description="Request ID for tracing")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": False,
|
||||
"error": {
|
||||
"field": "pickup_location",
|
||||
"message": "Pickup location is required",
|
||||
"code": "VALIDATION_ERROR"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||
"path": "/api/v1/optimization/single-route",
|
||||
"request_id": "req-123456"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SuccessResponse(BaseModel):
|
||||
"""Standardized success response wrapper."""
|
||||
success: bool = Field(True, description="Request success status")
|
||||
data: Any = Field(..., description="Response data")
|
||||
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat(), description="Response timestamp")
|
||||
request_id: Optional[str] = Field(None, description="Request ID for tracing")
|
||||
|
||||
167
app/models/schemas.py
Normal file
167
app/models/schemas.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Professional Pydantic models for request/response validation."""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class Location(BaseModel):
|
||||
"""Location model with latitude and longitude."""
|
||||
lat: float = Field(..., description="Latitude")
|
||||
lng: float = Field(..., description="Longitude")
|
||||
|
||||
|
||||
class PickupLocation(BaseModel):
|
||||
"""Pickup location model with latitude and longitude."""
|
||||
pickuplat: float = Field(
|
||||
...,
|
||||
description="Pickup latitude",
|
||||
ge=-90,
|
||||
le=90,
|
||||
examples=[11.0050534]
|
||||
)
|
||||
pickuplon: float = Field(
|
||||
...,
|
||||
description="Pickup longitude",
|
||||
ge=-180,
|
||||
le=180,
|
||||
examples=[76.9508991]
|
||||
)
|
||||
|
||||
@field_validator("pickuplat", "pickuplon")
|
||||
@classmethod
|
||||
def validate_coordinates(cls, v):
|
||||
"""Validate coordinate values."""
|
||||
if v is None:
|
||||
raise ValueError("Coordinate cannot be None")
|
||||
return float(v)
|
||||
|
||||
|
||||
class DeliveryLocation(BaseModel):
|
||||
"""Delivery location model with latitude and longitude."""
|
||||
deliverylat: float = Field(
|
||||
...,
|
||||
description="Delivery latitude",
|
||||
ge=-90,
|
||||
le=90,
|
||||
examples=[11.0309723]
|
||||
)
|
||||
deliverylong: float = Field(
|
||||
...,
|
||||
description="Delivery longitude",
|
||||
ge=-180,
|
||||
le=180,
|
||||
examples=[77.0004574]
|
||||
)
|
||||
|
||||
@field_validator("deliverylat", "deliverylong")
|
||||
@classmethod
|
||||
def validate_coordinates(cls, v):
|
||||
"""Validate coordinate values."""
|
||||
if v is None:
|
||||
raise ValueError("Coordinate cannot be None")
|
||||
return float(v)
|
||||
|
||||
|
||||
class Delivery(BaseModel):
|
||||
"""Delivery order model."""
|
||||
deliveryid: str = Field(..., description="Unique delivery identifier")
|
||||
deliverycustomerid: int = Field(..., description="Customer ID for this delivery")
|
||||
location: DeliveryLocation = Field(..., description="Delivery location coordinates")
|
||||
|
||||
|
||||
class RouteOptimizationRequest(BaseModel):
|
||||
"""
|
||||
Request model for route optimization.
|
||||
|
||||
Optimizes delivery routes starting from a pickup location (warehouse/store) to multiple delivery locations.
|
||||
Uses greedy nearest-neighbor algorithm for fast, efficient route calculation.
|
||||
"""
|
||||
pickup_location: PickupLocation = Field(
|
||||
...,
|
||||
description="Pickup location (warehouse/store) coordinates - starting point for optimization"
|
||||
)
|
||||
pickup_location_id: Optional[int] = Field(
|
||||
None,
|
||||
description="Optional pickup location ID for tracking purposes"
|
||||
)
|
||||
deliveries: List[Delivery] = Field(
|
||||
...,
|
||||
min_items=1,
|
||||
max_items=50,
|
||||
description="List of delivery locations to optimize (1-50 deliveries supported)"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"pickup_location": {
|
||||
"pickuplat": 11.0050534,
|
||||
"pickuplon": 76.9508991
|
||||
},
|
||||
"pickup_location_id": 1,
|
||||
"deliveries": [
|
||||
{
|
||||
"deliveryid": "90465",
|
||||
"deliverycustomerid": 1,
|
||||
"location": {
|
||||
"deliverylat": 11.0309723,
|
||||
"deliverylong": 77.0004574
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RouteStep(BaseModel):
|
||||
"""Single step in the optimized route."""
|
||||
step_number: int = Field(..., description="Step number in the route")
|
||||
delivery_id: str = Field(..., description="Delivery ID for this step")
|
||||
delivery_customer_id: int = Field(..., description="Customer ID for this delivery")
|
||||
location: DeliveryLocation = Field(..., description="Delivery location coordinates")
|
||||
distance_from_previous_km: float = Field(..., description="Distance from previous step in kilometers")
|
||||
cumulative_distance_km: float = Field(..., description="Total distance traveled so far in kilometers")
|
||||
|
||||
|
||||
class OptimizedRoute(BaseModel):
|
||||
"""
|
||||
Optimized route response with step-by-step delivery sequence.
|
||||
|
||||
Contains the optimized route starting from pickup location, with each step showing:
|
||||
- Delivery order (Step 1, Step 2, etc.)
|
||||
- Distance from previous step
|
||||
- Cumulative distance traveled
|
||||
"""
|
||||
route_id: str = Field(..., description="Unique route identifier (UUID)")
|
||||
pickup_location_id: Optional[int] = Field(None, description="Pickup location ID")
|
||||
pickup_location: PickupLocation = Field(..., description="Pickup location (warehouse/store) coordinates")
|
||||
total_distance_km: float = Field(
|
||||
...,
|
||||
ge=0,
|
||||
description="Total route distance in kilometers",
|
||||
examples=[12.45]
|
||||
)
|
||||
total_deliveries: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Total number of deliveries in the route",
|
||||
examples=[5]
|
||||
)
|
||||
optimization_algorithm: str = Field(
|
||||
"greedy",
|
||||
description="Algorithm used for optimization",
|
||||
examples=["greedy"]
|
||||
)
|
||||
steps: List[RouteStep] = Field(
|
||||
...,
|
||||
description="Ordered list of route steps (Step 1 = nearest from pickup, Step 2 = nearest from Step 1, etc.)"
|
||||
)
|
||||
created_at: str = Field(
|
||||
default_factory=lambda: datetime.utcnow().isoformat(),
|
||||
description="Route creation timestamp (ISO 8601)"
|
||||
)
|
||||
|
||||
|
||||
# Batch optimization removed - no rider support needed
|
||||
# Use single-route optimization for each pickup location
|
||||
18
app/routes/__init__.py
Normal file
18
app/routes/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Routes package."""
|
||||
|
||||
from .optimization import router as optimization_router
|
||||
from .health import router as health_router
|
||||
from .cache import router as cache_router
|
||||
from .ml_admin import router as ml_router, web_router as ml_web_router
|
||||
from .batch_analytics import router as batch_analytics_router
|
||||
from .riders import router as riders_router
|
||||
|
||||
__all__ = [
|
||||
"optimization_router",
|
||||
"health_router",
|
||||
"cache_router",
|
||||
"ml_router",
|
||||
"ml_web_router",
|
||||
"batch_analytics_router",
|
||||
"riders_router",
|
||||
]
|
||||
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/batch_analytics.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/batch_analytics.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/cache.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/cache.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/health.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/health.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/ml_admin.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/ml_admin.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/routes/__pycache__/optimization.cpython-312.pyc
Normal file
BIN
app/routes/__pycache__/optimization.cpython-312.pyc
Normal file
Binary file not shown.
263
app/routes/batch_analytics.py
Normal file
263
app/routes/batch_analytics.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
Batch Efficiency Analytics Endpoint
|
||||
=====================================
|
||||
POST /api/v1/batch/efficiency
|
||||
|
||||
Analyses a delivery batch for idle-rider substitution opportunities.
|
||||
Supports named batch windows (morning / afternoon / evening) or custom
|
||||
time ranges so you can run the same analysis for any shift.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"batch": "morning", // "morning" | "afternoon" | "evening" | "custom"
|
||||
"date": "2026-05-28", // defaults to today
|
||||
"tenant_id": 916, // defaults to 916
|
||||
"from_time": "06:00", // only for batch="custom"
|
||||
"to_time": "09:00", // only for batch="custom"
|
||||
"deliveries": [...], // supply inline instead of DB fetch
|
||||
"rider_names": {"1036": "Vignesh S", ...},
|
||||
"config": {
|
||||
"idle_threshold_minutes": 30,
|
||||
"road_kmh": 13.0,
|
||||
"max_transfer_orders": 4
|
||||
}
|
||||
}
|
||||
|
||||
Batch windows (assigntime range, inclusive start / exclusive end):
|
||||
morning : 06:00 – 09:00 (breakfast + early lunch prep)
|
||||
afternoon : 11:00 – 15:00 (lunch)
|
||||
evening : 17:00 – 21:30 (dinner)
|
||||
custom : caller provides from_time / to_time
|
||||
|
||||
Header shorthand (all equivalent to body.batch):
|
||||
X-Batch-Window: morning | afternoon | evening
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import date as _date
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, status
|
||||
|
||||
from app.services.routing.batch_efficiency import analyse_batch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/batch",
|
||||
tags=["Batch Analytics"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Named batch windows {name: (from_time, to_time)} — 24-h "HH:MM" strings
|
||||
# ---------------------------------------------------------------------------
|
||||
BATCH_WINDOWS: dict[str, tuple[str, str]] = {
|
||||
"morning": ("06:00", "09:00"),
|
||||
"afternoon": ("11:00", "15:00"),
|
||||
"evening": ("17:00", "21:30"),
|
||||
}
|
||||
|
||||
DEFAULT_BATCH = "morning"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fetch_from_db(
|
||||
target_date: str,
|
||||
tenant_id: int,
|
||||
from_time: str,
|
||||
to_time: str,
|
||||
) -> tuple[list[dict], dict[int, str]]:
|
||||
"""
|
||||
Fetch deliveries assigned within [from_time, to_time) on target_date.
|
||||
Times are 24-h "HH:MM" strings, e.g. "06:00", "09:00".
|
||||
Returns (deliveries, rider_names) where rider_names maps userid → username.
|
||||
"""
|
||||
# Shared nearledb connector (single source of truth for DB_* creds).
|
||||
from app.services.routing.delivery_history_service import connect_nearledb
|
||||
|
||||
try:
|
||||
conn = connect_nearledb()
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="psycopg2 not installed — cannot fetch from DB.",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"DB connection failed: {exc}",
|
||||
)
|
||||
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
d.deliveryid,
|
||||
d.userid,
|
||||
d.pickupcustomer,
|
||||
d.assigntime,
|
||||
d.arrivaltime,
|
||||
d.pickuptime,
|
||||
d.deliverytime,
|
||||
COALESCE(d.droplat, d.deliverylat) AS dlat,
|
||||
COALESCE(d.droplon, d.deliverylong) AS dlon
|
||||
FROM deliveries d
|
||||
WHERE d.tenantid = %s
|
||||
AND DATE(d.assigntime::timestamp) = %s
|
||||
AND CAST(SPLIT_PART(d.assigntime, ' ', 2) AS TIME) >= %s
|
||||
AND CAST(SPLIT_PART(d.assigntime, ' ', 2) AS TIME) < %s
|
||||
AND COALESCE(d.droplat, d.deliverylat) IS NOT NULL
|
||||
AND d.userid IS NOT NULL
|
||||
ORDER BY d.userid, d.assigntime
|
||||
""",
|
||||
(tenant_id, target_date, from_time + ":00", to_time + ":00"),
|
||||
)
|
||||
cols = [c.name for c in cur.description]
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
deliveries = [dict(zip(cols, r)) for r in rows]
|
||||
|
||||
# Fetch rider names for the returned userids
|
||||
rider_names_db: dict[int, str] = {}
|
||||
try:
|
||||
unique_uids = list({int(r["userid"]) for r in deliveries if r.get("userid") is not None})
|
||||
if unique_uids:
|
||||
cur2 = conn.cursor()
|
||||
cur2.execute(
|
||||
"SELECT userid, username FROM users WHERE userid = ANY(%s)",
|
||||
(unique_uids,)
|
||||
)
|
||||
for uid, uname in cur2.fetchall():
|
||||
if uname:
|
||||
rider_names_db[int(uid)] = str(uname)
|
||||
cur2.close()
|
||||
except Exception:
|
||||
pass # names are non-critical; callers fall back to "Rider {uid}"
|
||||
|
||||
return deliveries, rider_names_db
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"DB query failed: {exc}",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post(
|
||||
"/efficiency",
|
||||
summary="Batch efficiency analysis",
|
||||
description=(
|
||||
"Analyse a delivery batch for idle-rider substitution opportunities. "
|
||||
"Pass `batch` as 'morning', 'afternoon', or 'evening' to select the "
|
||||
"time window automatically, or use `batch='custom'` with `from_time`/`to_time`. "
|
||||
"Supply `deliveries` inline to skip the DB fetch entirely."
|
||||
),
|
||||
)
|
||||
async def batch_efficiency(
|
||||
body: Any = Body(default=None),
|
||||
x_batch_window: str | None = Header(default=None),
|
||||
):
|
||||
if body is None:
|
||||
body = {}
|
||||
|
||||
# ---- Parse inputs -------------------------------------------------------
|
||||
deliveries: list[dict] | None = None
|
||||
target_date: str | None = None
|
||||
tenant_id: int = 916
|
||||
rider_names: dict[int, str] = {}
|
||||
cfg: dict = {}
|
||||
batch_name: str = DEFAULT_BATCH
|
||||
from_time: str | None = None
|
||||
to_time: str | None = None
|
||||
|
||||
if isinstance(body, dict):
|
||||
deliveries = body.get("deliveries")
|
||||
target_date = body.get("date")
|
||||
tenant_id = int(body.get("tenant_id", 916))
|
||||
batch_name = (body.get("batch") or x_batch_window or DEFAULT_BATCH).lower()
|
||||
from_time = body.get("from_time")
|
||||
to_time = body.get("to_time")
|
||||
rider_names_raw = body.get("rider_names") or {}
|
||||
rider_names = {int(k): v for k, v in rider_names_raw.items()}
|
||||
cfg = body.get("config") or {}
|
||||
elif isinstance(body, list):
|
||||
deliveries = body
|
||||
batch_name = (x_batch_window or DEFAULT_BATCH).lower()
|
||||
|
||||
# ---- Resolve time window ------------------------------------------------
|
||||
if batch_name == "custom":
|
||||
if not from_time or not to_time:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="batch='custom' requires from_time and to_time (e.g. '11:00', '15:00').",
|
||||
)
|
||||
elif batch_name in BATCH_WINDOWS:
|
||||
from_time, to_time = BATCH_WINDOWS[batch_name]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Unknown batch '{batch_name}'. "
|
||||
f"Valid values: {list(BATCH_WINDOWS.keys())} or 'custom'."
|
||||
),
|
||||
)
|
||||
|
||||
# ---- Fetch from DB if no inline deliveries ------------------------------
|
||||
if not deliveries:
|
||||
if not target_date:
|
||||
target_date = str(_date.today())
|
||||
|
||||
logger.info(
|
||||
f"[BatchEfficiency] Fetching batch={batch_name} "
|
||||
f"date={target_date} window={from_time}-{to_time} tenant={tenant_id}"
|
||||
)
|
||||
deliveries, db_rider_names = _fetch_from_db(target_date, tenant_id, from_time, to_time)
|
||||
|
||||
if not deliveries:
|
||||
return {
|
||||
"batch": batch_name,
|
||||
"window": {"from": from_time, "to": to_time},
|
||||
"date": target_date,
|
||||
"fleet_summary": {},
|
||||
"rider_timelines": [],
|
||||
"substitution_opportunities": [],
|
||||
"top_recommendation": None,
|
||||
"message": (
|
||||
f"No {batch_name}-batch orders found for {target_date} "
|
||||
f"between {from_time} and {to_time}."
|
||||
),
|
||||
}
|
||||
|
||||
# Merge: DB-fetched names as base, request-provided names take precedence
|
||||
rider_names = {**db_rider_names, **rider_names}
|
||||
|
||||
logger.info(
|
||||
f"[BatchEfficiency] Analysing {len(deliveries)} deliveries — "
|
||||
f"batch={batch_name} date={target_date or 'inline'}"
|
||||
)
|
||||
|
||||
# ---- Run analysis -------------------------------------------------------
|
||||
result = analyse_batch(
|
||||
deliveries=deliveries,
|
||||
rider_names=rider_names,
|
||||
road_kmh=float(cfg.get("road_kmh", 13.0)),
|
||||
idle_threshold_min=float(cfg.get("idle_threshold_minutes", 30.0)),
|
||||
max_transfer=int(cfg.get("max_transfer_orders", 4)),
|
||||
)
|
||||
|
||||
result["batch"] = batch_name
|
||||
result["window"] = {"from": from_time, "to": to_time}
|
||||
result["date"] = target_date or "inline"
|
||||
result["input_delivery_count"] = len(deliveries)
|
||||
return result
|
||||
79
app/routes/cache.py
Normal file
79
app/routes/cache.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Cache management API endpoints."""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.services import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/cache", tags=["Cache Management"])
|
||||
|
||||
|
||||
@router.get("/stats", response_model=Dict[str, Any])
|
||||
async def get_cache_stats():
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
- hits: Number of cache hits
|
||||
- misses: Number of cache misses
|
||||
- sets: Number of cache writes
|
||||
- total_keys: Current number of cached route keys
|
||||
- enabled: Whether Redis cache is enabled
|
||||
"""
|
||||
try:
|
||||
stats = cache.get_stats()
|
||||
# Calculate hit rate
|
||||
total_requests = stats.get("hits", 0) + stats.get("misses", 0)
|
||||
if total_requests > 0:
|
||||
stats["hit_rate"] = round(stats.get("hits", 0) / total_requests * 100, 2)
|
||||
else:
|
||||
stats["hit_rate"] = 0.0
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cache stats: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_cache_keys(pattern: str = "routes:*"):
|
||||
"""
|
||||
List cache keys matching pattern.
|
||||
|
||||
- **pattern**: Redis key pattern (default: "routes:*")
|
||||
"""
|
||||
try:
|
||||
keys = cache.get_keys(pattern)
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"count": len(keys),
|
||||
"keys": keys[:100] # Limit to first 100 for response size
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing cache keys: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/clear")
|
||||
async def clear_cache(pattern: str = "routes:*"):
|
||||
"""
|
||||
Clear cache keys matching pattern.
|
||||
|
||||
- **pattern**: Redis key pattern to delete (default: "routes:*")
|
||||
|
||||
[WARN] **Warning**: This will delete cached route optimizations!
|
||||
"""
|
||||
try:
|
||||
deleted_count = cache.delete(pattern)
|
||||
logger.info(f"Cleared {deleted_count} cache keys matching pattern: {pattern}")
|
||||
return {
|
||||
"pattern": pattern,
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"Cleared {deleted_count} cache keys"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing cache: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
98
app/routes/health.py
Normal file
98
app/routes/health.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Professional health check endpoints."""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/health", tags=["Health"])
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
status: str = Field(..., description="Service status")
|
||||
uptime_seconds: float = Field(..., description="Service uptime in seconds")
|
||||
version: str = Field("2.0.0", description="API version")
|
||||
timestamp: str = Field(..., description="Health check timestamp (ISO 8601)")
|
||||
request_id: Optional[str] = Field(None, description="Request ID for tracing")
|
||||
|
||||
|
||||
@router.get("/", response_model=HealthResponse)
|
||||
async def health_check(request: Request):
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns the current health status of the API service including:
|
||||
- Service status (healthy/unhealthy)
|
||||
- Uptime in seconds
|
||||
- API version
|
||||
- Timestamp
|
||||
"""
|
||||
try:
|
||||
uptime = time.time() - start_time
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
uptime_seconds=round(uptime, 2),
|
||||
version="2.0.0",
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
request_id=request_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}", exc_info=True)
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
return HealthResponse(
|
||||
status="unhealthy",
|
||||
uptime_seconds=0.0,
|
||||
version="2.0.0",
|
||||
timestamp=datetime.utcnow().isoformat() + "Z",
|
||||
request_id=request_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def readiness_check(request: Request):
|
||||
"""
|
||||
Readiness check endpoint for load balancers.
|
||||
|
||||
Returns 200 if the service is ready to accept requests.
|
||||
"""
|
||||
try:
|
||||
# Check if critical services are available
|
||||
# Add your service health checks here
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Readiness check failed: {e}")
|
||||
return {
|
||||
"status": "not_ready",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def liveness_check(request: Request):
|
||||
"""
|
||||
Liveness check endpoint for container orchestration.
|
||||
|
||||
Returns 200 if the service is alive.
|
||||
"""
|
||||
return {
|
||||
"status": "alive",
|
||||
"timestamp": datetime.utcnow().isoformat() + "Z",
|
||||
"request_id": getattr(request.state, "request_id", None)
|
||||
}
|
||||
586
app/routes/ml_admin.py
Normal file
586
app/routes/ml_admin.py
Normal file
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
Analytics & ML Admin API
|
||||
=========================
|
||||
Exposes historical assignment quality data, the empirical-ETA pipeline, and the
|
||||
autonomous road-sequencing / rider-affinity agents. (The XGBoost/Optuna hypertuner,
|
||||
ID3 risk tree, and profit predictor have all been retired — they never affected
|
||||
assignment.)
|
||||
|
||||
Endpoints:
|
||||
GET /api/v1/ml/status – quality trend, analytics DB + history stats
|
||||
GET /api/v1/ml/analytics – hourly stats, zone stats, histogram
|
||||
GET /api/v1/ml/config – active config values
|
||||
PATCH /api/v1/ml/config – manual config override
|
||||
POST /api/v1/ml/reset – reset config to defaults
|
||||
POST /api/v1/ml/strategy – change optimization strategy
|
||||
POST /api/v1/ml/refresh-eta – sync nearledb + rebuild empirical ETA stats
|
||||
GET /api/v1/ml/eta-accuracy – formula vs empirical ETA backtest
|
||||
GET/POST /api/v1/ml/road-eval – autonomous road-sequencing decision
|
||||
GET /api/v1/ml/rider-affinity – learned vs configured rider→kitchen affinity
|
||||
GET /api/v1/ml/export – download assignment log as CSV
|
||||
GET /api/v1/ml/history – delivery-history FAISS store stats
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from fastapi.responses import PlainTextResponse, FileResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/ml",
|
||||
tags=["Analytics & ML"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
web_router = APIRouter(tags=["ML Monitor Web Dashboard"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard (HTML)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@web_router.get("/ml-ops", summary="Visual ML monitoring dashboard")
|
||||
def ml_dashboard():
|
||||
path = os.path.join(os.getcwd(), "app/templates/ml_dashboard.html")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail=f"Dashboard template not found at {path}")
|
||||
return FileResponse(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status", summary="Assignment quality trend & store stats")
|
||||
def ml_status():
|
||||
"""
|
||||
Returns:
|
||||
- How many assignment events are logged
|
||||
- Recent quality score trend (last 50 calls)
|
||||
- Delivery history FAISS store record count
|
||||
- Active config values
|
||||
"""
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
from app.config.dynamic_config import get_config
|
||||
|
||||
collector = get_collector()
|
||||
history = get_delivery_history_store()
|
||||
cfg = get_config()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"db_records": collector.count_records(),
|
||||
"quality_trend": collector.get_recent_quality_trend(last_n=50),
|
||||
"delivery_history": {
|
||||
"record_count": history.record_count(),
|
||||
"status": "ready" if history.record_count() > 0 else "empty",
|
||||
},
|
||||
"config": cfg.get_all(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] status: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/analytics", summary="Hourly stats, zone stats, quality histogram")
|
||||
def ml_analytics():
|
||||
"""Operational analytics from historical assignment logs."""
|
||||
try:
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
collector = get_collector()
|
||||
return {
|
||||
"status": "ok",
|
||||
"hourly_stats": collector.get_hourly_stats(),
|
||||
"zone_stats": collector.get_zone_stats(),
|
||||
"quality_histogram": collector.get_quality_histogram(),
|
||||
"strategy_comparison": collector.get_strategy_comparison(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] analytics: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/config", summary="Current active configuration values")
|
||||
def ml_config():
|
||||
from app.config.dynamic_config import get_config, DEFAULTS
|
||||
try:
|
||||
cfg = get_config()
|
||||
all_values = cfg.get_all()
|
||||
cached_keys = set(cfg._cache.keys())
|
||||
annotated = {
|
||||
k: {"value": v, "source": "override" if k in cached_keys else "default"}
|
||||
for k, v in all_values.items()
|
||||
}
|
||||
return {
|
||||
"status": "ok",
|
||||
"hyperparameters": annotated,
|
||||
"total_params": len(annotated),
|
||||
"override_count": sum(1 for x in annotated.values() if x["source"] == "override"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] config: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/config", summary="Override specific config values")
|
||||
def ml_config_patch(payload: dict = Body(...)):
|
||||
"""Manually set any config key, e.g. {\"road_factor\": 1.4}"""
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().set_bulk(payload, source="ml_admin")
|
||||
return {"status": "ok", "updated": list(payload.keys())}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] config patch: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/reset", summary="Reset all config overrides to factory defaults")
|
||||
def ml_reset():
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().reset_to_defaults()
|
||||
return {"status": "ok", "message": "All config values reset to factory defaults."}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] reset: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /strategy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/strategy", summary="Change the optimization strategy")
|
||||
def ml_strategy(strategy: str = Body(default="balanced", embed=True)):
|
||||
"""
|
||||
Choices: balanced | fuel_saver | aggressive_speed | zone_strict
|
||||
Affects how the quality score is computed in analytics only.
|
||||
"""
|
||||
valid = ["balanced", "fuel_saver", "aggressive_speed", "zone_strict"]
|
||||
if strategy not in valid:
|
||||
raise HTTPException(400, f"Invalid strategy. Choose from {valid}")
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
get_config().set("ml_strategy", strategy)
|
||||
return {"status": "ok", "strategy": strategy}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] strategy: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auto-tune
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/auto-tune", summary="Run SQL-based strategy auto-tuner")
|
||||
def ml_auto_tune():
|
||||
"""
|
||||
Analyses the assignment log and picks the best-performing ml_strategy
|
||||
based on average quality score across all recorded calls.
|
||||
|
||||
Rules:
|
||||
- A strategy needs ≥ 10 calls to be considered.
|
||||
- At least 2 strategies must have enough data to compare.
|
||||
- If a better strategy is found it is written to DynamicConfig
|
||||
immediately and takes effect on the next /riderassign call.
|
||||
- Also returns per-hour breakdown so you can see peak-hour patterns.
|
||||
|
||||
Safe to call any time. Runs in < 100ms.
|
||||
"""
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
from app.config.dynamic_config import get_config, DEFAULTS
|
||||
try:
|
||||
collector = get_collector()
|
||||
cfg = get_config()
|
||||
comparison = collector.get_strategy_comparison()
|
||||
hourly = collector.get_hourly_stats()
|
||||
total_records = collector.count_records()
|
||||
|
||||
if total_records == 0:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": "No assignment events logged yet. "
|
||||
"Call /riderassign a few times first.",
|
||||
"total_records": 0,
|
||||
}
|
||||
|
||||
qualified = [s for s in comparison if s["call_count"] >= 10]
|
||||
current_strategy = cfg.get("ml_strategy", "balanced")
|
||||
action = "no_change"
|
||||
recommendation = None
|
||||
|
||||
if len(qualified) >= 2:
|
||||
best = max(qualified, key=lambda x: x["avg_quality"])
|
||||
recommendation = best["strategy"]
|
||||
if best["strategy"] != current_strategy:
|
||||
cfg.set("ml_strategy", best["strategy"], source="auto_tuner")
|
||||
action = "updated"
|
||||
logger.info(
|
||||
f"[AutoTune API] Strategy: '{current_strategy}' → "
|
||||
f"'{best['strategy']}' (quality={best['avg_quality']:.1f})"
|
||||
)
|
||||
elif len(comparison) > 0:
|
||||
action = "insufficient_data"
|
||||
recommendation = comparison[0]["strategy"] # best so far even if < 10 calls
|
||||
|
||||
# Per-hour best strategy (informational — not auto-applied)
|
||||
# Shows which strategy logged the highest quality at each hour
|
||||
hour_best: list = []
|
||||
if hourly:
|
||||
for h in hourly:
|
||||
# Find which strategy performed best in this hour block
|
||||
# (simple: use the dominant strategy for that hour from comparison)
|
||||
hour_best.append({
|
||||
"hour": h["hour"],
|
||||
"avg_quality": h["avg_quality"],
|
||||
"call_count": h["call_count"],
|
||||
"sla_breaches": h["sla_breaches"],
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"current_strategy": cfg.get("ml_strategy", "balanced"),
|
||||
"recommendation": recommendation,
|
||||
"total_records": total_records,
|
||||
"strategy_comparison": comparison,
|
||||
"hourly_quality": hour_best,
|
||||
"message": (
|
||||
f"Strategy updated to '{recommendation}'."
|
||||
if action == "updated"
|
||||
else "Current strategy is already optimal."
|
||||
if action == "no_change"
|
||||
else "More data needed (≥ 10 calls per strategy to compare)."
|
||||
),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] auto-tune: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /bandit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/bandit", summary="Thompson Sampling bandit — posterior stats per context")
|
||||
def ml_bandit():
|
||||
"""
|
||||
Shows the current state of the RL strategy bandit.
|
||||
|
||||
Each context (time_band|load_band) has 4 arms (strategies).
|
||||
For each arm:
|
||||
mean_reward — expected quality / 100 based on posterior mean
|
||||
observations — number of observed calls (excluding prior)
|
||||
alpha / beta — Beta distribution parameters
|
||||
|
||||
The bandit uses Thompson Sampling to select strategies automatically
|
||||
on every /riderassign call, balancing exploration vs exploitation.
|
||||
"""
|
||||
try:
|
||||
from app.services.ml.strategy_bandit import get_bandit
|
||||
return {"status": "ok", **get_bandit().get_stats()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] bandit: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /rider-efficiency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/rider-efficiency", summary="Per-rider efficiency scores from 30-day CSV history")
|
||||
def ml_rider_efficiency():
|
||||
"""
|
||||
Computes efficiency scores for each rider from the delivery_details.csv.
|
||||
|
||||
Metrics:
|
||||
delivery_count — total deliveries in the 30-day window
|
||||
avg_km — average km per delivery
|
||||
unique_zones — number of distinct 1.1 km delivery cells served
|
||||
efficiency_score — normalised 0..1 composite (high = efficient)
|
||||
|
||||
Used internally as tiebreaker during solo rider consolidation
|
||||
and Phase-0 pattern pre-assignment host selection.
|
||||
"""
|
||||
try:
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
scores = get_delivery_history_store().get_rider_efficiency_scores()
|
||||
ranked = sorted(scores.items(), key=lambda x: x[1]["efficiency_score"], reverse=True)
|
||||
return {
|
||||
"status": "ok",
|
||||
"rider_count": len(scores),
|
||||
"riders": [{"rider_id": rid, **data} for rid, data in ranked],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] rider-efficiency: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/history", summary="Delivery-history FAISS store stats + pattern table")
|
||||
def ml_history(patterns: bool = False):
|
||||
"""
|
||||
Shows the delivery history store status.
|
||||
|
||||
Add ?patterns=true to include the full pattern table
|
||||
(all clear dominant-rider zones, sorted by pattern score).
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import (
|
||||
get_delivery_history_store, _INDEX_PATH, _META_PATH, CSV_PATH
|
||||
)
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
meta = {}
|
||||
if os.path.isfile(_META_PATH):
|
||||
with open(_META_PATH, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
|
||||
resp = {
|
||||
"status": "ok",
|
||||
"record_count": store.record_count(),
|
||||
"pattern_count": store.pattern_count(),
|
||||
"index_ready": store.record_count() > 0,
|
||||
"disk_index": os.path.isfile(_INDEX_PATH),
|
||||
"csv_path": CSV_PATH,
|
||||
"csv_exists": os.path.isfile(CSV_PATH),
|
||||
"saved_meta": meta,
|
||||
}
|
||||
if patterns:
|
||||
resp["patterns"] = store.get_pattern_stats()
|
||||
return resp
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] history: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inject-corrections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/inject-corrections", summary="Inject manually-corrected delivery CSV as high-weight priors")
|
||||
def ml_inject_corrections(
|
||||
corrections_path: str = Body(default="delivery_corrections.csv", embed=True),
|
||||
weight: int = Body(default=5, embed=True),
|
||||
):
|
||||
"""
|
||||
Loads a manually-corrected delivery CSV and injects it into the live
|
||||
delivery-history pattern table as high-weight prior evidence.
|
||||
|
||||
Each record in the corrections file is counted `weight` times (default 5),
|
||||
so 5 correction votes easily override 1-2 noise votes from regular history.
|
||||
|
||||
The correction CSV must have the same columns as delivery_details.csv:
|
||||
pickupcustomer, pickuplat, pickuplon, deliverylat, deliverylong,
|
||||
userid, ridername
|
||||
|
||||
After injection the pattern table is rebuilt in-memory and saved to disk.
|
||||
No server restart needed.
|
||||
|
||||
Parameters:
|
||||
corrections_path Path inside the container (default: delivery_corrections.csv)
|
||||
weight How many times each correction record is counted (default: 5)
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
result = store.inject_corrections(corrections_path, weight=weight)
|
||||
return {"status": "ok", **result}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] inject-corrections: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /reload-history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/reload-history", summary="Rebuild FAISS delivery-history index from CSV")
|
||||
def ml_reload_history():
|
||||
"""
|
||||
Forces a full rebuild of the delivery-history FAISS index from
|
||||
delivery_details.csv, then saves the new index to disk.
|
||||
|
||||
Call this whenever you update the CSV with a fresh 30-day export.
|
||||
No server restart needed — the in-memory store is hot-swapped.
|
||||
|
||||
Returns the number of records now loaded.
|
||||
"""
|
||||
from app.services.vector.delivery_history_store import get_delivery_history_store
|
||||
try:
|
||||
store = get_delivery_history_store()
|
||||
n = store.reload_from_csv()
|
||||
return {
|
||||
"status": "ok",
|
||||
"record_count": n,
|
||||
"message": f"FAISS history index rebuilt from CSV — {n} records loaded and saved to disk.",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] reload-history: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /refresh-eta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/refresh-eta", summary="Sync nearledb -> local mirror and rebuild empirical ETA stats")
|
||||
def ml_refresh_eta(
|
||||
days: int = Body(default=None, embed=True),
|
||||
tenant_id: int = Body(default=916, embed=True),
|
||||
full: bool = Body(default=False, embed=True),
|
||||
):
|
||||
"""
|
||||
Manually trigger the empirical-ETA pipeline (the autonomous agent does this
|
||||
on a schedule too): incremental READ-ONLY pull of new completed deliveries
|
||||
from nearledb into the local mirror, then rebuild the learned medians.
|
||||
|
||||
`days` defaults to `eta_history_days`. Set `full=true` to force a full
|
||||
backfill of the window instead of an incremental sync.
|
||||
"""
|
||||
from app.services.routing.delivery_history_service import get_delivery_history_service
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
window = int(days if days is not None else get_config().get("eta_history_days", 14))
|
||||
result = get_delivery_history_service().refresh_eta_stats(
|
||||
days=window, tenant_id=tenant_id, full=full
|
||||
)
|
||||
return {"status": "ok", "result": result}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] refresh-eta: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /eta-accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/eta-accuracy", summary="Backtest: formula ETA MAE vs empirical ETA MAE")
|
||||
def ml_eta_accuracy(days: int = None, tenant_id: int = 916):
|
||||
"""
|
||||
Honest time-split backtest. Trains empirical medians on the older 80% of the
|
||||
history window and reports mean-absolute-error (minutes) of the formula vs
|
||||
the empirical model on the held-out recent 20%.
|
||||
|
||||
Use this as the gate before trusting empirical ETAs: if `interpretation` is
|
||||
not "empirical_better", leave `eta_empirical_enabled=false` and investigate.
|
||||
"""
|
||||
from app.services.routing.delivery_history_service import get_delivery_history_service
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
cfg = get_config()
|
||||
window = int(days if days is not None else cfg.get("eta_history_days", 14))
|
||||
result = get_delivery_history_service().backtest(
|
||||
days=window,
|
||||
tenant_id=tenant_id,
|
||||
min_samples=int(cfg.get("eta_min_samples", 20)),
|
||||
stat=str(cfg.get("eta_stat", "median")),
|
||||
)
|
||||
return {"status": "ok", "backtest": result}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] eta-accuracy: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET/POST /road-eval (autonomous road-sequencing decision agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/road-eval", summary="Last autonomous road-sequencing decision")
|
||||
def ml_road_eval_get():
|
||||
"""
|
||||
Show the road-sequencing agent's most recent decision: whether road-aware
|
||||
ordering beat straight-line ordering on real batches, the measured mean
|
||||
travel-time gain, and whether it auto-enabled/disabled `routing_use_road_distance`.
|
||||
"""
|
||||
from app.services.routing.road_sequencing_agent import get_road_agent
|
||||
from app.config.dynamic_config import get_config
|
||||
try:
|
||||
agent = get_road_agent()
|
||||
return {
|
||||
"status": "ok",
|
||||
"road_distance_enabled": bool(get_config().get("routing_use_road_distance", False)),
|
||||
"auto_manage": bool(get_config().get("routing_auto_manage", True)),
|
||||
"last_decision": agent.last_decision or get_config().get("routing_road_eval", {}),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] road-eval get: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/road-eval", summary="Run the road-sequencing decision now")
|
||||
def ml_road_eval_run():
|
||||
"""
|
||||
Trigger an immediate evaluation + autonomous decision (the agent also does
|
||||
this on a schedule). Measures road vs aerial sequencing on sampled real
|
||||
batches and may flip `routing_use_road_distance` based on the measured gain.
|
||||
Also reports whether we beat the riders' actual delivered order.
|
||||
"""
|
||||
from app.services.routing.road_sequencing_agent import get_road_agent
|
||||
try:
|
||||
return {"status": "ok", "decision": get_road_agent().decide_and_apply()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] road-eval run: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /rider-affinity (learned vs configured rider→kitchen affinity)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/rider-affinity", summary="Learned vs configured rider→kitchen affinity")
|
||||
def ml_rider_affinity(refresh: bool = False):
|
||||
"""
|
||||
Show the learned rider→kitchen affinity (from real delivery history) merged
|
||||
with the curated config. `?refresh=true` recomputes from the local mirror
|
||||
first. Learned data only augments SOFT steering — hard kitchen locks and
|
||||
BLOCKED_RIDERS stay sourced from the curated config.
|
||||
"""
|
||||
from app.services.routing.rider_affinity_service import get_rider_affinity
|
||||
try:
|
||||
aff = get_rider_affinity()
|
||||
if refresh:
|
||||
aff.refresh()
|
||||
return {"status": "ok", "affinity": aff.get_summary()}
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] rider-affinity: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/export", summary="Download all assignment logs as CSV")
|
||||
def ml_export():
|
||||
from app.services.ml.ml_data_collector import get_collector
|
||||
try:
|
||||
csv_data = get_collector().export_csv()
|
||||
response = PlainTextResponse(content=csv_data, media_type="text/csv")
|
||||
response.headers["Content-Disposition"] = 'attachment; filename="assignment_log.csv"'
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error(f"[ML API] export: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
1373
app/routes/optimization.py
Normal file
1373
app/routes/optimization.py
Normal file
File diff suppressed because it is too large
Load Diff
149
app/routes/riders.py
Normal file
149
app/routes/riders.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
Riders Admin API
|
||||
================
|
||||
Endpoints for the operations team to manage rider substitutions.
|
||||
|
||||
POST /api/v1/riders/substitution – register one or many substitutions
|
||||
GET /api/v1/riders/substitution – list upcoming/active subs
|
||||
DELETE /api/v1/riders/substitution/{sub_date}/{absent_rider_id} – cancel one
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Path
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/riders",
|
||||
tags=["Riders & Substitutions"],
|
||||
responses={500: {"description": "Internal server error"}},
|
||||
)
|
||||
|
||||
|
||||
class SubstitutionEntry(BaseModel):
|
||||
sub_date: str
|
||||
absent_rider_id: int
|
||||
sub_rider_id: int
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_entry(self):
|
||||
try:
|
||||
date.fromisoformat(self.sub_date)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid date format '{self.sub_date}'. Use YYYY-MM-DD.")
|
||||
if self.absent_rider_id == self.sub_rider_id:
|
||||
raise ValueError("absent_rider_id and sub_rider_id must be different.")
|
||||
return self
|
||||
|
||||
|
||||
@router.post("/substitution", summary="Register one or multiple rider substitutions")
|
||||
def register_substitution(entries: List[SubstitutionEntry] = Body(...)):
|
||||
"""
|
||||
Register rider substitutions for the operations team. Accepts a list so
|
||||
you can submit all absent riders for a day in one call.
|
||||
|
||||
Example — single:
|
||||
```json
|
||||
[{"sub_date": "2026-06-20", "absent_rider_id": 101, "sub_rider_id": 205}]
|
||||
```
|
||||
|
||||
Example — batch (3 riders absent same day):
|
||||
```json
|
||||
[
|
||||
{"sub_date": "2026-06-20", "absent_rider_id": 101, "sub_rider_id": 205},
|
||||
{"sub_date": "2026-06-20", "absent_rider_id": 102, "sub_rider_id": 206},
|
||||
{"sub_date": "2026-06-21", "absent_rider_id": 103, "sub_rider_id": 207}
|
||||
]
|
||||
```
|
||||
|
||||
Each sub rider **must** appear in that day's getriderlogs response.
|
||||
The assignment engine copies the absent rider's kitchen ownership, soft
|
||||
preferences, and home location onto the sub rider automatically — reverts
|
||||
the next day with no action needed.
|
||||
|
||||
Posting the same (sub_date, absent_rider_id) again updates sub_rider_id.
|
||||
"""
|
||||
if not entries:
|
||||
raise HTTPException(status_code=400, detail="Request body must be a non-empty list.")
|
||||
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
svc = get_substitution_service()
|
||||
results = [svc.register(e.sub_date, e.absent_rider_id, e.sub_rider_id) for e in entries]
|
||||
return {
|
||||
"status": "ok",
|
||||
"registered": len(results),
|
||||
"substitutions": results,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] register_substitution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/substitution", summary="List upcoming and active rider substitutions")
|
||||
def list_substitutions(from_date: str = None):
|
||||
"""
|
||||
Returns all substitution records on or after `from_date` (default: today).
|
||||
Use `?from_date=2026-06-01` to look back further.
|
||||
"""
|
||||
if from_date is not None:
|
||||
try:
|
||||
date.fromisoformat(from_date)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid date format '{from_date}'. Use YYYY-MM-DD.",
|
||||
)
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
records = get_substitution_service().list_all(from_date)
|
||||
return {
|
||||
"status": "ok",
|
||||
"count": len(records),
|
||||
"substitutions": records,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] list_substitutions: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/substitution/{sub_date}/{absent_rider_id}",
|
||||
summary="Cancel a rider substitution",
|
||||
)
|
||||
def cancel_substitution(
|
||||
sub_date: str = Path(..., example="2026-06-20"),
|
||||
absent_rider_id: int = Path(..., example=101),
|
||||
):
|
||||
"""
|
||||
Remove a substitution record. The sub rider will no longer inherit the
|
||||
absent rider's profile on that date.
|
||||
"""
|
||||
try:
|
||||
date.fromisoformat(sub_date)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid date format '{sub_date}'. Use YYYY-MM-DD.",
|
||||
)
|
||||
try:
|
||||
from app.services.rider.substitution_service import get_substitution_service
|
||||
removed = get_substitution_service().cancel(sub_date, absent_rider_id)
|
||||
if not removed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No substitution found for absent_rider_id={absent_rider_id} on {sub_date}.",
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Substitution cancelled: rider {absent_rider_id} on {sub_date}.",
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[Riders API] cancel_substitution: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
175
app/services/__init__.py
Normal file
175
app/services/__init__.py
Normal 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()
|
||||
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/assignment_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/assignment_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/clustering_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/clustering_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/get_active_riders.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/get_active_riders.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/kalman_filter.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/kalman_filter.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/ml_data_collector.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/ml_data_collector.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/ml_hypertuner.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/ml_hypertuner.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
app/services/__pycache__/rider_history_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/rider_history_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/rider_state_manager.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/rider_state_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/route_optimizer.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/route_optimizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/__pycache__/zone_service.cpython-312.pyc
Normal file
BIN
app/services/__pycache__/zone_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/core/__pycache__/assignment_service.cpython-312.pyc
Normal file
BIN
app/services/core/__pycache__/assignment_service.cpython-312.pyc
Normal file
Binary file not shown.
1166
app/services/core/assignment_service.py
Normal file
1166
app/services/core/assignment_service.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/services/ml/__pycache__/behavior_analyzer.cpython-312.pyc
Normal file
BIN
app/services/ml/__pycache__/behavior_analyzer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/ml/__pycache__/id3_classifier.cpython-312.pyc
Normal file
BIN
app/services/ml/__pycache__/id3_classifier.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/ml/__pycache__/ml_data_collector.cpython-312.pyc
Normal file
BIN
app/services/ml/__pycache__/ml_data_collector.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
app/services/ml/__pycache__/strategy_bandit.cpython-312.pyc
Normal file
BIN
app/services/ml/__pycache__/strategy_bandit.cpython-312.pyc
Normal file
Binary file not shown.
55
app/services/ml/behavior_analyzer.py
Normal file
55
app/services/ml/behavior_analyzer.py
Normal 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"
|
||||
611
app/services/ml/ml_data_collector.py
Normal file
611
app/services/ml/ml_data_collector.py
Normal 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 (0–100, 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
|
||||
277
app/services/ml/strategy_bandit.py
Normal file
277
app/services/ml/strategy_bandit.py
Normal 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
|
||||
78
app/services/rider/get_active_riders.py
Normal file
78
app/services/rider/get_active_riders.py
Normal 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 []
|
||||
|
||||
68
app/services/rider/rider_history_service.py
Normal file
68
app/services/rider/rider_history_service.py
Normal 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)
|
||||
|
||||
120
app/services/rider/rider_state_manager.py
Normal file
120
app/services/rider/rider_state_manager.py
Normal 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()
|
||||
163
app/services/rider/substitution_service.py
Normal file
163
app/services/rider/substitution_service.py
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/services/routing/__pycache__/kalman_filter.cpython-312.pyc
Normal file
BIN
app/services/routing/__pycache__/kalman_filter.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/services/routing/__pycache__/route_optimizer.cpython-312.pyc
Normal file
BIN
app/services/routing/__pycache__/route_optimizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
app/services/routing/__pycache__/zone_service.cpython-312.pyc
Normal file
BIN
app/services/routing/__pycache__/zone_service.cpython-312.pyc
Normal file
Binary file not shown.
860
app/services/routing/batch_efficiency.py
Normal file
860
app/services/routing/batch_efficiency.py
Normal 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
|
||||
300
app/services/routing/clustering_service.py
Normal file
300
app/services/routing/clustering_service.py
Normal 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"
|
||||
864
app/services/routing/delivery_history_service.py
Normal file
864
app/services/routing/delivery_history_service.py
Normal 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
|
||||
143
app/services/routing/empirical_eta_calculator.py
Normal file
143
app/services/routing/empirical_eta_calculator.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Empirical ETA Calculator
|
||||
========================
|
||||
|
||||
Drop-in replacement for `RealisticETACalculator` that prefers ETAs *learned
|
||||
from actual delivery times* (see delivery_history_service.py) and falls back to
|
||||
the original formula whenever history is too thin.
|
||||
|
||||
Design goals
|
||||
------------
|
||||
* **Interface-compatible**: `calculate_eta(...)` keeps the same positional args
|
||||
and `int`-minutes return as the formula calculator, so existing call sites
|
||||
work unchanged. Extra args (`kitchen`, `drop_coords`, `rider_id`) are optional
|
||||
and let callers that *have* that context (the optimizer does) get sharper,
|
||||
zone-aware estimates.
|
||||
* **Safe by default**: if empirical data is missing for a context, or the
|
||||
feature is disabled via `eta_empirical_enabled`, it returns exactly what the
|
||||
formula would — zero behavior change until real data exists.
|
||||
* **Non-blocking**: never calls Postgres on the hot path. If the stats cache is
|
||||
empty it kicks off a one-shot background refresh and serves the formula
|
||||
meanwhile.
|
||||
|
||||
Empirical values are real door-to-door leg times (gap between consecutive
|
||||
deliveries), so they already include travel + drop service time. We only add the
|
||||
kitchen pickup buffer for the first leg, mirroring the formula's semantics.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
from app.services.routing.realistic_eta_calculator import (
|
||||
RealisticETACalculator,
|
||||
get_time_of_day_category,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EmpiricalETACalculator:
|
||||
"""ETA calculator backed by empirical history, with formula fallback."""
|
||||
|
||||
def __init__(self):
|
||||
# Composed formula calculator — the fallback and the source of buffers.
|
||||
self.formula = RealisticETACalculator()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main entry point (signature is a superset of RealisticETACalculator)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def calculate_eta(
|
||||
self,
|
||||
distance_km: float,
|
||||
is_first_order: bool = False,
|
||||
order_type: str = "Economy",
|
||||
time_of_day: str = "peak",
|
||||
kitchen: Optional[str] = None,
|
||||
drop_coords: Optional[Tuple[float, float]] = None,
|
||||
rider_id: Optional[Any] = None,
|
||||
) -> int:
|
||||
"""Return ETA in minutes — empirical if available, else the formula."""
|
||||
if distance_km is not None and distance_km <= 0 and not is_first_order:
|
||||
return 0
|
||||
|
||||
from app.config.dynamic_config import get_config
|
||||
cfg = get_config()
|
||||
|
||||
if not bool(cfg.get("eta_empirical_enabled", True)):
|
||||
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
||||
|
||||
try:
|
||||
from app.services.routing.delivery_history_service import (
|
||||
get_delivery_history_service,
|
||||
)
|
||||
svc = get_delivery_history_service()
|
||||
|
||||
if not svc.has_data():
|
||||
# Populate in the background; serve the formula for now.
|
||||
svc.maybe_background_refresh(
|
||||
days=int(cfg.get("eta_history_days", 14)),
|
||||
)
|
||||
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
||||
|
||||
hit = svc.lookup(
|
||||
distance_km=float(distance_km or 0.0),
|
||||
traffic_cat=time_of_day,
|
||||
kitchen=kitchen,
|
||||
drop_coords=drop_coords,
|
||||
min_samples=int(cfg.get("eta_min_samples", 20)),
|
||||
stat=str(cfg.get("eta_stat", "median")),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[EmpiricalETA] lookup failed, using formula: {e}")
|
||||
hit = None
|
||||
|
||||
if not hit:
|
||||
return self._formula_eta(distance_km, is_first_order, order_type, time_of_day)
|
||||
|
||||
value = float(hit["value_min"])
|
||||
# First leg includes time spent picking up at the kitchen; the empirical
|
||||
# leg gap starts at pickup completion, so add the same buffer the formula uses.
|
||||
if is_first_order:
|
||||
value += float(cfg.get("eta_pickup_time_min", 3.0))
|
||||
return int(value) + 1 # round up for safety, matching the formula
|
||||
|
||||
def _formula_eta(self, distance_km, is_first_order, order_type, time_of_day) -> int:
|
||||
return self.formula.calculate_eta(
|
||||
distance_km=distance_km,
|
||||
is_first_order=is_first_order,
|
||||
order_type=order_type,
|
||||
time_of_day=time_of_day,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Batch helper (kept compatible with RealisticETACalculator)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def calculate_batch_eta(self, orders: List[dict]) -> List[dict]:
|
||||
"""Calculate ETAs for a batch in sequence (formula-parity batch path)."""
|
||||
traffic = get_time_of_day_category()
|
||||
for order in orders:
|
||||
distance_km = float(order.get("previouskms", 0) or 0)
|
||||
step = order.get("step", 1)
|
||||
order_type = order.get("ordertype", "Economy")
|
||||
drop = None
|
||||
try:
|
||||
dlat = float(order.get("deliverylat") or order.get("droplat") or 0)
|
||||
dlon = float(order.get("deliverylong") or order.get("droplon") or 0)
|
||||
if dlat and dlon:
|
||||
drop = (dlat, dlon)
|
||||
except (TypeError, ValueError):
|
||||
drop = None
|
||||
eta = self.calculate_eta(
|
||||
distance_km=distance_km,
|
||||
is_first_order=(step == 1),
|
||||
order_type=order_type,
|
||||
time_of_day=traffic,
|
||||
kitchen=order.get("pickupcustomer") or order.get("locationname"),
|
||||
drop_coords=drop,
|
||||
rider_id=order.get("userid"),
|
||||
)
|
||||
order["eta"] = str(eta)
|
||||
order["eta_empirical"] = True
|
||||
return orders
|
||||
327
app/services/routing/kalman_filter.py
Normal file
327
app/services/routing/kalman_filter.py
Normal 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
|
||||
127
app/services/routing/realistic_eta_calculator.py
Normal file
127
app/services/routing/realistic_eta_calculator.py
Normal 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"
|
||||
171
app/services/routing/rider_affinity_service.py
Normal file
171
app/services/routing/rider_affinity_service.py
Normal 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
|
||||
233
app/services/routing/road_sequencing_agent.py
Normal file
233
app/services/routing/road_sequencing_agent.py
Normal 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
|
||||
1614
app/services/routing/route_optimizer.py
Normal file
1614
app/services/routing/route_optimizer.py
Normal file
File diff suppressed because it is too large
Load Diff
201
app/services/routing/zone_service.py
Normal file
201
app/services/routing/zone_service.py
Normal 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user