fix: Add vehicle capacity constraint, priority-aware routing, traffic integration, cost model to VRP solver

#35
brain/app/services/route_optimization_engine.py CHANGED
@@ -1,16 +1,19 @@
1
  """
2
- Route Optimization Engine β€” Full VRP/TSP Implementation
3
- ========================================================
4
 
5
  Addresses Challenge #4: "Suboptimal route selection increasing total travel distance"
6
 
7
  Features:
8
  1. Multi-stop TSP within routes (OR-Tools Routing + 2-opt local search)
9
  2. Time window constraints per stop (AddDimension + SetRange)
10
- 3. Before/after route comparison with distance savings
11
- 4. DBSCAN clustering option (discovers K automatically, handles arbitrary shapes)
12
- 5. Dynamic re-routing via cheapest-insertion heuristic
13
- 6. Traffic-aware effective speed (via traffic_integration)
 
 
 
14
 
15
  This module is called AFTER clustering assigns packages to routes,
16
  to optimize the STOP ORDER within each route.
@@ -25,22 +28,53 @@ from dataclasses import dataclass, field
25
  logger = logging.getLogger("fairrelay.route_optimizer")
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  # ═══════════════════════════════════════════════════════════════
29
  # DATA STRUCTURES
30
  # ═══════════════════════════════════════════════════════════════
31
 
32
  @dataclass
33
  class Stop:
34
- """A delivery stop with coordinates and optional time window."""
35
  id: str
36
  lat: float
37
  lng: float
38
  address: str = ""
39
  weight_kg: float = 0.0
40
- service_time_min: float = 5.0 # Time spent at stop
 
41
  time_window_start: Optional[int] = None # Minutes from route start
42
- time_window_end: Optional[int] = None # Minutes from route start
43
- priority: str = "normal"
 
 
 
 
 
 
 
 
 
 
 
44
 
45
 
46
  @dataclass
@@ -49,13 +83,17 @@ class RouteOptResult:
49
  ordered_stops: List[Stop]
50
  total_distance_km: float
51
  total_time_minutes: float
52
- naive_distance_km: float # Before optimization
53
- distance_saved_km: float # Improvement
54
- distance_saved_pct: float # % improvement
55
- optimization_method: str # "or_tools_vrp" | "2_opt" | "nearest_neighbor"
 
 
56
  time_windows_respected: bool
 
 
57
  num_stops: int
58
- polyline_points: List[Tuple[float, float]] # Ordered (lat, lng) for map
59
 
60
 
61
  @dataclass
@@ -68,7 +106,7 @@ class RouteComparison:
68
 
69
 
70
  # ════���══════════════════════════════════════════════════════════
71
- # CORE: HAVERSINE DISTANCE
72
  # ═══════════════════════════════════════════════════════════════
73
 
74
  def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
@@ -80,6 +118,35 @@ def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
80
  return R * 2 * math.asin(math.sqrt(a))
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def build_distance_matrix(stops: List[Stop], depot_lat: float, depot_lng: float) -> List[List[int]]:
84
  """Build distance matrix (in meters) with depot at index 0."""
85
  all_points = [(depot_lat, depot_lng)] + [(s.lat, s.lng) for s in stops]
@@ -88,81 +155,148 @@ def build_distance_matrix(stops: List[Stop], depot_lat: float, depot_lng: float)
88
  for i in range(n):
89
  for j in range(n):
90
  if i != j:
91
- d = haversine_km(all_points[i][0], all_points[i][1], all_points[j][0], all_points[j][1])
92
- matrix[i][j] = int(d * 1000) # Convert to meters for OR-Tools
93
  return matrix
94
 
95
 
96
  # ═══════════════════════════════════════════════════════════════
97
- # METHOD 1: OR-TOOLS VRP WITH TIME WINDOWS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  # ═══════════════════════════════════════════════════════════════
99
 
100
  def solve_vrp_ortools(
101
  stops: List[Stop],
102
  depot_lat: float,
103
  depot_lng: float,
 
104
  speed_kmh: float = 30.0,
105
  max_time_seconds: int = 5,
106
  ) -> Optional[List[int]]:
107
  """
108
- Solve TSP/VRP using OR-Tools Routing Library with time windows.
109
-
110
- Returns ordered indices into stops list, or None if solver fails.
 
 
111
  """
112
  try:
113
  from ortools.constraint_solver import routing_enums_pb2, pywrapcp
114
  except ImportError:
115
  return None
116
-
 
117
  n = len(stops) + 1 # +1 for depot
118
  if n <= 2:
119
  return list(range(len(stops)))
120
-
121
- # Build distance matrix
122
  dist_matrix = build_distance_matrix(stops, depot_lat, depot_lng)
123
-
124
- # Create routing index manager (1 vehicle, depot at node 0)
125
  manager = pywrapcp.RoutingIndexManager(n, 1, 0)
126
  routing = pywrapcp.RoutingModel(manager)
127
-
128
- # Distance callback
129
  def distance_callback(from_index, to_index):
130
  from_node = manager.IndexToNode(from_index)
131
  to_node = manager.IndexToNode(to_index)
132
- return dist_matrix[from_node][to_node]
133
-
 
 
 
 
 
 
 
 
 
 
 
 
134
  transit_callback_index = routing.RegisterTransitCallback(distance_callback)
135
  routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
136
-
137
- # Time dimension (for time windows)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  has_time_windows = any(s.time_window_start is not None for s in stops)
139
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  if has_time_windows:
141
- def time_callback(from_index, to_index):
142
- from_node = manager.IndexToNode(from_index)
143
- to_node = manager.IndexToNode(to_index)
144
- # Travel time in minutes
145
- dist_km = dist_matrix[from_node][to_node] / 1000
146
- travel_min = (dist_km / speed_kmh) * 60
147
- # Add service time at destination
148
- if to_node > 0:
149
- travel_min += stops[to_node - 1].service_time_min
150
- return int(travel_min)
151
-
152
- time_callback_index = routing.RegisterTransitCallback(time_callback)
153
-
154
- # Add time dimension
155
- max_route_time = 720 # 12 hours max
156
- routing.AddDimension(
157
- time_callback_index,
158
- 30, # Slack (waiting time allowed)
159
- max_route_time, # Max cumulative time
160
- False, # Don't force start at 0
161
- 'Time'
162
- )
163
- time_dimension = routing.GetDimensionOrDie('Time')
164
-
165
- # Set time windows
166
  for i, stop in enumerate(stops):
167
  node_index = manager.NodeToIndex(i + 1)
168
  if stop.time_window_start is not None and stop.time_window_end is not None:
@@ -170,34 +304,32 @@ def solve_vrp_ortools(
170
  int(stop.time_window_start),
171
  int(stop.time_window_end)
172
  )
173
-
174
- # Depot time window (start at 0, end at max)
175
- time_dimension.CumulVar(routing.Start(0)).SetRange(0, max_route_time)
176
-
177
- # Solve with time limit
178
  search_params = pywrapcp.DefaultRoutingSearchParameters()
179
  search_params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
180
  search_params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
181
  search_params.time_limit.FromSeconds(max_time_seconds)
182
-
183
  solution = routing.SolveWithParameters(search_params)
184
-
185
  if solution:
186
- # Extract route order
187
  order = []
188
  index = routing.Start(0)
189
  while not routing.IsEnd(index):
190
  node = manager.IndexToNode(index)
191
- if node > 0: # Skip depot
192
- order.append(node - 1) # Map back to stops index
193
  index = solution.Value(routing.NextVar(index))
194
  return order
195
-
196
  return None
197
 
198
 
199
  # ═══════════════════════════════════════════════════════════════
200
- # METHOD 2: 2-OPT LOCAL SEARCH IMPROVEMENT
201
  # ═══════════════════════════════════════════════════════════════
202
 
203
  def two_opt_improve(
@@ -208,67 +340,78 @@ def two_opt_improve(
208
  max_iterations: int = 1000,
209
  ) -> List[int]:
210
  """
211
- Apply 2-opt improvement to a route order.
212
-
213
- 2-opt reverses segments of the route to reduce total distance.
214
- Guaranteed to converge to a local optimum.
215
  """
216
- def route_distance(order: List[int]) -> float:
217
- """Total route distance including depot→first and last→depot."""
218
  if not order:
219
  return 0.0
220
- # Depot to first stop
221
- total = haversine_km(depot_lat, depot_lng, stops[order[0]].lat, stops[order[0]].lng)
222
- # Between stops
223
  for i in range(len(order) - 1):
224
- total += haversine_km(stops[order[i]].lat, stops[order[i]].lng, stops[order[i+1]].lat, stops[order[i+1]].lng)
225
- # Last stop back to depot
226
- total += haversine_km(stops[order[-1]].lat, stops[order[-1]].lng, depot_lat, depot_lng)
 
 
 
 
 
 
 
 
227
  return total
228
-
229
  best_order = list(initial_order)
230
- best_dist = route_distance(best_order)
231
  improved = True
232
  iterations = 0
233
-
234
  while improved and iterations < max_iterations:
235
  improved = False
236
  iterations += 1
237
  for i in range(len(best_order) - 1):
238
  for j in range(i + 1, len(best_order)):
239
- # Reverse segment [i:j+1]
240
  new_order = best_order[:i] + best_order[i:j+1][::-1] + best_order[j+1:]
241
- new_dist = route_distance(new_order)
242
- if new_dist < best_dist - 0.01: # 10m improvement threshold
243
  best_order = new_order
244
- best_dist = new_dist
245
  improved = True
246
  break
247
  if improved:
248
  break
249
-
250
  return best_order
251
 
252
 
253
  # ═══════════════════════════════════════════════════════════════
254
- # METHOD 3: NEAREST NEIGHBOR (BASELINE)
255
  # ═══════════════════════════════════════════════════════════════
256
 
257
  def nearest_neighbor_order(stops: List[Stop], depot_lat: float, depot_lng: float) -> List[int]:
258
- """Simple nearest-neighbor heuristic as baseline."""
 
 
 
259
  if not stops:
260
  return []
261
-
262
  remaining = list(range(len(stops)))
263
  order = []
264
  curr_lat, curr_lng = depot_lat, depot_lng
265
-
266
  while remaining:
267
- nearest_idx = min(remaining, key=lambda i: haversine_km(curr_lat, curr_lng, stops[i].lat, stops[i].lng))
268
- order.append(nearest_idx)
269
- remaining.remove(nearest_idx)
270
- curr_lat, curr_lng = stops[nearest_idx].lat, stops[nearest_idx].lng
271
-
 
 
 
 
 
 
272
  return order
273
 
274
 
@@ -283,136 +426,150 @@ def cheapest_insertion(
283
  depot_lat: float,
284
  depot_lng: float,
285
  ) -> List[int]:
286
- """
287
- Insert a new stop into an existing route at the cheapest position.
288
- Used for dynamic re-routing when new packages arrive mid-route.
289
- """
290
  if not existing_order:
291
  return [new_stop_idx]
292
-
293
- def segment_cost(from_lat, from_lng, to_lat, to_lng):
294
- return haversine_km(from_lat, from_lng, to_lat, to_lng)
295
-
296
  new_stop = stops[new_stop_idx]
297
  best_position = 0
298
  best_cost_increase = float('inf')
299
-
300
- # Try inserting at each position
 
 
301
  for pos in range(len(existing_order) + 1):
302
  if pos == 0:
303
  prev_lat, prev_lng = depot_lat, depot_lng
304
  else:
305
  prev_stop = stops[existing_order[pos - 1]]
306
  prev_lat, prev_lng = prev_stop.lat, prev_stop.lng
307
-
308
  if pos == len(existing_order):
309
  next_lat, next_lng = depot_lat, depot_lng
310
  else:
311
  next_stop = stops[existing_order[pos]]
312
  next_lat, next_lng = next_stop.lat, next_stop.lng
313
-
314
- # Cost of current segment (prev β†’ next)
315
- current_cost = segment_cost(prev_lat, prev_lng, next_lat, next_lng)
316
- # Cost after insertion (prev β†’ new β†’ next)
317
- new_cost = (segment_cost(prev_lat, prev_lng, new_stop.lat, new_stop.lng) +
318
- segment_cost(new_stop.lat, new_stop.lng, next_lat, next_lng))
319
-
320
  cost_increase = new_cost - current_cost
 
 
 
 
 
321
  if cost_increase < best_cost_increase:
322
  best_cost_increase = cost_increase
323
  best_position = pos
324
-
325
  result = list(existing_order)
326
  result.insert(best_position, new_stop_idx)
327
  return result
328
 
329
 
330
  # ═══════════════════════════════════════════════════════════════
331
- # MAIN OPTIMIZER: COMBINES ALL METHODS
332
  # ═══════════════════════════════════════════════════════════════
333
 
334
  def optimize_route(
335
  stops: List[Stop],
336
  depot_lat: float,
337
  depot_lng: float,
338
- speed_kmh: float = 30.0,
 
339
  use_time_windows: bool = True,
340
  max_solver_time: int = 5,
341
  ) -> RouteOptResult:
342
  """
343
  Full route optimization pipeline:
344
- 1. Try OR-Tools VRP with time windows (best quality)
345
  2. Apply 2-opt local search improvement
346
- 3. Fallback: nearest-neighbor + 2-opt
347
-
348
- Returns optimized route with before/after comparison.
349
  """
 
 
350
  if not stops:
351
  return RouteOptResult(
352
- ordered_stops=[], total_distance_km=0, total_time_minutes=0,
353
- naive_distance_km=0, distance_saved_km=0, distance_saved_pct=0,
354
  optimization_method="empty", time_windows_respected=True,
 
355
  num_stops=0, polyline_points=[],
356
  )
357
-
358
- t0 = time.time()
359
-
360
- # Step 1: Compute naive (input order) distance as baseline
 
 
361
  naive_order = list(range(len(stops)))
362
  naive_dist = _compute_route_distance(stops, naive_order, depot_lat, depot_lng)
363
-
364
- # Step 2: Try OR-Tools VRP
365
  method = "nearest_neighbor"
366
  ortools_order = None
367
-
368
  if len(stops) >= 3:
369
- ortools_order = solve_vrp_ortools(
370
- stops, depot_lat, depot_lng, speed_kmh, max_solver_time
371
- )
372
-
373
  if ortools_order:
374
  method = "or_tools_vrp"
375
  best_order = ortools_order
376
  else:
377
- # Fallback: nearest neighbor
378
  best_order = nearest_neighbor_order(stops, depot_lat, depot_lng)
379
-
380
- # Step 3: Apply 2-opt improvement (always)
381
  if len(stops) >= 4:
382
  improved_order = two_opt_improve(stops, depot_lat, depot_lng, best_order)
383
- improved_dist = _compute_route_distance(stops, improved_order, depot_lat, depot_lng)
384
- current_dist = _compute_route_distance(stops, best_order, depot_lat, depot_lng)
385
-
386
- if improved_dist < current_dist:
387
  best_order = improved_order
388
  method = f"{method}+2opt"
389
-
390
- # Step 4: Compute final metrics
391
  optimized_dist = _compute_route_distance(stops, best_order, depot_lat, depot_lng)
392
  distance_saved = naive_dist - optimized_dist
393
  saved_pct = (distance_saved / naive_dist * 100) if naive_dist > 0 else 0
394
-
395
- # Compute total time (distance/speed + service times)
396
- total_time = (optimized_dist / speed_kmh) * 60 + sum(stops[i].service_time_min for i in best_order)
397
-
398
- # Check time windows respected
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  tw_respected = _check_time_windows(stops, best_order, depot_lat, depot_lng, speed_kmh)
400
-
401
- # Build polyline
402
  polyline = [(depot_lat, depot_lng)] + [(stops[i].lat, stops[i].lng) for i in best_order] + [(depot_lat, depot_lng)]
403
-
404
- # Order stops
405
  ordered_stops = [stops[i] for i in best_order]
406
-
407
  return RouteOptResult(
408
  ordered_stops=ordered_stops,
409
  total_distance_km=round(optimized_dist, 2),
410
- total_time_minutes=round(total_time, 1),
 
411
  naive_distance_km=round(naive_dist, 2),
412
  distance_saved_km=round(max(0, distance_saved), 2),
413
  distance_saved_pct=round(max(0, saved_pct), 1),
 
414
  optimization_method=method,
415
  time_windows_respected=tw_respected,
 
 
416
  num_stops=len(stops),
417
  polyline_points=polyline,
418
  )
@@ -422,26 +579,16 @@ def compare_routes(
422
  routes: List[Dict[str, Any]],
423
  depot_lat: float,
424
  depot_lng: float,
425
- speed_kmh: float = 30.0,
 
426
  ) -> List[RouteComparison]:
427
- """
428
- Generate before/after route comparison for Challenge #4.
429
-
430
- Args:
431
- routes: List of route dicts with "stops" (list of stop dicts)
432
- depot_lat, depot_lng: Warehouse coordinates
433
- speed_kmh: Average speed
434
-
435
- Returns:
436
- List of RouteComparison objects showing improvement per route
437
- """
438
  comparisons = []
439
-
440
  for route in routes:
441
  route_id = route.get("id", f"route_{len(comparisons)}")
442
  raw_stops = route.get("stops", route.get("packages", []))
443
-
444
- # Convert to Stop objects
445
  stops = [
446
  Stop(
447
  id=s.get("id", f"stop_{i}"),
@@ -449,49 +596,63 @@ def compare_routes(
449
  lng=s.get("longitude", s.get("lng", 0)),
450
  address=s.get("address", ""),
451
  weight_kg=s.get("weight_kg", 0),
 
452
  service_time_min=s.get("service_time_min", 5),
453
  time_window_start=s.get("time_window_start"),
454
  time_window_end=s.get("time_window_end"),
455
  priority=s.get("priority", "normal"),
 
456
  )
457
  for i, s in enumerate(raw_stops)
458
  ]
459
-
460
  if not stops:
461
  continue
462
-
463
- # Before: naive order (as received)
 
 
 
464
  naive_dist = _compute_route_distance(stops, list(range(len(stops))), depot_lat, depot_lng)
465
- naive_time = (naive_dist / speed_kmh) * 60 + sum(s.service_time_min for s in stops)
466
-
467
- # After: optimized
468
- result = optimize_route(stops, depot_lat, depot_lng, speed_kmh)
469
-
 
 
470
  comparisons.append(RouteComparison(
471
  route_id=route_id,
472
  before={
473
  "distance_km": round(naive_dist, 2),
474
- "time_minutes": round(naive_time, 1),
 
 
 
475
  "stop_order": [s.id for s in stops],
476
- "co2_kg": round(naive_dist * 0.21, 2),
477
  },
478
  after={
479
  "distance_km": result.total_distance_km,
480
  "time_minutes": result.total_time_minutes,
 
 
 
481
  "stop_order": [s.id for s in result.ordered_stops],
482
- "co2_kg": round(result.total_distance_km * 0.21, 2),
483
  "method": result.optimization_method,
 
484
  "polyline": result.polyline_points,
485
  },
486
  improvement={
487
  "distance_saved_km": result.distance_saved_km,
488
  "distance_saved_pct": result.distance_saved_pct,
489
- "time_saved_minutes": round(naive_time - result.total_time_minutes, 1),
490
- "co2_saved_kg": round((naive_dist - result.total_distance_km) * 0.21, 2),
 
 
491
  "time_windows_respected": result.time_windows_respected,
492
  },
493
  ))
494
-
495
  return comparisons
496
 
497
 
@@ -506,97 +667,78 @@ def cluster_packages_dbscan(
506
  max_cluster_size: int = 30,
507
  ) -> List[List[Dict[str, Any]]]:
508
  """
509
- DBSCAN-based clustering that discovers K automatically.
510
- Handles arbitrary cluster shapes (unlike KMeans).
511
-
512
- Fixes: Noise points (-1 label) are merged into nearest cluster.
513
-
514
- Args:
515
- packages: List of package dicts with latitude, longitude
516
- eps_km: Max distance between points in same cluster (km)
517
- min_samples: Min points to form a cluster
518
- max_cluster_size: Split clusters exceeding this
519
-
520
- Returns:
521
- List of clusters (each cluster is a list of package dicts)
522
  """
523
  import numpy as np
524
  from sklearn.cluster import DBSCAN
525
- from sklearn.metrics.pairwise import haversine_distances
526
-
527
  if not packages:
528
  return []
529
-
530
  if len(packages) <= min_samples:
531
  return [packages]
532
-
533
- # Convert to radians for haversine
 
 
 
 
 
 
534
  coords_rad = np.array([
535
  [math.radians(p["latitude"]), math.radians(p["longitude"])]
536
- for p in packages
537
  ])
538
-
539
- # eps in radians (eps_km / Earth radius)
540
  eps_rad = eps_km / 6371.0
541
-
542
- # Run DBSCAN
543
  db = DBSCAN(eps=eps_rad, min_samples=min_samples, metric='haversine')
544
  labels = db.fit_predict(coords_rad)
545
-
546
- # Group by cluster
547
  clusters: Dict[int, List[int]] = {}
548
  noise_indices: List[int] = []
549
-
550
  for idx, label in enumerate(labels):
551
  if label == -1:
552
  noise_indices.append(idx)
553
  else:
554
- if label not in clusters:
555
- clusters[label] = []
556
- clusters[label].append(idx)
557
-
558
- # FIX: Merge noise points into nearest cluster
559
  if noise_indices and clusters:
560
- # Compute cluster centroids
561
- cluster_centroids = {}
562
  for label, indices in clusters.items():
563
- lats = [packages[i]["latitude"] for i in indices]
564
- lngs = [packages[i]["longitude"] for i in indices]
565
- cluster_centroids[label] = (sum(lats)/len(lats), sum(lngs)/len(lngs))
566
-
567
- for noise_idx in noise_indices:
568
- pkg = packages[noise_idx]
569
- # Find nearest cluster
570
- nearest_label = min(
571
- cluster_centroids.keys(),
572
- key=lambda l: haversine_km(
573
- pkg["latitude"], pkg["longitude"],
574
- cluster_centroids[l][0], cluster_centroids[l][1]
575
- )
576
- )
577
- clusters[nearest_label].append(noise_idx)
578
- elif noise_indices and not clusters:
579
- # All points are noise β€” treat as one cluster
580
  clusters[0] = noise_indices
581
-
582
- # Split oversized clusters
583
- final_clusters = []
584
  for label, indices in clusters.items():
585
  if len(indices) <= max_cluster_size:
586
- final_clusters.append([packages[i] for i in indices])
587
  else:
588
- # Split using KMeans
589
  from sklearn.cluster import KMeans
590
- sub_coords = np.array([[packages[i]["latitude"], packages[i]["longitude"]] for i in indices])
591
  n_sub = max(2, len(indices) // max_cluster_size + 1)
592
  km = KMeans(n_clusters=n_sub, random_state=42, n_init=5)
593
  sub_labels = km.fit_predict(sub_coords)
594
  for sl in range(n_sub):
595
- sub_indices = [indices[j] for j in range(len(indices)) if sub_labels[j] == sl]
596
- if sub_indices:
597
- final_clusters.append([packages[i] for i in sub_indices])
598
-
599
- return final_clusters
 
 
 
 
600
 
601
 
602
  # ═══════════════════════════════════════════════════════════════
@@ -604,41 +746,36 @@ def cluster_packages_dbscan(
604
  # ═══════════════════════════════════════════════════════════════
605
 
606
  def _compute_route_distance(stops: List[Stop], order: List[int], depot_lat: float, depot_lng: float) -> float:
607
- """Compute total route distance for a given stop order."""
608
  if not order:
609
  return 0.0
610
- total = haversine_km(depot_lat, depot_lng, stops[order[0]].lat, stops[order[0]].lng)
611
  for i in range(len(order) - 1):
612
- total += haversine_km(stops[order[i]].lat, stops[order[i]].lng, stops[order[i+1]].lat, stops[order[i+1]].lng)
613
- total += haversine_km(stops[order[-1]].lat, stops[order[-1]].lng, depot_lat, depot_lng)
614
  return total
615
 
616
 
617
  def _check_time_windows(stops: List[Stop], order: List[int], depot_lat: float, depot_lng: float, speed_kmh: float) -> bool:
618
- """Check if all time windows are respected in the given order."""
619
  if not order:
620
  return True
621
-
622
- current_time = 0.0 # Minutes from departure
623
  current_lat, current_lng = depot_lat, depot_lng
624
-
625
  for idx in order:
626
  stop = stops[idx]
627
- # Travel time to this stop
628
- dist = haversine_km(current_lat, current_lng, stop.lat, stop.lng)
629
- travel_time = (dist / speed_kmh) * 60
630
  current_time += travel_time
631
-
632
- # Check time window
633
  if stop.time_window_end is not None and current_time > stop.time_window_end:
634
  return False
635
-
636
- # Wait if arrived too early
637
  if stop.time_window_start is not None and current_time < stop.time_window_start:
638
  current_time = stop.time_window_start
639
-
640
- # Service time
641
  current_time += stop.service_time_min
642
  current_lat, current_lng = stop.lat, stop.lng
643
-
644
  return True
 
1
  """
2
+ Route Optimization Engine β€” Full VRP/TSP Implementation (v2)
3
+ =============================================================
4
 
5
  Addresses Challenge #4: "Suboptimal route selection increasing total travel distance"
6
 
7
  Features:
8
  1. Multi-stop TSP within routes (OR-Tools Routing + 2-opt local search)
9
  2. Time window constraints per stop (AddDimension + SetRange)
10
+ 3. Vehicle capacity constraint (AddDimension weight enforcement)
11
+ 4. Priority-aware routing (HIGH/EXPRESS stops penalized if placed late)
12
+ 5. Traffic-aware speed via OLA Maps integration
13
+ 6. Cost model (distance Γ— fuel cost + toll + time-based labor)
14
+ 7. Before/after route comparison with distance/time/COβ‚‚/cost savings
15
+ 8. DBSCAN clustering option (discovers K automatically, handles arbitrary shapes)
16
+ 9. Dynamic re-routing via cheapest-insertion heuristic
17
 
18
  This module is called AFTER clustering assigns packages to routes,
19
  to optimize the STOP ORDER within each route.
 
28
  logger = logging.getLogger("fairrelay.route_optimizer")
29
 
30
 
31
+ # ═══════════════════════════════════════════════════════════════
32
+ # COST MODEL CONSTANTS (Indian logistics, configurable)
33
+ # ═══════════════════════════════════════════════════════════════
34
+
35
+ FUEL_COST_PER_KM = 8.5 # β‚Ή/km (diesel truck avg India 2026)
36
+ TOLL_COST_PER_KM = 1.2 # β‚Ή/km (avg toll on state highways)
37
+ DRIVER_LABOR_PER_HOUR = 125.0 # β‚Ή/hour (avg driver wage)
38
+ CO2_KG_PER_KM = 0.21 # kg COβ‚‚ per km (diesel)
39
+ ROAD_FACTOR = 1.35 # Haversine to road distance multiplier (Indian roads)
40
+
41
+ # Priority penalty multiplier for late delivery of high-priority items
42
+ PRIORITY_PENALTY = {
43
+ "express": 3.0, # 3x cost penalty if EXPRESS is placed late in route
44
+ "high": 2.0, # 2x cost penalty if HIGH is placed late
45
+ "normal": 1.0, # No penalty
46
+ "low": 0.8, # Slight discount β€” can be delivered last
47
+ }
48
+
49
+
50
  # ═══════════════════════════════════════════════════════════════
51
  # DATA STRUCTURES
52
  # ═══════════════════════════════════════════════════════════════
53
 
54
  @dataclass
55
  class Stop:
56
+ """A delivery stop with coordinates, capacity, time window, and priority."""
57
  id: str
58
  lat: float
59
  lng: float
60
  address: str = ""
61
  weight_kg: float = 0.0
62
+ volume_m3: float = 0.0
63
+ service_time_min: float = 5.0
64
  time_window_start: Optional[int] = None # Minutes from route start
65
+ time_window_end: Optional[int] = None
66
+ priority: str = "normal" # "express" | "high" | "normal" | "low"
67
+ is_hazmat: bool = False # Hazardous material flag
68
+
69
+
70
+ @dataclass
71
+ class VehicleConfig:
72
+ """Vehicle capacity and cost configuration."""
73
+ max_weight_kg: float = 1000.0
74
+ max_volume_m3: float = 8.0
75
+ fuel_cost_per_km: float = FUEL_COST_PER_KM
76
+ co2_per_km: float = CO2_KG_PER_KM
77
+ vehicle_type: str = "diesel" # "diesel" | "ev" | "cng"
78
 
79
 
80
  @dataclass
 
83
  ordered_stops: List[Stop]
84
  total_distance_km: float
85
  total_time_minutes: float
86
+ total_cost_inr: float # NEW: Total route cost in β‚Ή
87
+ naive_distance_km: float
88
+ distance_saved_km: float
89
+ distance_saved_pct: float
90
+ cost_saved_inr: float # NEW: Cost savings
91
+ optimization_method: str
92
  time_windows_respected: bool
93
+ capacity_respected: bool # NEW: Vehicle capacity check
94
+ priority_score: float # NEW: 0-100 (how well priorities honored)
95
  num_stops: int
96
+ polyline_points: List[Tuple[float, float]]
97
 
98
 
99
  @dataclass
 
106
 
107
 
108
  # ════���══════════════════════════════════════════════════════════
109
+ # CORE: DISTANCE + COST
110
  # ═══════════════════════════════════════════════════════════════
111
 
112
  def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
 
118
  return R * 2 * math.asin(math.sqrt(a))
119
 
120
 
121
+ def road_distance_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
122
+ """Estimated road distance (Haversine Γ— road factor for India)."""
123
+ return haversine_km(lat1, lng1, lat2, lng2) * ROAD_FACTOR
124
+
125
+
126
+ def compute_cost(distance_km: float, time_hours: float, vehicle: VehicleConfig = None) -> float:
127
+ """Compute route cost: fuel + toll + labor."""
128
+ v = vehicle or VehicleConfig()
129
+ fuel = distance_km * v.fuel_cost_per_km
130
+ toll = distance_km * TOLL_COST_PER_KM
131
+ labor = time_hours * DRIVER_LABOR_PER_HOUR
132
+ return round(fuel + toll + labor, 2)
133
+
134
+
135
+ def get_traffic_speed(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
136
+ """Get traffic-aware speed. Uses traffic_integration if available."""
137
+ try:
138
+ from app.services.traffic_integration import get_effective_speed
139
+ return get_effective_speed(lat1, lng1, lat2, lng2)
140
+ except (ImportError, Exception):
141
+ # Fallback: static Indian urban speed
142
+ hour = time.localtime().tm_hour
143
+ if 7 <= hour <= 10 or 17 <= hour <= 20:
144
+ return 18.0 # Peak hours
145
+ elif 22 <= hour or hour <= 5:
146
+ return 40.0 # Night
147
+ return 28.0 # Off-peak
148
+
149
+
150
  def build_distance_matrix(stops: List[Stop], depot_lat: float, depot_lng: float) -> List[List[int]]:
151
  """Build distance matrix (in meters) with depot at index 0."""
152
  all_points = [(depot_lat, depot_lng)] + [(s.lat, s.lng) for s in stops]
 
155
  for i in range(n):
156
  for j in range(n):
157
  if i != j:
158
+ d = road_distance_km(all_points[i][0], all_points[i][1], all_points[j][0], all_points[j][1])
159
+ matrix[i][j] = int(d * 1000) # meters for OR-Tools
160
  return matrix
161
 
162
 
163
  # ═══════════════════════════════════════════════════════════════
164
+ # PRIORITY SCORING
165
+ # ═══════════════════════════════════════════════════════════════
166
+
167
+ def compute_priority_score(stops: List[Stop], order: List[int]) -> float:
168
+ """
169
+ Score how well priorities are honored (0-100).
170
+ HIGH/EXPRESS stops should be delivered EARLY in the route.
171
+ Score = 100 means all high-priority stops are in first half.
172
+ """
173
+ if not order:
174
+ return 100.0
175
+
176
+ n = len(order)
177
+ total_penalty = 0.0
178
+ max_penalty = 0.0
179
+
180
+ for position, idx in enumerate(order):
181
+ stop = stops[idx]
182
+ priority_weight = PRIORITY_PENALTY.get(stop.priority.lower(), 1.0)
183
+
184
+ if priority_weight > 1.0:
185
+ # High-priority stop β€” penalty increases with position
186
+ normalized_position = position / max(n - 1, 1) # 0.0 (first) to 1.0 (last)
187
+ total_penalty += normalized_position * priority_weight
188
+ max_penalty += 1.0 * priority_weight # Worst case: all at end
189
+
190
+ if max_penalty == 0:
191
+ return 100.0
192
+
193
+ # Invert: lower penalty = higher score
194
+ return round(max(0, (1 - total_penalty / max_penalty)) * 100, 1)
195
+
196
+
197
+ # ═══════════════════════════════════════════════════════════════
198
+ # METHOD 1: OR-TOOLS VRP WITH CAPACITY + TIME WINDOWS + PRIORITY
199
  # ═══════════════════════════════════════════════════════════════
200
 
201
  def solve_vrp_ortools(
202
  stops: List[Stop],
203
  depot_lat: float,
204
  depot_lng: float,
205
+ vehicle: VehicleConfig = None,
206
  speed_kmh: float = 30.0,
207
  max_time_seconds: int = 5,
208
  ) -> Optional[List[int]]:
209
  """
210
+ Solve TSP/VRP using OR-Tools with:
211
+ - Distance minimization
212
+ - Time window constraints (AddDimension 'Time')
213
+ - Vehicle capacity constraint (AddDimension 'Capacity')
214
+ - Priority-aware arc costs (high-priority stops penalized if late)
215
  """
216
  try:
217
  from ortools.constraint_solver import routing_enums_pb2, pywrapcp
218
  except ImportError:
219
  return None
220
+
221
+ v = vehicle or VehicleConfig()
222
  n = len(stops) + 1 # +1 for depot
223
  if n <= 2:
224
  return list(range(len(stops)))
225
+
 
226
  dist_matrix = build_distance_matrix(stops, depot_lat, depot_lng)
227
+
 
228
  manager = pywrapcp.RoutingIndexManager(n, 1, 0)
229
  routing = pywrapcp.RoutingModel(manager)
230
+
231
+ # ── Distance callback with priority-aware cost ──
232
  def distance_callback(from_index, to_index):
233
  from_node = manager.IndexToNode(from_index)
234
  to_node = manager.IndexToNode(to_index)
235
+ base_cost = dist_matrix[from_node][to_node]
236
+
237
+ # Apply priority penalty: delivering high-priority late costs more
238
+ if to_node > 0:
239
+ stop = stops[to_node - 1]
240
+ multiplier = PRIORITY_PENALTY.get(stop.priority.lower(), 1.0)
241
+ # Only penalize if this would be a "late" delivery (heuristic: further from depot)
242
+ if multiplier > 1.0:
243
+ depot_dist = dist_matrix[0][to_node]
244
+ if base_cost > depot_dist * 0.5:
245
+ base_cost = int(base_cost * (1 + (multiplier - 1) * 0.3))
246
+
247
+ return base_cost
248
+
249
  transit_callback_index = routing.RegisterTransitCallback(distance_callback)
250
  routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
251
+
252
+ # ── Capacity dimension (weight) ──
253
+ total_weight = sum(s.weight_kg for s in stops)
254
+ if total_weight > 0:
255
+ def demand_callback(from_index):
256
+ node = manager.IndexToNode(from_index)
257
+ if node == 0:
258
+ return 0 # Depot has no demand
259
+ return int(stops[node - 1].weight_kg * 100) # Scale to int (100g units)
260
+
261
+ demand_callback_index = routing.RegisterUnaryTransitCallback(demand_callback)
262
+ routing.AddDimensionWithVehicleCapacity(
263
+ demand_callback_index,
264
+ 0, # No slack
265
+ [int(v.max_weight_kg * 100)], # Vehicle capacity (in 100g units)
266
+ True, # Start cumul at zero
267
+ 'Capacity'
268
+ )
269
+
270
+ # ── Time dimension (for time windows) ──
271
  has_time_windows = any(s.time_window_start is not None for s in stops)
272
+
273
+ def time_callback(from_index, to_index):
274
+ from_node = manager.IndexToNode(from_index)
275
+ to_node = manager.IndexToNode(to_index)
276
+ dist_km = dist_matrix[from_node][to_node] / 1000
277
+ # Use traffic-aware speed
278
+ if from_node > 0 and to_node > 0:
279
+ spd = get_traffic_speed(stops[from_node-1].lat, stops[from_node-1].lng, stops[to_node-1].lat, stops[to_node-1].lng)
280
+ else:
281
+ spd = speed_kmh
282
+ travel_min = (dist_km / max(spd, 5)) * 60
283
+ if to_node > 0:
284
+ travel_min += stops[to_node - 1].service_time_min
285
+ return int(travel_min)
286
+
287
+ time_callback_index = routing.RegisterTransitCallback(time_callback)
288
+
289
+ max_route_time = 720 # 12 hours
290
+ routing.AddDimension(
291
+ time_callback_index,
292
+ 30, # Slack
293
+ max_route_time,
294
+ False,
295
+ 'Time'
296
+ )
297
+ time_dimension = routing.GetDimensionOrDie('Time')
298
+
299
  if has_time_windows:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  for i, stop in enumerate(stops):
301
  node_index = manager.NodeToIndex(i + 1)
302
  if stop.time_window_start is not None and stop.time_window_end is not None:
 
304
  int(stop.time_window_start),
305
  int(stop.time_window_end)
306
  )
307
+
308
+ time_dimension.CumulVar(routing.Start(0)).SetRange(0, max_route_time)
309
+
310
+ # ── Solve ──
 
311
  search_params = pywrapcp.DefaultRoutingSearchParameters()
312
  search_params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
313
  search_params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
314
  search_params.time_limit.FromSeconds(max_time_seconds)
315
+
316
  solution = routing.SolveWithParameters(search_params)
317
+
318
  if solution:
 
319
  order = []
320
  index = routing.Start(0)
321
  while not routing.IsEnd(index):
322
  node = manager.IndexToNode(index)
323
+ if node > 0:
324
+ order.append(node - 1)
325
  index = solution.Value(routing.NextVar(index))
326
  return order
327
+
328
  return None
329
 
330
 
331
  # ═══════════════════════════════════════════════════════════════
332
+ # METHOD 2: 2-OPT LOCAL SEARCH (PRIORITY-AWARE)
333
  # ═══════════════════════════════════════════════════════════════
334
 
335
  def two_opt_improve(
 
340
  max_iterations: int = 1000,
341
  ) -> List[int]:
342
  """
343
+ 2-opt improvement with priority-aware cost function.
344
+ High-priority stops incur penalty when placed late.
 
 
345
  """
346
+ def route_cost(order: List[int]) -> float:
 
347
  if not order:
348
  return 0.0
349
+ total = road_distance_km(depot_lat, depot_lng, stops[order[0]].lat, stops[order[0]].lng)
 
 
350
  for i in range(len(order) - 1):
351
+ total += road_distance_km(stops[order[i]].lat, stops[order[i]].lng, stops[order[i+1]].lat, stops[order[i+1]].lng)
352
+ total += road_distance_km(stops[order[-1]].lat, stops[order[-1]].lng, depot_lat, depot_lng)
353
+
354
+ # Priority penalty: HIGH/EXPRESS stops later = higher cost
355
+ n = len(order)
356
+ for pos, idx in enumerate(order):
357
+ mult = PRIORITY_PENALTY.get(stops[idx].priority.lower(), 1.0)
358
+ if mult > 1.0:
359
+ position_factor = pos / max(n - 1, 1)
360
+ total += position_factor * mult * 0.5 # Small penalty in km-equivalent
361
+
362
  return total
363
+
364
  best_order = list(initial_order)
365
+ best_cost = route_cost(best_order)
366
  improved = True
367
  iterations = 0
368
+
369
  while improved and iterations < max_iterations:
370
  improved = False
371
  iterations += 1
372
  for i in range(len(best_order) - 1):
373
  for j in range(i + 1, len(best_order)):
 
374
  new_order = best_order[:i] + best_order[i:j+1][::-1] + best_order[j+1:]
375
+ new_cost = route_cost(new_order)
376
+ if new_cost < best_cost - 0.01:
377
  best_order = new_order
378
+ best_cost = new_cost
379
  improved = True
380
  break
381
  if improved:
382
  break
383
+
384
  return best_order
385
 
386
 
387
  # ═══════════════════════════════════════════════════════════════
388
+ # METHOD 3: NEAREST NEIGHBOR (PRIORITY-FIRST)
389
  # ═══════════════════════════════════════════════════════════════
390
 
391
  def nearest_neighbor_order(stops: List[Stop], depot_lat: float, depot_lng: float) -> List[int]:
392
+ """
393
+ Priority-aware nearest-neighbor: HIGH/EXPRESS stops get preference
394
+ when multiple stops are roughly equidistant.
395
+ """
396
  if not stops:
397
  return []
398
+
399
  remaining = list(range(len(stops)))
400
  order = []
401
  curr_lat, curr_lng = depot_lat, depot_lng
402
+
403
  while remaining:
404
+ # Score = distance / priority_weight (lower = better)
405
+ def score(i):
406
+ dist = road_distance_km(curr_lat, curr_lng, stops[i].lat, stops[i].lng)
407
+ priority_boost = PRIORITY_PENALTY.get(stops[i].priority.lower(), 1.0)
408
+ return dist / max(priority_boost, 0.5)
409
+
410
+ best_idx = min(remaining, key=score)
411
+ order.append(best_idx)
412
+ remaining.remove(best_idx)
413
+ curr_lat, curr_lng = stops[best_idx].lat, stops[best_idx].lng
414
+
415
  return order
416
 
417
 
 
426
  depot_lat: float,
427
  depot_lng: float,
428
  ) -> List[int]:
429
+ """Insert a new stop at the cheapest position (priority-aware)."""
 
 
 
430
  if not existing_order:
431
  return [new_stop_idx]
432
+
 
 
 
433
  new_stop = stops[new_stop_idx]
434
  best_position = 0
435
  best_cost_increase = float('inf')
436
+
437
+ # High-priority stops prefer earlier positions
438
+ priority_mult = PRIORITY_PENALTY.get(new_stop.priority.lower(), 1.0)
439
+
440
  for pos in range(len(existing_order) + 1):
441
  if pos == 0:
442
  prev_lat, prev_lng = depot_lat, depot_lng
443
  else:
444
  prev_stop = stops[existing_order[pos - 1]]
445
  prev_lat, prev_lng = prev_stop.lat, prev_stop.lng
446
+
447
  if pos == len(existing_order):
448
  next_lat, next_lng = depot_lat, depot_lng
449
  else:
450
  next_stop = stops[existing_order[pos]]
451
  next_lat, next_lng = next_stop.lat, next_stop.lng
452
+
453
+ current_cost = road_distance_km(prev_lat, prev_lng, next_lat, next_lng)
454
+ new_cost = (road_distance_km(prev_lat, prev_lng, new_stop.lat, new_stop.lng) +
455
+ road_distance_km(new_stop.lat, new_stop.lng, next_lat, next_lng))
456
+
 
 
457
  cost_increase = new_cost - current_cost
458
+ # Priority discount for earlier positions
459
+ if priority_mult > 1.0:
460
+ position_penalty = pos / max(len(existing_order), 1) * (priority_mult - 1)
461
+ cost_increase += position_penalty
462
+
463
  if cost_increase < best_cost_increase:
464
  best_cost_increase = cost_increase
465
  best_position = pos
466
+
467
  result = list(existing_order)
468
  result.insert(best_position, new_stop_idx)
469
  return result
470
 
471
 
472
  # ═══════════════════════════════════════════════════════════════
473
+ # MAIN OPTIMIZER
474
  # ═══════════════════════════════════════════════════════════════
475
 
476
  def optimize_route(
477
  stops: List[Stop],
478
  depot_lat: float,
479
  depot_lng: float,
480
+ vehicle: VehicleConfig = None,
481
+ speed_kmh: float = None,
482
  use_time_windows: bool = True,
483
  max_solver_time: int = 5,
484
  ) -> RouteOptResult:
485
  """
486
  Full route optimization pipeline:
487
+ 1. Try OR-Tools VRP with capacity + time windows + priority
488
  2. Apply 2-opt local search improvement
489
+ 3. Fallback: priority-aware nearest-neighbor + 2-opt
490
+ 4. Compute cost model (fuel + toll + labor)
491
+ 5. Check capacity and priority compliance
492
  """
493
+ v = vehicle or VehicleConfig()
494
+
495
  if not stops:
496
  return RouteOptResult(
497
+ ordered_stops=[], total_distance_km=0, total_time_minutes=0, total_cost_inr=0,
498
+ naive_distance_km=0, distance_saved_km=0, distance_saved_pct=0, cost_saved_inr=0,
499
  optimization_method="empty", time_windows_respected=True,
500
+ capacity_respected=True, priority_score=100.0,
501
  num_stops=0, polyline_points=[],
502
  )
503
+
504
+ # Get traffic-aware speed
505
+ if speed_kmh is None:
506
+ speed_kmh = get_traffic_speed(depot_lat, depot_lng, stops[0].lat, stops[0].lng)
507
+
508
+ # Step 1: Naive baseline
509
  naive_order = list(range(len(stops)))
510
  naive_dist = _compute_route_distance(stops, naive_order, depot_lat, depot_lng)
511
+
512
+ # Step 2: OR-Tools VRP with capacity + time + priority
513
  method = "nearest_neighbor"
514
  ortools_order = None
515
+
516
  if len(stops) >= 3:
517
+ ortools_order = solve_vrp_ortools(stops, depot_lat, depot_lng, v, speed_kmh, max_solver_time)
518
+
 
 
519
  if ortools_order:
520
  method = "or_tools_vrp"
521
  best_order = ortools_order
522
  else:
 
523
  best_order = nearest_neighbor_order(stops, depot_lat, depot_lng)
524
+
525
+ # Step 3: 2-opt improvement
526
  if len(stops) >= 4:
527
  improved_order = two_opt_improve(stops, depot_lat, depot_lng, best_order)
528
+ if _compute_route_distance(stops, improved_order, depot_lat, depot_lng) < _compute_route_distance(stops, best_order, depot_lat, depot_lng):
 
 
 
529
  best_order = improved_order
530
  method = f"{method}+2opt"
531
+
532
+ # Step 4: Compute metrics
533
  optimized_dist = _compute_route_distance(stops, best_order, depot_lat, depot_lng)
534
  distance_saved = naive_dist - optimized_dist
535
  saved_pct = (distance_saved / naive_dist * 100) if naive_dist > 0 else 0
536
+
537
+ total_time_hours = (optimized_dist / max(speed_kmh, 5)) + sum(stops[i].service_time_min for i in best_order) / 60
538
+ total_time_min = total_time_hours * 60
539
+
540
+ # Cost model
541
+ optimized_cost = compute_cost(optimized_dist, total_time_hours, v)
542
+ naive_time_hours = (naive_dist / max(speed_kmh, 5)) + sum(s.service_time_min for s in stops) / 60
543
+ naive_cost = compute_cost(naive_dist, naive_time_hours, v)
544
+ cost_saved = naive_cost - optimized_cost
545
+
546
+ # Capacity check
547
+ total_weight = sum(stops[i].weight_kg for i in best_order)
548
+ capacity_ok = total_weight <= v.max_weight_kg
549
+
550
+ # Priority score
551
+ priority_score = compute_priority_score(stops, best_order)
552
+
553
+ # Time windows check
554
  tw_respected = _check_time_windows(stops, best_order, depot_lat, depot_lng, speed_kmh)
555
+
556
+ # Polyline
557
  polyline = [(depot_lat, depot_lng)] + [(stops[i].lat, stops[i].lng) for i in best_order] + [(depot_lat, depot_lng)]
 
 
558
  ordered_stops = [stops[i] for i in best_order]
559
+
560
  return RouteOptResult(
561
  ordered_stops=ordered_stops,
562
  total_distance_km=round(optimized_dist, 2),
563
+ total_time_minutes=round(total_time_min, 1),
564
+ total_cost_inr=optimized_cost,
565
  naive_distance_km=round(naive_dist, 2),
566
  distance_saved_km=round(max(0, distance_saved), 2),
567
  distance_saved_pct=round(max(0, saved_pct), 1),
568
+ cost_saved_inr=round(max(0, cost_saved), 2),
569
  optimization_method=method,
570
  time_windows_respected=tw_respected,
571
+ capacity_respected=capacity_ok,
572
+ priority_score=priority_score,
573
  num_stops=len(stops),
574
  polyline_points=polyline,
575
  )
 
579
  routes: List[Dict[str, Any]],
580
  depot_lat: float,
581
  depot_lng: float,
582
+ speed_kmh: float = None,
583
+ vehicle: VehicleConfig = None,
584
  ) -> List[RouteComparison]:
585
+ """Generate before/after route comparison for Challenge #4."""
 
 
 
 
 
 
 
 
 
 
586
  comparisons = []
587
+
588
  for route in routes:
589
  route_id = route.get("id", f"route_{len(comparisons)}")
590
  raw_stops = route.get("stops", route.get("packages", []))
591
+
 
592
  stops = [
593
  Stop(
594
  id=s.get("id", f"stop_{i}"),
 
596
  lng=s.get("longitude", s.get("lng", 0)),
597
  address=s.get("address", ""),
598
  weight_kg=s.get("weight_kg", 0),
599
+ volume_m3=s.get("volume_m3", 0),
600
  service_time_min=s.get("service_time_min", 5),
601
  time_window_start=s.get("time_window_start"),
602
  time_window_end=s.get("time_window_end"),
603
  priority=s.get("priority", "normal"),
604
+ is_hazmat=s.get("is_hazmat", False),
605
  )
606
  for i, s in enumerate(raw_stops)
607
  ]
608
+
609
  if not stops:
610
  continue
611
+
612
+ spd = speed_kmh or get_traffic_speed(depot_lat, depot_lng, stops[0].lat, stops[0].lng)
613
+ v = vehicle or VehicleConfig()
614
+
615
+ # Before
616
  naive_dist = _compute_route_distance(stops, list(range(len(stops))), depot_lat, depot_lng)
617
+ naive_time_h = (naive_dist / max(spd, 5)) + sum(s.service_time_min for s in stops) / 60
618
+ naive_cost = compute_cost(naive_dist, naive_time_h, v)
619
+ naive_priority = compute_priority_score(stops, list(range(len(stops))))
620
+
621
+ # After
622
+ result = optimize_route(stops, depot_lat, depot_lng, v, spd)
623
+
624
  comparisons.append(RouteComparison(
625
  route_id=route_id,
626
  before={
627
  "distance_km": round(naive_dist, 2),
628
+ "time_minutes": round(naive_time_h * 60, 1),
629
+ "cost_inr": naive_cost,
630
+ "co2_kg": round(naive_dist * CO2_KG_PER_KM, 2),
631
+ "priority_score": naive_priority,
632
  "stop_order": [s.id for s in stops],
 
633
  },
634
  after={
635
  "distance_km": result.total_distance_km,
636
  "time_minutes": result.total_time_minutes,
637
+ "cost_inr": result.total_cost_inr,
638
+ "co2_kg": round(result.total_distance_km * CO2_KG_PER_KM, 2),
639
+ "priority_score": result.priority_score,
640
  "stop_order": [s.id for s in result.ordered_stops],
 
641
  "method": result.optimization_method,
642
+ "capacity_ok": result.capacity_respected,
643
  "polyline": result.polyline_points,
644
  },
645
  improvement={
646
  "distance_saved_km": result.distance_saved_km,
647
  "distance_saved_pct": result.distance_saved_pct,
648
+ "time_saved_minutes": round(naive_time_h * 60 - result.total_time_minutes, 1),
649
+ "cost_saved_inr": result.cost_saved_inr,
650
+ "co2_saved_kg": round((naive_dist - result.total_distance_km) * CO2_KG_PER_KM, 2),
651
+ "priority_improved": result.priority_score > naive_priority,
652
  "time_windows_respected": result.time_windows_respected,
653
  },
654
  ))
655
+
656
  return comparisons
657
 
658
 
 
667
  max_cluster_size: int = 30,
668
  ) -> List[List[Dict[str, Any]]]:
669
  """
670
+ DBSCAN clustering β€” auto K, arbitrary shapes, noise merged.
671
+ Hazmat packages are isolated into their own clusters.
 
 
 
 
 
 
 
 
 
 
 
672
  """
673
  import numpy as np
674
  from sklearn.cluster import DBSCAN
675
+
 
676
  if not packages:
677
  return []
 
678
  if len(packages) <= min_samples:
679
  return [packages]
680
+
681
+ # Separate hazmat packages (must be isolated)
682
+ hazmat = [p for p in packages if p.get("is_hazmat", False)]
683
+ normal = [p for p in packages if not p.get("is_hazmat", False)]
684
+
685
+ if not normal:
686
+ return [[p] for p in hazmat] # Each hazmat gets its own route
687
+
688
  coords_rad = np.array([
689
  [math.radians(p["latitude"]), math.radians(p["longitude"])]
690
+ for p in normal
691
  ])
692
+
 
693
  eps_rad = eps_km / 6371.0
 
 
694
  db = DBSCAN(eps=eps_rad, min_samples=min_samples, metric='haversine')
695
  labels = db.fit_predict(coords_rad)
696
+
 
697
  clusters: Dict[int, List[int]] = {}
698
  noise_indices: List[int] = []
699
+
700
  for idx, label in enumerate(labels):
701
  if label == -1:
702
  noise_indices.append(idx)
703
  else:
704
+ clusters.setdefault(label, []).append(idx)
705
+
706
+ # Merge noise into nearest cluster
 
 
707
  if noise_indices and clusters:
708
+ centroids = {}
 
709
  for label, indices in clusters.items():
710
+ lats = [normal[i]["latitude"] for i in indices]
711
+ lngs = [normal[i]["longitude"] for i in indices]
712
+ centroids[label] = (sum(lats)/len(lats), sum(lngs)/len(lngs))
713
+
714
+ for ni in noise_indices:
715
+ pkg = normal[ni]
716
+ nearest = min(centroids.keys(), key=lambda l: haversine_km(pkg["latitude"], pkg["longitude"], centroids[l][0], centroids[l][1]))
717
+ clusters[nearest].append(ni)
718
+ elif noise_indices:
 
 
 
 
 
 
 
 
719
  clusters[0] = noise_indices
720
+
721
+ # Split oversized + build final
722
+ final = []
723
  for label, indices in clusters.items():
724
  if len(indices) <= max_cluster_size:
725
+ final.append([normal[i] for i in indices])
726
  else:
 
727
  from sklearn.cluster import KMeans
728
+ sub_coords = np.array([[normal[i]["latitude"], normal[i]["longitude"]] for i in indices])
729
  n_sub = max(2, len(indices) // max_cluster_size + 1)
730
  km = KMeans(n_clusters=n_sub, random_state=42, n_init=5)
731
  sub_labels = km.fit_predict(sub_coords)
732
  for sl in range(n_sub):
733
+ sub = [indices[j] for j in range(len(indices)) if sub_labels[j] == sl]
734
+ if sub:
735
+ final.append([normal[i] for i in sub])
736
+
737
+ # Add hazmat as separate clusters
738
+ for h in hazmat:
739
+ final.append([h])
740
+
741
+ return final
742
 
743
 
744
  # ═══════════════════════════════════════════════════════════════
 
746
  # ═══════════════════════════════════════════════════════════════
747
 
748
  def _compute_route_distance(stops: List[Stop], order: List[int], depot_lat: float, depot_lng: float) -> float:
749
+ """Compute total road distance for a given stop order."""
750
  if not order:
751
  return 0.0
752
+ total = road_distance_km(depot_lat, depot_lng, stops[order[0]].lat, stops[order[0]].lng)
753
  for i in range(len(order) - 1):
754
+ total += road_distance_km(stops[order[i]].lat, stops[order[i]].lng, stops[order[i+1]].lat, stops[order[i+1]].lng)
755
+ total += road_distance_km(stops[order[-1]].lat, stops[order[-1]].lng, depot_lat, depot_lng)
756
  return total
757
 
758
 
759
  def _check_time_windows(stops: List[Stop], order: List[int], depot_lat: float, depot_lng: float, speed_kmh: float) -> bool:
760
+ """Check if all time windows are respected."""
761
  if not order:
762
  return True
763
+
764
+ current_time = 0.0
765
  current_lat, current_lng = depot_lat, depot_lng
766
+
767
  for idx in order:
768
  stop = stops[idx]
769
+ dist = road_distance_km(current_lat, current_lng, stop.lat, stop.lng)
770
+ travel_time = (dist / max(speed_kmh, 5)) * 60
 
771
  current_time += travel_time
772
+
 
773
  if stop.time_window_end is not None and current_time > stop.time_window_end:
774
  return False
 
 
775
  if stop.time_window_start is not None and current_time < stop.time_window_start:
776
  current_time = stop.time_window_start
777
+
 
778
  current_time += stop.service_time_min
779
  current_lat, current_lng = stop.lat, stop.lng
780
+
781
  return True