Initial commit
This commit is contained in:
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
|
||||
}
|
||||
1
app/services/vector/__init__.py
Normal file
1
app/services/vector/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Vector services package."""
|
||||
BIN
app/services/vector/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
app/services/vector/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user