feat: Add /api/v1/routes/optimize endpoint for before/after comparison + integrate TSP into clustering
4027144 verified | """ | |
| Route Optimization API — Exposes VRP/TSP and before/after comparison. | |
| Addresses Challenge #4: "Comparison between current vs optimized routes" | |
| """ | |
| from typing import List, Dict, Any, Optional | |
| from fastapi import APIRouter, HTTPException | |
| from pydantic import BaseModel | |
| router = APIRouter(prefix="/routes", tags=["Route Optimization"]) | |
| class StopInput(BaseModel): | |
| id: str | |
| latitude: float | |
| longitude: float | |
| address: str = "" | |
| weight_kg: float = 0.0 | |
| service_time_min: float = 5.0 | |
| time_window_start: Optional[int] = None | |
| time_window_end: Optional[int] = None | |
| priority: str = "normal" | |
| class RouteInput(BaseModel): | |
| id: str = "route_1" | |
| stops: List[StopInput] | |
| class OptimizeRequest(BaseModel): | |
| routes: List[RouteInput] | |
| warehouse_lat: float = 19.076 | |
| warehouse_lng: float = 72.877 | |
| speed_kmh: float = 30.0 | |
| use_time_windows: bool = True | |
| class ClusterRequest(BaseModel): | |
| packages: List[Dict[str, Any]] | |
| method: str = "dbscan" # "dbscan" or "kmeans" | |
| num_drivers: Optional[int] = None | |
| eps_km: float = 5.0 | |
| min_samples: int = 2 | |
| async def optimize_routes(request: OptimizeRequest): | |
| """ | |
| Optimize multi-stop delivery routes using OR-Tools VRP + 2-opt. | |
| Returns before/after comparison with distance savings, time savings, and CO₂ reduction. | |
| This directly addresses Challenge #4: "Comparison between current vs optimized routes." | |
| """ | |
| from app.services.route_optimization_engine import compare_routes | |
| routes_data = [] | |
| for route in request.routes: | |
| routes_data.append({ | |
| "id": route.id, | |
| "stops": [s.model_dump() for s in route.stops], | |
| }) | |
| comparisons = compare_routes( | |
| routes=routes_data, | |
| depot_lat=request.warehouse_lat, | |
| depot_lng=request.warehouse_lng, | |
| speed_kmh=request.speed_kmh, | |
| ) | |
| # Aggregate metrics | |
| total_before_km = sum(c.before["distance_km"] for c in comparisons) | |
| total_after_km = sum(c.after["distance_km"] for c in comparisons) | |
| total_saved_km = total_before_km - total_after_km | |
| total_saved_pct = (total_saved_km / total_before_km * 100) if total_before_km > 0 else 0 | |
| total_before_min = sum(c.before["time_minutes"] for c in comparisons) | |
| total_after_min = sum(c.after["time_minutes"] for c in comparisons) | |
| return { | |
| "success": True, | |
| "routes": [ | |
| { | |
| "route_id": c.route_id, | |
| "before": c.before, | |
| "after": c.after, | |
| "improvement": c.improvement, | |
| } | |
| for c in comparisons | |
| ], | |
| "summary": { | |
| "total_routes": len(comparisons), | |
| "total_distance_before_km": round(total_before_km, 2), | |
| "total_distance_after_km": round(total_after_km, 2), | |
| "total_distance_saved_km": round(total_saved_km, 2), | |
| "total_distance_saved_pct": round(total_saved_pct, 1), | |
| "total_time_before_min": round(total_before_min, 1), | |
| "total_time_after_min": round(total_after_min, 1), | |
| "total_time_saved_min": round(total_before_min - total_after_min, 1), | |
| "total_co2_saved_kg": round(total_saved_km * 0.21, 2), | |
| "optimization_methods": list(set(c.after["method"] for c in comparisons)), | |
| }, | |
| } | |
| async def cluster_packages_endpoint(request: ClusterRequest): | |
| """ | |
| Cluster packages using either DBSCAN (auto-discovers K) or KMeans. | |
| DBSCAN advantages: | |
| - Discovers cluster count automatically | |
| - Handles arbitrary cluster shapes | |
| - Noise points merged into nearest cluster (not discarded) | |
| """ | |
| if not request.packages: | |
| raise HTTPException(400, "packages list required") | |
| if request.method == "dbscan": | |
| from app.services.route_optimization_engine import cluster_packages_dbscan | |
| clusters = cluster_packages_dbscan( | |
| packages=request.packages, | |
| eps_km=request.eps_km, | |
| min_samples=request.min_samples, | |
| ) | |
| return { | |
| "success": True, | |
| "method": "dbscan", | |
| "num_clusters": len(clusters), | |
| "clusters": [ | |
| { | |
| "cluster_id": i, | |
| "num_packages": len(c), | |
| "total_weight_kg": sum(p.get("weight_kg", 0) for p in c), | |
| "packages": c, | |
| } | |
| for i, c in enumerate(clusters) | |
| ], | |
| "params": {"eps_km": request.eps_km, "min_samples": request.min_samples}, | |
| } | |
| else: | |
| # KMeans | |
| from app.services.clustering import cluster_packages | |
| num_drivers = request.num_drivers or max(2, len(request.packages) // 10) | |
| results = cluster_packages(request.packages, num_drivers) | |
| return { | |
| "success": True, | |
| "method": "kmeans", | |
| "num_clusters": len(results), | |
| "clusters": [ | |
| { | |
| "cluster_id": r.cluster_id, | |
| "num_packages": r.num_packages, | |
| "total_weight_kg": r.total_weight_kg, | |
| "num_stops": r.num_stops, | |
| "centroid": r.centroid, | |
| "packages": r.packages, | |
| } | |
| for r in results | |
| ], | |
| "params": {"num_drivers": num_drivers}, | |
| } | |
| async def dynamic_insert( | |
| route_stops: List[StopInput], | |
| new_stop: StopInput, | |
| warehouse_lat: float = 19.076, | |
| warehouse_lng: float = 72.877, | |
| ): | |
| """ | |
| Dynamically insert a new delivery stop into an existing optimized route. | |
| Uses cheapest-insertion heuristic to find optimal position. | |
| """ | |
| from app.services.route_optimization_engine import Stop, cheapest_insertion, optimize_route | |
| stops = [ | |
| Stop(id=s.id, lat=s.latitude, lng=s.longitude, address=s.address, | |
| weight_kg=s.weight_kg, service_time_min=s.service_time_min) | |
| for s in route_stops | |
| ] | |
| new = Stop(id=new_stop.id, lat=new_stop.latitude, lng=new_stop.longitude, | |
| address=new_stop.address, weight_kg=new_stop.weight_kg, | |
| service_time_min=new_stop.service_time_min) | |
| # Add new stop to list | |
| all_stops = stops + [new] | |
| new_idx = len(stops) | |
| # Current order | |
| current_order = list(range(len(stops))) | |
| # Insert at cheapest position | |
| new_order = cheapest_insertion(current_order, new_idx, all_stops, warehouse_lat, warehouse_lng) | |
| # Compute distances | |
| from app.services.route_optimization_engine import _compute_route_distance | |
| before_dist = _compute_route_distance(all_stops, current_order, warehouse_lat, warehouse_lng) | |
| after_dist = _compute_route_distance(all_stops, new_order, warehouse_lat, warehouse_lng) | |
| return { | |
| "success": True, | |
| "new_order": [all_stops[i].id for i in new_order], | |
| "insertion_position": new_order.index(new_idx), | |
| "distance_before_km": round(before_dist, 2), | |
| "distance_after_km": round(after_dist, 2), | |
| "additional_distance_km": round(after_dist - before_dist, 2), | |
| } | |