fix: Add vehicle capacity constraint, priority-aware routing, traffic integration, cost model to VRP solver
#35
by MouleeswaranM - opened
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.
|
| 11 |
-
4.
|
| 12 |
-
5.
|
| 13 |
-
6.
|
|
|
|
|
|
|
|
|
|
| 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
|
| 35 |
id: str
|
| 36 |
lat: float
|
| 37 |
lng: float
|
| 38 |
address: str = ""
|
| 39 |
weight_kg: float = 0.0
|
| 40 |
-
|
|
|
|
| 41 |
time_window_start: Optional[int] = None # Minutes from route start
|
| 42 |
-
time_window_end: Optional[int] = None
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
time_windows_respected: bool
|
|
|
|
|
|
|
| 57 |
num_stops: int
|
| 58 |
-
polyline_points: List[Tuple[float, float]]
|
| 59 |
|
| 60 |
|
| 61 |
@dataclass
|
|
@@ -68,7 +106,7 @@ class RouteComparison:
|
|
| 68 |
|
| 69 |
|
| 70 |
# ββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 71 |
-
# CORE:
|
| 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 =
|
| 92 |
-
matrix[i][j] = int(d * 1000) #
|
| 93 |
return matrix
|
| 94 |
|
| 95 |
|
| 96 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
|
| 135 |
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
|
| 136 |
-
|
| 137 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 175 |
-
|
| 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:
|
| 192 |
-
order.append(node - 1)
|
| 193 |
index = solution.Value(routing.NextVar(index))
|
| 194 |
return order
|
| 195 |
-
|
| 196 |
return None
|
| 197 |
|
| 198 |
|
| 199 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 200 |
-
# METHOD 2: 2-OPT LOCAL SEARCH
|
| 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 |
-
|
| 212 |
-
|
| 213 |
-
2-opt reverses segments of the route to reduce total distance.
|
| 214 |
-
Guaranteed to converge to a local optimum.
|
| 215 |
"""
|
| 216 |
-
def
|
| 217 |
-
"""Total route distance including depotβfirst and lastβdepot."""
|
| 218 |
if not order:
|
| 219 |
return 0.0
|
| 220 |
-
|
| 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 +=
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
return total
|
| 228 |
-
|
| 229 |
best_order = list(initial_order)
|
| 230 |
-
|
| 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 |
-
|
| 242 |
-
if
|
| 243 |
best_order = new_order
|
| 244 |
-
|
| 245 |
improved = True
|
| 246 |
break
|
| 247 |
if improved:
|
| 248 |
break
|
| 249 |
-
|
| 250 |
return best_order
|
| 251 |
|
| 252 |
|
| 253 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 254 |
-
# METHOD 3: NEAREST NEIGHBOR (
|
| 255 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 256 |
|
| 257 |
def nearest_neighbor_order(stops: List[Stop], depot_lat: float, depot_lng: float) -> List[int]:
|
| 258 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 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
|
| 332 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 333 |
|
| 334 |
def optimize_route(
|
| 335 |
stops: List[Stop],
|
| 336 |
depot_lat: float,
|
| 337 |
depot_lng: float,
|
| 338 |
-
|
|
|
|
| 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
|
| 345 |
2. Apply 2-opt local search improvement
|
| 346 |
-
3. Fallback: nearest-neighbor + 2-opt
|
| 347 |
-
|
| 348 |
-
|
| 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 |
-
|
| 359 |
-
|
| 360 |
-
|
|
|
|
|
|
|
| 361 |
naive_order = list(range(len(stops)))
|
| 362 |
naive_dist = _compute_route_distance(stops, naive_order, depot_lat, depot_lng)
|
| 363 |
-
|
| 364 |
-
# Step 2:
|
| 365 |
method = "nearest_neighbor"
|
| 366 |
ortools_order = None
|
| 367 |
-
|
| 368 |
if len(stops) >= 3:
|
| 369 |
-
ortools_order = solve_vrp_ortools(
|
| 370 |
-
|
| 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:
|
| 381 |
if len(stops) >= 4:
|
| 382 |
improved_order = two_opt_improve(stops, depot_lat, depot_lng, best_order)
|
| 383 |
-
|
| 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
|
| 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 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
tw_respected = _check_time_windows(stops, best_order, depot_lat, depot_lng, speed_kmh)
|
| 400 |
-
|
| 401 |
-
#
|
| 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(
|
|
|
|
| 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 =
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 464 |
naive_dist = _compute_route_distance(stops, list(range(len(stops))), depot_lat, depot_lng)
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
| 470 |
comparisons.append(RouteComparison(
|
| 471 |
route_id=route_id,
|
| 472 |
before={
|
| 473 |
"distance_km": round(naive_dist, 2),
|
| 474 |
-
"time_minutes": round(
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 490 |
-
"
|
|
|
|
|
|
|
| 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
|
| 510 |
-
|
| 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 |
-
|
| 526 |
-
|
| 527 |
if not packages:
|
| 528 |
return []
|
| 529 |
-
|
| 530 |
if len(packages) <= min_samples:
|
| 531 |
return [packages]
|
| 532 |
-
|
| 533 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
coords_rad = np.array([
|
| 535 |
[math.radians(p["latitude"]), math.radians(p["longitude"])]
|
| 536 |
-
for p in
|
| 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 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
# FIX: Merge noise points into nearest cluster
|
| 559 |
if noise_indices and clusters:
|
| 560 |
-
|
| 561 |
-
cluster_centroids = {}
|
| 562 |
for label, indices in clusters.items():
|
| 563 |
-
lats = [
|
| 564 |
-
lngs = [
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
for
|
| 568 |
-
pkg =
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 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
|
| 583 |
-
|
| 584 |
for label, indices in clusters.items():
|
| 585 |
if len(indices) <= max_cluster_size:
|
| 586 |
-
|
| 587 |
else:
|
| 588 |
-
# Split using KMeans
|
| 589 |
from sklearn.cluster import KMeans
|
| 590 |
-
sub_coords = np.array([[
|
| 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 |
-
|
| 596 |
-
if
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 608 |
if not order:
|
| 609 |
return 0.0
|
| 610 |
-
total =
|
| 611 |
for i in range(len(order) - 1):
|
| 612 |
-
total +=
|
| 613 |
-
total +=
|
| 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
|
| 619 |
if not order:
|
| 620 |
return True
|
| 621 |
-
|
| 622 |
-
current_time = 0.0
|
| 623 |
current_lat, current_lng = depot_lat, depot_lng
|
| 624 |
-
|
| 625 |
for idx in order:
|
| 626 |
stop = stops[idx]
|
| 627 |
-
|
| 628 |
-
|
| 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
|