MouleeswaranM commited on
Commit
523883f
·
1 Parent(s): e069e42

feat: Add /api/v1/routes/optimize endpoint for before/after comparison + integrate TSP into clustering (#30)

Browse files

- feat: Add /api/v1/routes/optimize endpoint for before/after comparison + integrate TSP into clustering (4027144108bdb5ce12cbfa747153c3b3725fc434)

Files changed (1) hide show
  1. brain/app/api/route_optimization.py +214 -0
brain/app/api/route_optimization.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Route Optimization API — Exposes VRP/TSP and before/after comparison.
3
+ Addresses Challenge #4: "Comparison between current vs optimized routes"
4
+ """
5
+
6
+ from typing import List, Dict, Any, Optional
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel
9
+
10
+
11
+ router = APIRouter(prefix="/routes", tags=["Route Optimization"])
12
+
13
+
14
+ class StopInput(BaseModel):
15
+ id: str
16
+ latitude: float
17
+ longitude: float
18
+ address: str = ""
19
+ weight_kg: float = 0.0
20
+ service_time_min: float = 5.0
21
+ time_window_start: Optional[int] = None
22
+ time_window_end: Optional[int] = None
23
+ priority: str = "normal"
24
+
25
+
26
+ class RouteInput(BaseModel):
27
+ id: str = "route_1"
28
+ stops: List[StopInput]
29
+
30
+
31
+ class OptimizeRequest(BaseModel):
32
+ routes: List[RouteInput]
33
+ warehouse_lat: float = 19.076
34
+ warehouse_lng: float = 72.877
35
+ speed_kmh: float = 30.0
36
+ use_time_windows: bool = True
37
+
38
+
39
+ class ClusterRequest(BaseModel):
40
+ packages: List[Dict[str, Any]]
41
+ method: str = "dbscan" # "dbscan" or "kmeans"
42
+ num_drivers: Optional[int] = None
43
+ eps_km: float = 5.0
44
+ min_samples: int = 2
45
+
46
+
47
+ @router.post("/optimize", summary="Optimize stop order within routes (TSP/VRP)")
48
+ async def optimize_routes(request: OptimizeRequest):
49
+ """
50
+ Optimize multi-stop delivery routes using OR-Tools VRP + 2-opt.
51
+
52
+ Returns before/after comparison with distance savings, time savings, and CO₂ reduction.
53
+ This directly addresses Challenge #4: "Comparison between current vs optimized routes."
54
+ """
55
+ from app.services.route_optimization_engine import compare_routes
56
+
57
+ routes_data = []
58
+ for route in request.routes:
59
+ routes_data.append({
60
+ "id": route.id,
61
+ "stops": [s.model_dump() for s in route.stops],
62
+ })
63
+
64
+ comparisons = compare_routes(
65
+ routes=routes_data,
66
+ depot_lat=request.warehouse_lat,
67
+ depot_lng=request.warehouse_lng,
68
+ speed_kmh=request.speed_kmh,
69
+ )
70
+
71
+ # Aggregate metrics
72
+ total_before_km = sum(c.before["distance_km"] for c in comparisons)
73
+ total_after_km = sum(c.after["distance_km"] for c in comparisons)
74
+ total_saved_km = total_before_km - total_after_km
75
+ total_saved_pct = (total_saved_km / total_before_km * 100) if total_before_km > 0 else 0
76
+
77
+ total_before_min = sum(c.before["time_minutes"] for c in comparisons)
78
+ total_after_min = sum(c.after["time_minutes"] for c in comparisons)
79
+
80
+ return {
81
+ "success": True,
82
+ "routes": [
83
+ {
84
+ "route_id": c.route_id,
85
+ "before": c.before,
86
+ "after": c.after,
87
+ "improvement": c.improvement,
88
+ }
89
+ for c in comparisons
90
+ ],
91
+ "summary": {
92
+ "total_routes": len(comparisons),
93
+ "total_distance_before_km": round(total_before_km, 2),
94
+ "total_distance_after_km": round(total_after_km, 2),
95
+ "total_distance_saved_km": round(total_saved_km, 2),
96
+ "total_distance_saved_pct": round(total_saved_pct, 1),
97
+ "total_time_before_min": round(total_before_min, 1),
98
+ "total_time_after_min": round(total_after_min, 1),
99
+ "total_time_saved_min": round(total_before_min - total_after_min, 1),
100
+ "total_co2_saved_kg": round(total_saved_km * 0.21, 2),
101
+ "optimization_methods": list(set(c.after["method"] for c in comparisons)),
102
+ },
103
+ }
104
+
105
+
106
+ @router.post("/cluster", summary="Cluster packages using DBSCAN or KMeans")
107
+ async def cluster_packages_endpoint(request: ClusterRequest):
108
+ """
109
+ Cluster packages using either DBSCAN (auto-discovers K) or KMeans.
110
+
111
+ DBSCAN advantages:
112
+ - Discovers cluster count automatically
113
+ - Handles arbitrary cluster shapes
114
+ - Noise points merged into nearest cluster (not discarded)
115
+ """
116
+ if not request.packages:
117
+ raise HTTPException(400, "packages list required")
118
+
119
+ if request.method == "dbscan":
120
+ from app.services.route_optimization_engine import cluster_packages_dbscan
121
+
122
+ clusters = cluster_packages_dbscan(
123
+ packages=request.packages,
124
+ eps_km=request.eps_km,
125
+ min_samples=request.min_samples,
126
+ )
127
+
128
+ return {
129
+ "success": True,
130
+ "method": "dbscan",
131
+ "num_clusters": len(clusters),
132
+ "clusters": [
133
+ {
134
+ "cluster_id": i,
135
+ "num_packages": len(c),
136
+ "total_weight_kg": sum(p.get("weight_kg", 0) for p in c),
137
+ "packages": c,
138
+ }
139
+ for i, c in enumerate(clusters)
140
+ ],
141
+ "params": {"eps_km": request.eps_km, "min_samples": request.min_samples},
142
+ }
143
+ else:
144
+ # KMeans
145
+ from app.services.clustering import cluster_packages
146
+
147
+ num_drivers = request.num_drivers or max(2, len(request.packages) // 10)
148
+ results = cluster_packages(request.packages, num_drivers)
149
+
150
+ return {
151
+ "success": True,
152
+ "method": "kmeans",
153
+ "num_clusters": len(results),
154
+ "clusters": [
155
+ {
156
+ "cluster_id": r.cluster_id,
157
+ "num_packages": r.num_packages,
158
+ "total_weight_kg": r.total_weight_kg,
159
+ "num_stops": r.num_stops,
160
+ "centroid": r.centroid,
161
+ "packages": r.packages,
162
+ }
163
+ for r in results
164
+ ],
165
+ "params": {"num_drivers": num_drivers},
166
+ }
167
+
168
+
169
+ @router.post("/dynamic-insert", summary="Insert new stop into existing route (cheapest insertion)")
170
+ async def dynamic_insert(
171
+ route_stops: List[StopInput],
172
+ new_stop: StopInput,
173
+ warehouse_lat: float = 19.076,
174
+ warehouse_lng: float = 72.877,
175
+ ):
176
+ """
177
+ Dynamically insert a new delivery stop into an existing optimized route.
178
+ Uses cheapest-insertion heuristic to find optimal position.
179
+ """
180
+ from app.services.route_optimization_engine import Stop, cheapest_insertion, optimize_route
181
+
182
+ stops = [
183
+ Stop(id=s.id, lat=s.latitude, lng=s.longitude, address=s.address,
184
+ weight_kg=s.weight_kg, service_time_min=s.service_time_min)
185
+ for s in route_stops
186
+ ]
187
+
188
+ new = Stop(id=new_stop.id, lat=new_stop.latitude, lng=new_stop.longitude,
189
+ address=new_stop.address, weight_kg=new_stop.weight_kg,
190
+ service_time_min=new_stop.service_time_min)
191
+
192
+ # Add new stop to list
193
+ all_stops = stops + [new]
194
+ new_idx = len(stops)
195
+
196
+ # Current order
197
+ current_order = list(range(len(stops)))
198
+
199
+ # Insert at cheapest position
200
+ new_order = cheapest_insertion(current_order, new_idx, all_stops, warehouse_lat, warehouse_lng)
201
+
202
+ # Compute distances
203
+ from app.services.route_optimization_engine import _compute_route_distance
204
+ before_dist = _compute_route_distance(all_stops, current_order, warehouse_lat, warehouse_lng)
205
+ after_dist = _compute_route_distance(all_stops, new_order, warehouse_lat, warehouse_lng)
206
+
207
+ return {
208
+ "success": True,
209
+ "new_order": [all_stops[i].id for i in new_order],
210
+ "insertion_position": new_order.index(new_idx),
211
+ "distance_before_km": round(before_dist, 2),
212
+ "distance_after_km": round(after_dist, 2),
213
+ "additional_distance_km": round(after_dist - before_dist, 2),
214
+ }