466 lines
20 KiB
Python
466 lines
20 KiB
Python
"""Route Optimizer Agent - Optimizes delivery routes based on zones and available hubs."""
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from time import monotonic
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
from dataclasses import dataclass
|
|
from math import radians, cos, sin, asin, sqrt
|
|
from collections import defaultdict
|
|
|
|
from core.agent import SpecializedAgent
|
|
from core.types import AgentTask, MessageType, ZoneType
|
|
from core.logger import logger
|
|
|
|
_CACHE_TTL_SECONDS = 600 # 10 minutes
|
|
|
|
|
|
@dataclass
|
|
class Waypoint:
|
|
location_id: str
|
|
lat: float
|
|
lng: float
|
|
address: str
|
|
type: str # pickup, delivery, hub, spoke
|
|
order_id: Optional[str] = None
|
|
time_window_start: Optional[datetime] = None
|
|
time_window_end: Optional[datetime] = None
|
|
|
|
|
|
@dataclass
|
|
class Route:
|
|
route_id: str
|
|
waypoints: List[Waypoint]
|
|
total_distance_km: float
|
|
estimated_duration_minutes: float
|
|
vehicle_id: str
|
|
zones_traversed: List[str]
|
|
fuel_cost: float
|
|
efficiency_score: float
|
|
|
|
|
|
class RouteOptimizerAgent(SpecializedAgent):
|
|
"""Route Optimizer Agent - Optimizes delivery routes based on zones, traffic, and constraints."""
|
|
|
|
def __init__(self):
|
|
super().__init__(
|
|
agent_id="ROUTE_OPTIMIZER",
|
|
domain="route_optimization",
|
|
description="Optimizes delivery routes based on zones, hubs, and constraints"
|
|
)
|
|
|
|
self._hubs = {
|
|
"DL-HUB-01": (28.6139, 77.2090),
|
|
"DL-HUB-02": (28.5355, 77.2100),
|
|
"MU-HUB-01": (19.0760, 72.8777),
|
|
"MU-HUB-02": (19.1650, 72.8500),
|
|
"BL-HUB-01": (12.9716, 77.5946),
|
|
"HY-HUB-01": (17.3850, 78.4867),
|
|
"PU-HUB-01": (18.5204, 73.8567),
|
|
"KL-HUB-01": (22.5726, 88.3639),
|
|
}
|
|
|
|
self._zones = self._init_zones()
|
|
# Cache stores (Route, created_at_monotonic) — evicted after _CACHE_TTL_SECONDS
|
|
self._route_cache: Dict[str, Tuple[Route, float]] = {}
|
|
self._traffic_patterns = self._init_traffic_patterns()
|
|
self._route_history: List[Dict] = []
|
|
|
|
def _init_zones(self) -> Dict[str, Dict]:
|
|
return {
|
|
"north_delhi": {"pincode_range": ("100", "199"), "center": (28.6139, 77.2090), "hub": "DL-HUB-01", "typical_traffic": "medium"},
|
|
"south_delhi": {"pincode_range": ("200", "299"), "center": (28.5355, 77.2100), "hub": "DL-HUB-02", "typical_traffic": "high"},
|
|
"mumbai_west": {"pincode_range": ("400", "449"), "center": (19.0760, 72.8777), "hub": "MU-HUB-01", "typical_traffic": "high"},
|
|
"mumbai_east": {"pincode_range": ("450", "499"), "center": (19.1650, 72.8500), "hub": "MU-HUB-02", "typical_traffic": "medium"},
|
|
"bangalore": {"pincode_range": ("560", "562"), "center": (12.9716, 77.5946), "hub": "BL-HUB-01", "typical_traffic": "medium"},
|
|
"hyderabad": {"pincode_range": ("500", "599"), "center": (17.3850, 78.4867), "hub": "HY-HUB-01", "typical_traffic": "medium"},
|
|
"pune": {"pincode_range": ("400", "499"), "center": (18.5204, 73.8567), "hub": "PU-HUB-01", "typical_traffic": "medium"},
|
|
"kolkata": {"pincode_range": ("600", "699"), "center": (22.5726, 88.3639), "hub": "KL-HUB-01", "typical_traffic": "low"},
|
|
}
|
|
|
|
def _init_traffic_patterns(self) -> Dict[str, Dict]:
|
|
return {
|
|
"morning": {"multiplier": 1.2, "description": "7AM-10AM rush"},
|
|
"midday": {"multiplier": 1.0, "description": "10AM-4PM normal"},
|
|
"evening": {"multiplier": 1.5, "description": "4PM-8PM rush"},
|
|
"night": {"multiplier": 0.8, "description": "8PM-7AM light"},
|
|
}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Cache helpers with TTL #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _cache_put(self, route_id: str, route: Route):
|
|
self._route_cache[route_id] = (route, monotonic())
|
|
|
|
def _cache_get(self, route_id: str) -> Optional[Route]:
|
|
entry = self._route_cache.get(route_id)
|
|
if entry is None:
|
|
return None
|
|
route, ts = entry
|
|
if monotonic() - ts >= _CACHE_TTL_SECONDS:
|
|
del self._route_cache[route_id]
|
|
return None
|
|
return route
|
|
|
|
async def _heartbeat(self):
|
|
"""Evict expired entries from route cache."""
|
|
now = monotonic()
|
|
expired = [rid for rid, (_, ts) in self._route_cache.items() if now - ts >= _CACHE_TTL_SECONDS]
|
|
for rid in expired:
|
|
del self._route_cache[rid]
|
|
if expired:
|
|
logger.debug(f"Route cache: evicted {len(expired)} expired entries ({len(self._route_cache)} remaining)")
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Task dispatch #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
async def handle_task(self, task: AgentTask) -> Dict[str, Any]:
|
|
handlers = {
|
|
"optimize_route": self._optimize_route,
|
|
"plan_multi_stop": self._plan_multi_stop,
|
|
"plan_inter_hub_route": self._plan_inter_hub_route,
|
|
"calculate_eta": self._calculate_eta,
|
|
"avoid_zone": self._avoid_zone,
|
|
"reoptimize_route": self._reoptimize_route,
|
|
"get_zone_routes": self._get_zone_routes,
|
|
"batch_optimize": self._batch_optimize,
|
|
}
|
|
handler = handlers.get(task.task_type, self._unknown_task)
|
|
return await handler(task)
|
|
|
|
async def _optimize_route(self, task: AgentTask) -> Dict[str, Any]:
|
|
order_id = task.data.get("order_id")
|
|
pickup = task.data.get("pickup", {})
|
|
delivery = task.data.get("delivery", {})
|
|
vehicle_type = task.data.get("vehicle_type", "van")
|
|
|
|
logger.info(f"Route Optimizer: Optimizing route for order {order_id}")
|
|
|
|
pickup_coords = (pickup.get("lat", 28.6139), pickup.get("lng", 77.2090))
|
|
delivery_coords = (delivery.get("lat", 19.0760), delivery.get("lng", 72.8777))
|
|
|
|
direct_distance = self._haversine_distance(pickup_coords, delivery_coords)
|
|
optimal_path = self._find_optimal_path(pickup_coords, delivery_coords)
|
|
total_distance = self._calculate_total_distance(optimal_path)
|
|
traffic_multiplier = self._get_traffic_multiplier()
|
|
estimated_time = (total_distance / 30) * traffic_multiplier * 60
|
|
|
|
route_id = f"RT-OPT-{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
waypoints = []
|
|
for i, coords in enumerate(optimal_path):
|
|
hub_id = self._find_nearest_hub(coords)
|
|
waypoints.append(Waypoint(
|
|
location_id=f"WPT-{i}",
|
|
lat=coords[0],
|
|
lng=coords[1],
|
|
address=str(self._hubs.get(hub_id, ("Unknown",))[0]) if hub_id else "Route point",
|
|
type="hub" if 0 < i < len(optimal_path) - 1 else ("pickup" if i == 0 else "delivery"),
|
|
order_id=order_id,
|
|
))
|
|
|
|
route = Route(
|
|
route_id=route_id,
|
|
waypoints=waypoints,
|
|
total_distance_km=total_distance,
|
|
estimated_duration_minutes=estimated_time,
|
|
vehicle_id=task.data.get("vehicle_id", ""),
|
|
zones_traversed=self._identify_zones(optimal_path),
|
|
fuel_cost=total_distance * 3.5,
|
|
efficiency_score=self._calculate_efficiency(total_distance, direct_distance),
|
|
)
|
|
|
|
self._cache_put(route_id, route)
|
|
|
|
logger.info(
|
|
f"Route {route_id}: {len(waypoints)} waypoints | {total_distance:.1f} km | "
|
|
f"ETA {estimated_time:.0f} min | efficiency {route.efficiency_score:.0f}%"
|
|
)
|
|
|
|
return {
|
|
"status": "optimized",
|
|
"route_id": route_id,
|
|
"waypoints": [{"lat": w.lat, "lng": w.lng, "type": w.type, "address": w.address} for w in waypoints],
|
|
"total_distance_km": total_distance,
|
|
"estimated_duration_minutes": estimated_time,
|
|
"zones_traversed": route.zones_traversed,
|
|
"fuel_cost": route.fuel_cost,
|
|
"efficiency_score": route.efficiency_score,
|
|
}
|
|
|
|
async def _plan_multi_stop(self, task: AgentTask) -> Dict[str, Any]:
|
|
stops = task.data.get("stops", [])
|
|
vehicle_id = task.data.get("vehicle_id")
|
|
|
|
logger.info(f"Route Optimizer: Planning multi-stop route with {len(stops)} stops")
|
|
|
|
waypoints = [
|
|
Waypoint(
|
|
location_id=f"STOP-{i}",
|
|
lat=stop.get("lat"),
|
|
lng=stop.get("lng"),
|
|
address=stop.get("address", ""),
|
|
type=stop.get("type", "delivery"),
|
|
order_id=stop.get("order_id"),
|
|
)
|
|
for i, stop in enumerate(stops)
|
|
]
|
|
|
|
optimized_order = self._nearest_neighbor_optimization(waypoints)
|
|
total_distance = self._calculate_route_distance(optimized_order)
|
|
estimated_time = (total_distance / 25) * 60
|
|
|
|
route_id = f"RT-MULTI-{uuid.uuid4().hex[:8].upper()}"
|
|
route = Route(
|
|
route_id=route_id,
|
|
waypoints=optimized_order,
|
|
total_distance_km=total_distance,
|
|
estimated_duration_minutes=estimated_time,
|
|
vehicle_id=vehicle_id,
|
|
zones_traversed=self._identify_zones([(w.lat, w.lng) for w in optimized_order]),
|
|
fuel_cost=total_distance * 3.5,
|
|
efficiency_score=85.0,
|
|
)
|
|
self._cache_put(route_id, route)
|
|
|
|
return {
|
|
"status": "planned",
|
|
"route_id": route_id,
|
|
"stop_order": [{"order": i + 1, "lat": w.lat, "lng": w.lng, "type": w.type} for i, w in enumerate(optimized_order)],
|
|
"total_distance_km": total_distance,
|
|
"estimated_duration_minutes": estimated_time,
|
|
}
|
|
|
|
async def _plan_inter_hub_route(self, task: AgentTask) -> Dict[str, Any]:
|
|
from_hub = task.data.get("from_hub")
|
|
to_hub = task.data.get("to_hub")
|
|
order_id = task.data.get("order_id")
|
|
|
|
logger.info(f"Route Optimizer: Inter-hub route {from_hub} -> {to_hub}")
|
|
|
|
if from_hub not in self._hubs or to_hub not in self._hubs:
|
|
return {"status": "error", "message": "Invalid hub ID(s)"}
|
|
|
|
from_coords = self._hubs[from_hub]
|
|
to_coords = self._hubs[to_hub]
|
|
direct_distance = self._haversine_distance(from_coords, to_coords)
|
|
|
|
intermediate_hub = None
|
|
if direct_distance > 500:
|
|
intermediate_hub = self._find_intermediate_hub(from_coords, to_coords)
|
|
|
|
route_coords = (
|
|
[from_coords, self._hubs[intermediate_hub], to_coords]
|
|
if intermediate_hub else
|
|
[from_coords, to_coords]
|
|
)
|
|
total_distance = self._calculate_total_distance(route_coords)
|
|
estimated_time = (total_distance / 40) * 60
|
|
|
|
route_id = f"RT-IHUB-{uuid.uuid4().hex[:8].upper()}"
|
|
|
|
return {
|
|
"status": "planned",
|
|
"route_id": route_id,
|
|
"from_hub": from_hub,
|
|
"to_hub": to_hub,
|
|
"intermediate_hub": intermediate_hub,
|
|
"waypoints": [{"hub": h, "coords": self._hubs.get(h, (0, 0))} for h in [from_hub, intermediate_hub, to_hub] if h],
|
|
"total_distance_km": total_distance,
|
|
"estimated_duration_minutes": estimated_time,
|
|
"estimated_hours": estimated_time / 60,
|
|
}
|
|
|
|
async def _calculate_eta(self, task: AgentTask) -> Dict[str, Any]:
|
|
route_id = task.data.get("route_id")
|
|
current_location = task.data.get("current_location")
|
|
|
|
cached = self._cache_get(route_id)
|
|
if cached:
|
|
return {
|
|
"route_id": route_id,
|
|
"total_eta_minutes": cached.estimated_duration_minutes,
|
|
"remaining_distance_km": cached.total_distance_km,
|
|
"current_eta": (datetime.now() + timedelta(minutes=cached.estimated_duration_minutes)).isoformat(),
|
|
}
|
|
|
|
from_coords = (current_location.get("lat", 0), current_location.get("lng", 0))
|
|
to_coords = task.data.get("destination", (0, 0))
|
|
distance = self._haversine_distance(from_coords, to_coords)
|
|
eta_minutes = (distance / 30) * self._get_traffic_multiplier() * 60
|
|
|
|
return {
|
|
"distance_km": distance,
|
|
"eta_minutes": eta_minutes,
|
|
"current_eta": (datetime.now() + timedelta(minutes=eta_minutes)).isoformat(),
|
|
}
|
|
|
|
async def _avoid_zone(self, task: AgentTask) -> Dict[str, Any]:
|
|
route_id = task.data.get("route_id")
|
|
avoid_zone = task.data.get("zone")
|
|
|
|
logger.info(f"Route Optimizer: Avoiding zone {avoid_zone}")
|
|
|
|
if self._cache_get(route_id):
|
|
return {
|
|
"status": "replanned",
|
|
"route_id": route_id,
|
|
"avoided_zone": avoid_zone,
|
|
"additional_distance_km": 5.0,
|
|
"additional_time_minutes": 15,
|
|
}
|
|
|
|
return {"status": "error", "message": "Route not found"}
|
|
|
|
async def _reoptimize_route(self, task: AgentTask) -> Dict[str, Any]:
|
|
route_id = task.data.get("route_id")
|
|
new_stops = task.data.get("new_stops", [])
|
|
|
|
logger.info(f"Route Optimizer: Reoptimizing route {route_id}")
|
|
|
|
route = self._cache_get(route_id)
|
|
if route:
|
|
for stop in new_stops:
|
|
route.waypoints.append(Waypoint(
|
|
location_id=f"NEW-{len(route.waypoints)}",
|
|
lat=stop.get("lat"),
|
|
lng=stop.get("lng"),
|
|
address=stop.get("address", ""),
|
|
type="add_delivery",
|
|
order_id=stop.get("order_id"),
|
|
))
|
|
coords = [(w.lat, w.lng) for w in route.waypoints]
|
|
route.total_distance_km = self._calculate_total_distance(coords)
|
|
route.estimated_duration_minutes = (route.total_distance_km / 25) * 60
|
|
self._cache_put(route_id, route)
|
|
|
|
return {
|
|
"status": "reoptimized",
|
|
"route_id": route_id,
|
|
"new_distance_km": route.total_distance_km,
|
|
"new_eta_minutes": route.estimated_duration_minutes,
|
|
}
|
|
|
|
return {"status": "error", "message": "Route not found"}
|
|
|
|
async def _get_zone_routes(self, task: AgentTask) -> Dict[str, Any]:
|
|
zone = task.data.get("zone")
|
|
now = monotonic()
|
|
|
|
zone_routes = []
|
|
for route_id, (route, ts) in list(self._route_cache.items()):
|
|
if now - ts >= _CACHE_TTL_SECONDS:
|
|
continue
|
|
if zone in route.zones_traversed:
|
|
zone_routes.append({
|
|
"route_id": route.route_id,
|
|
"distance_km": route.total_distance_km,
|
|
"duration_minutes": route.estimated_duration_minutes,
|
|
})
|
|
|
|
return {"zone": zone, "total_routes": len(zone_routes), "routes": zone_routes}
|
|
|
|
async def _batch_optimize(self, task: AgentTask) -> Dict[str, Any]:
|
|
orders = task.data.get("orders", [])
|
|
logger.info(f"Route Optimizer: Batch optimizing {len(orders)} orders")
|
|
|
|
zone_groups: Dict[str, list] = defaultdict(list)
|
|
for order in orders:
|
|
zone = self._identify_zone_from_coords((order.get("lat", 0), order.get("lng", 0)))
|
|
zone_groups[zone].append(order)
|
|
|
|
results = [await self._optimize_zone_routes(zone, zone_orders) for zone, zone_orders in zone_groups.items()]
|
|
|
|
return {
|
|
"status": "batch_optimized",
|
|
"zones_optimized": len(results),
|
|
"total_orders": len(orders),
|
|
"total_distance_km": sum(r["total_distance_km"] for r in results),
|
|
"total_time_minutes": sum(r["estimated_time_minutes"] for r in results),
|
|
"zone_results": results,
|
|
}
|
|
|
|
async def _unknown_task(self, task: AgentTask) -> Dict[str, Any]:
|
|
return {"status": "error", "message": f"Unknown task: {task.task_type}"}
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Geometry helpers #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
def _haversine_distance(self, coord1: Tuple[float, float], coord2: Tuple[float, float]) -> float:
|
|
lat1, lon1 = coord1
|
|
lat2, lon2 = coord2
|
|
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
dlat = lat2 - lat1
|
|
dlon = lon2 - lon1
|
|
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
|
|
return 2 * asin(sqrt(a)) * 6371
|
|
|
|
def _find_optimal_path(self, start: Tuple[float, float], end: Tuple[float, float]) -> List[Tuple[float, float]]:
|
|
start_hub = self._find_nearest_hub(start)
|
|
end_hub = self._find_nearest_hub(end)
|
|
if start_hub != end_hub:
|
|
return [start, self._hubs[start_hub], self._hubs[end_hub], end]
|
|
return [start, end]
|
|
|
|
def _find_nearest_hub(self, coords: Tuple[float, float]) -> Optional[str]:
|
|
return min(self._hubs.keys(), key=lambda h: self._haversine_distance(coords, self._hubs[h]), default=None)
|
|
|
|
def _find_intermediate_hub(self, start: Tuple[float, float], end: Tuple[float, float]) -> Optional[str]:
|
|
mid = ((start[0] + end[0]) / 2, (start[1] + end[1]) / 2)
|
|
return self._find_nearest_hub(mid)
|
|
|
|
def _calculate_total_distance(self, coords: List[Tuple[float, float]]) -> float:
|
|
return sum(self._haversine_distance(coords[i], coords[i + 1]) for i in range(len(coords) - 1))
|
|
|
|
def _calculate_route_distance(self, waypoints: List[Waypoint]) -> float:
|
|
return self._calculate_total_distance([(w.lat, w.lng) for w in waypoints])
|
|
|
|
def _get_traffic_multiplier(self) -> float:
|
|
hour = datetime.now().hour
|
|
if 7 <= hour < 10:
|
|
return self._traffic_patterns["morning"]["multiplier"]
|
|
if 10 <= hour < 16:
|
|
return self._traffic_patterns["midday"]["multiplier"]
|
|
if 16 <= hour < 20:
|
|
return self._traffic_patterns["evening"]["multiplier"]
|
|
return self._traffic_patterns["night"]["multiplier"]
|
|
|
|
def _identify_zones(self, coords: List[Tuple[float, float]]) -> List[str]:
|
|
return list({self._identify_zone_from_coords(c) for c in coords if self._identify_zone_from_coords(c)})
|
|
|
|
def _identify_zone_from_coords(self, coords: Tuple[float, float]) -> str:
|
|
return min(self._zones.keys(), key=lambda z: self._haversine_distance(coords, self._zones[z]["center"]), default="unknown")
|
|
|
|
def _calculate_efficiency(self, actual_distance: float, direct_distance: float) -> float:
|
|
if direct_distance == 0:
|
|
return 100.0
|
|
return min(100.0, (direct_distance / actual_distance) * 100)
|
|
|
|
def _nearest_neighbor_optimization(self, waypoints: List[Waypoint]) -> List[Waypoint]:
|
|
if not waypoints:
|
|
return []
|
|
unvisited = waypoints[1:]
|
|
ordered = [waypoints[0]]
|
|
while unvisited:
|
|
current = ordered[-1]
|
|
nearest = min(unvisited, key=lambda w: self._haversine_distance((current.lat, current.lng), (w.lat, w.lng)))
|
|
ordered.append(nearest)
|
|
unvisited.remove(nearest)
|
|
return ordered
|
|
|
|
async def _optimize_zone_routes(self, zone: str, orders: List[Dict]) -> Dict[str, Any]:
|
|
total_distance = 0.0
|
|
total_time = 0.0
|
|
for i in range(0, len(orders), 5):
|
|
batch = orders[i:i + 5]
|
|
coords = [(o.get("lat", 0), o.get("lng", 0)) for o in batch]
|
|
dist = self._calculate_total_distance(coords)
|
|
total_distance += dist
|
|
total_time += (dist / 25) * 60
|
|
return {"zone": zone, "orders_in_zone": len(orders), "total_distance_km": total_distance, "estimated_time_minutes": total_time}
|
|
|
|
async def think(self, context: str, options: List[str] = None) -> str:
|
|
return f"[ROUTE_OPTIMIZER reasoning]: {context}"
|