1615 lines
70 KiB
Python
1615 lines
70 KiB
Python
"""Production-grade route optimization using Google OR-Tools.
|
|
|
|
ALGORITHM: TSP / VRP with Google OR-Tools
|
|
- Industry-standard solver (same as used by major logistics companies)
|
|
- Constraint-based optimization
|
|
- Handles time windows (future proofing)
|
|
- Guaranteed optimal or near-optimal solution
|
|
|
|
FEATURES:
|
|
- Automatic outlier detection and coordinate correction
|
|
- Hybrid distance calculation (Google Maps + Haversine fallback)
|
|
- Robust error handling for invalid inputs
|
|
- Composite cost function (idea.txt: distance + profit - margin)
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import hashlib
|
|
import logging
|
|
import asyncio
|
|
from typing import Dict, Any, List as _List, Optional, Tuple, Union
|
|
from datetime import datetime, timedelta
|
|
import httpx
|
|
from app.services.routing.kalman_filter import smooth_order_coordinates
|
|
import numpy as np
|
|
from app.core.arrow_utils import calculate_haversine_matrix_vectorized
|
|
from app.config.dynamic_config import get_config
|
|
|
|
try:
|
|
from ortools.constraint_solver import routing_enums_pb2
|
|
from ortools.constraint_solver import pywrapcp
|
|
|
|
ORTOOLS_AVAILABLE = True
|
|
except ImportError:
|
|
ORTOOLS_AVAILABLE = False
|
|
logging.warning("Google OR-Tools not found. Falling back to simple greedy solver.")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CompositeCostCalculator:
|
|
"""
|
|
Composite Cost Function for Profit-Aware Route Optimization.
|
|
|
|
Based on idea.txt data encoding techniques:
|
|
- Total Cost = Distance Cost + Rider Cost - Merchant Profit
|
|
- Edge Cost = (distance * fuel_rate) + rider_cost - merchant_margin
|
|
- route_score = profit - composite_cost (what we want to MAXIMIZE)
|
|
|
|
This transforms the problem from:
|
|
"Find shortest route" -> "Find most profitable route"
|
|
"""
|
|
|
|
def __init__(self):
|
|
# Cost parameters (can be ML-tuned via DynamicConfig)
|
|
self.fuel_rate_per_km = 2.5
|
|
self.base_rider_cost = 0.0
|
|
self.opportunity_cost_per_km = 0.5 # Cost of rider's time per km
|
|
self.profit_weight = 0.3 # How much profit influences routing (0-1)
|
|
self.traffic_multiplier_peak = 1.5 # Peak hour traffic penalty
|
|
self.traffic_multiplier_normal = 1.2 # Normal traffic multiplier
|
|
|
|
# Defaults for orders without profit data
|
|
self.default_order_amount = 80.0
|
|
self.default_merchant_margin = 5.0
|
|
|
|
def calculate_composite_cost(
|
|
self,
|
|
distance_km: float,
|
|
order_amount: float = None,
|
|
merchant_margin: float = None,
|
|
traffic_factor: float = 1.0,
|
|
time_of_day: str = "NORMAL",
|
|
) -> Dict[str, float]:
|
|
"""
|
|
Calculate composite cost for a route edge.
|
|
|
|
Args:
|
|
distance_km: Distance for this leg
|
|
order_amount: Revenue from this order (if known)
|
|
merchant_margin: Merchant's margin for this order (if known)
|
|
traffic_factor: Traffic multiplier (1.0 = normal)
|
|
time_of_day: Traffic time category ("PEAK", "NORMAL", "OFF_PEAK")
|
|
|
|
Returns:
|
|
Dict with:
|
|
- distance_cost: Raw distance cost
|
|
- rider_cost: Total rider cost for this leg
|
|
- gross_profit: Revenue - rider cost
|
|
- net_cost: Cost after profit adjustment (MINIMIZE THIS)
|
|
- route_score: Profitability score (MAXIMIZE THIS)
|
|
"""
|
|
# Distance cost = fuel + opportunity cost
|
|
distance_cost = distance_km * self.fuel_rate_per_km
|
|
|
|
# Rider cost = base + distance cost
|
|
rider_cost = self.base_rider_cost + distance_cost
|
|
|
|
# Apply traffic penalty
|
|
if time_of_day == "PEAK":
|
|
rider_cost *= self.traffic_multiplier_peak
|
|
elif time_of_day == "NORMAL":
|
|
rider_cost *= self.traffic_multiplier_normal
|
|
|
|
# Apply custom traffic factor
|
|
rider_cost *= traffic_factor
|
|
|
|
# Profit calculation (target encoding: use order data if available)
|
|
if order_amount is None:
|
|
order_amount = self.default_order_amount
|
|
if merchant_margin is None:
|
|
merchant_margin = self.default_merchant_margin
|
|
|
|
gross_profit = order_amount - rider_cost
|
|
|
|
# Net cost = rider cost - profit contribution
|
|
# This means high-profit orders have LOWER cost (more desirable)
|
|
profit_adjustment = gross_profit * self.profit_weight
|
|
net_cost = rider_cost - profit_adjustment
|
|
|
|
# Route score = profit - opportunity cost (for route planning)
|
|
route_score = gross_profit - (distance_km * self.opportunity_cost_per_km)
|
|
|
|
# Ensure net_cost is never negative (minimum cost for any delivery)
|
|
net_cost = max(net_cost, 5.0) # Minimum 5 km equivalent cost
|
|
|
|
return {
|
|
"distance_cost": round(distance_cost, 2),
|
|
"rider_cost": round(rider_cost, 2),
|
|
"gross_profit": round(gross_profit, 2),
|
|
"net_cost": round(net_cost, 2),
|
|
"route_score": round(route_score, 2),
|
|
}
|
|
|
|
def calculate_cost_matrix(
|
|
self,
|
|
dist_matrix: np.ndarray,
|
|
orders: _List[Dict[str, Any]] = None,
|
|
traffic_condition: str = "NORMAL",
|
|
) -> Tuple[np.ndarray, Dict[str, Any]]:
|
|
"""
|
|
Calculate composite cost matrix for all node pairs.
|
|
|
|
Args:
|
|
dist_matrix: Distance matrix (N x N)
|
|
orders: List of orders (for profit data)
|
|
traffic_condition: Traffic condition ("PEAK", "NORMAL", "OFF_PEAK")
|
|
|
|
Returns:
|
|
Tuple of (cost_matrix, summary_stats)
|
|
"""
|
|
n = len(dist_matrix)
|
|
cost_matrix = np.zeros((n, n))
|
|
|
|
# Extract order data for profit encoding
|
|
order_amounts = []
|
|
merchant_margins = []
|
|
|
|
if orders:
|
|
for o in orders:
|
|
try:
|
|
amount = float(
|
|
o.get("orderamount")
|
|
or o.get("deliveryamount")
|
|
or self.default_order_amount
|
|
)
|
|
margin = float(
|
|
o.get("merchant_margin") or self.default_merchant_margin
|
|
)
|
|
except:
|
|
amount = self.default_order_amount
|
|
margin = self.default_merchant_margin
|
|
order_amounts.append(amount)
|
|
merchant_margins.append(margin)
|
|
else:
|
|
order_amounts = [self.default_order_amount] * n
|
|
merchant_margins = [self.default_merchant_margin] * n
|
|
|
|
# Calculate costs for each pair
|
|
total_cost = 0.0
|
|
total_profit = 0.0
|
|
high_cost_count = 0
|
|
|
|
for i in range(n):
|
|
for j in range(n):
|
|
if i == j:
|
|
cost_matrix[i][j] = 0
|
|
continue
|
|
|
|
dist = dist_matrix[i][j]
|
|
|
|
# Use order j's profit data (destination)
|
|
order_amount = (
|
|
order_amounts[j - 1] if j > 0 else self.default_order_amount
|
|
)
|
|
merchant_margin = (
|
|
merchant_margins[j - 1] if j > 0 else self.default_merchant_margin
|
|
)
|
|
|
|
cost_data = self.calculate_composite_cost(
|
|
distance_km=dist,
|
|
order_amount=order_amount,
|
|
merchant_margin=merchant_margin,
|
|
time_of_day=traffic_condition,
|
|
)
|
|
|
|
cost_matrix[i][j] = cost_data["net_cost"]
|
|
total_cost += cost_data["net_cost"]
|
|
total_profit += cost_data["gross_profit"]
|
|
|
|
if cost_data["net_cost"] > 50:
|
|
high_cost_count += 1
|
|
|
|
summary = {
|
|
"total_cost": round(total_cost, 2),
|
|
"total_profit": round(total_profit, 2),
|
|
"avg_cost": round(total_cost / (n * n) if n > 0 else 0, 2),
|
|
"avg_profit": round(total_profit / (n * n) if n > 0 else 0, 2),
|
|
"high_cost_legs": high_cost_count,
|
|
"traffic_condition": traffic_condition,
|
|
}
|
|
|
|
return cost_matrix, summary
|
|
|
|
|
|
class RouteOptimizer:
|
|
"""Route optimization using Google OR-Tools (Async)."""
|
|
|
|
def __init__(self):
|
|
self.earth_radius = 6371 # Earth radius in km
|
|
_cfg = get_config()
|
|
|
|
# Initialize ETA Calculator - empirical (learned from actual delivery
|
|
# times) with automatic fallback to the realistic formula.
|
|
from app.services.routing.realistic_eta_calculator import (
|
|
get_time_of_day_category,
|
|
)
|
|
from app.services.routing.empirical_eta_calculator import EmpiricalETACalculator
|
|
|
|
self.eta_calculator = EmpiricalETACalculator()
|
|
self.get_traffic_condition = get_time_of_day_category
|
|
|
|
# Speed settings (ML-tuned via DynamicConfig)
|
|
self.avg_speed_kmh = float(_cfg.get("avg_speed_kmh"))
|
|
|
|
# Road factor (haversine -> road distance multiplier, ML-tuned)
|
|
self.road_factor = float(_cfg.get("road_factor"))
|
|
|
|
# Google Maps API settings
|
|
self.google_maps_api_key = os.getenv("GOOGLE_MAPS_API_KEY", "")
|
|
self.use_google_maps = bool(self.google_maps_api_key)
|
|
|
|
# Solver time limit (ML-tuned)
|
|
self.search_time_limit_seconds = int(_cfg.get("search_time_limit_seconds"))
|
|
|
|
self.cost_calculator = CompositeCostCalculator()
|
|
|
|
def haversine_distance(
|
|
self, lat1: float, lon1: float, lat2: float, lon2: float
|
|
) -> float:
|
|
"""Calculate great circle distance between two points on Earth (in km)."""
|
|
try:
|
|
lat1, lon1, lat2, lon2 = map(
|
|
math.radians, [float(lat1), float(lon1), float(lat2), float(lon2)]
|
|
)
|
|
dlat = lat2 - lat1
|
|
dlon = lon2 - lon1
|
|
a = (
|
|
math.sin(dlat / 2) ** 2
|
|
+ math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
|
|
)
|
|
c = 2 * math.asin(math.sqrt(a))
|
|
return self.earth_radius * c
|
|
except Exception:
|
|
return 0.0
|
|
|
|
async def _get_google_maps_distances_batch(
|
|
self, origin_lat: float, origin_lon: float, destinations: _List[tuple]
|
|
) -> Dict[tuple, float]:
|
|
"""Get road distances for multiple destinations from Google Maps API. (Async, Parallel)"""
|
|
if not self.use_google_maps or not destinations:
|
|
return {}
|
|
|
|
results = {}
|
|
batch_size = 25
|
|
chunks = [
|
|
destinations[i : i + batch_size]
|
|
for i in range(0, len(destinations), batch_size)
|
|
]
|
|
|
|
async def process_batch(batch):
|
|
batch_result = {}
|
|
try:
|
|
dest_str = "|".join([f"{lat},{lon}" for lat, lon in batch])
|
|
url = "https://maps.googleapis.com/maps/api/distancematrix/json"
|
|
params = {
|
|
"origins": f"{origin_lat},{origin_lon}",
|
|
"destinations": dest_str,
|
|
"key": self.google_maps_api_key,
|
|
"units": "metric",
|
|
}
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
response = await client.get(url, params=params)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
if data.get("status") == "OK":
|
|
rows = data.get("rows", [])
|
|
if rows:
|
|
elements = rows[0].get("elements", [])
|
|
for idx, element in enumerate(elements):
|
|
if idx < len(batch):
|
|
dest_coord = batch[idx]
|
|
if element.get("status") == "OK":
|
|
dist = element.get("distance", {}).get("value")
|
|
dur = element.get("duration", {}).get("value")
|
|
if dist is not None:
|
|
batch_result[dest_coord] = {
|
|
"distance": dist / 1000.0,
|
|
"duration": dur / 60.0 if dur else None,
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Google Maps batch call failed: {e}")
|
|
return batch_result
|
|
|
|
batch_results_list = await asyncio.gather(
|
|
*[process_batch(chunk) for chunk in chunks]
|
|
)
|
|
for res in batch_results_list:
|
|
results.update(res)
|
|
return results
|
|
|
|
# ------------------------------------------------------------------
|
|
# GOOGLE DIRECTIONS - WAYPOINT OPTIMISATION
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _optimize_waypoints_google(
|
|
self,
|
|
origin_lat: float,
|
|
origin_lon: float,
|
|
waypoints: _List[Tuple[float, float]],
|
|
) -> Tuple[Optional[_List[int]], Optional[_List[float]]]:
|
|
"""
|
|
Ask Google Directions API to find the optimal visiting order for a set
|
|
of delivery points starting from a kitchen/pickup location.
|
|
|
|
Uses `optimize:true` in the waypoints parameter - Google solves the TSP
|
|
internally using actual road geometry (turn restrictions, one-way
|
|
streets, real distances) rather than Haversine approximation.
|
|
|
|
Returns
|
|
-------
|
|
(waypoint_order, leg_km)
|
|
waypoint_order 0-based indices into `waypoints` in optimal order.
|
|
e.g. [2, 0, 1] means visit wp[2] -> wp[0] -> wp[1].
|
|
leg_km Actual road distance (km) for each leg in order:
|
|
leg_km[0] = kitchen -> wp[order[0]],
|
|
leg_km[1] = wp[order[0]] -> wp[order[1]], etc.
|
|
Both are None on any failure - caller falls back to OR-Tools.
|
|
|
|
Notes
|
|
-----
|
|
- Supports up to 25 intermediate waypoints (Google's standard limit).
|
|
- destination = origin (closed-loop TSP); the return leg is discarded.
|
|
- One API call per rider per assignment - cheap at delivery scale.
|
|
"""
|
|
if not self.use_google_maps or not waypoints or len(waypoints) < 2:
|
|
return None, None
|
|
if len(waypoints) > 25:
|
|
return None, None # fall back to OR-Tools for unusually large routes
|
|
|
|
try:
|
|
wp_str = "optimize:true|" + "|".join(
|
|
f"{lat},{lon}" for lat, lon in waypoints
|
|
)
|
|
params = {
|
|
"origin": f"{origin_lat},{origin_lon}",
|
|
"destination": f"{origin_lat},{origin_lon}", # closed loop
|
|
"waypoints": wp_str,
|
|
"key": self.google_maps_api_key,
|
|
}
|
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
resp = await client.get(
|
|
"https://maps.googleapis.com/maps/api/directions/json",
|
|
params=params,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
status_code = data.get("status")
|
|
if status_code != "OK":
|
|
logger.debug(
|
|
f"[GoogleWaypoints] status={status_code} "
|
|
f"error='{data.get('error_message', '')}'"
|
|
)
|
|
return None, None
|
|
|
|
routes = data.get("routes", [])
|
|
if not routes:
|
|
return None, None
|
|
|
|
route = routes[0]
|
|
wp_order = route.get("waypoint_order")
|
|
legs = route.get("legs", [])
|
|
|
|
if wp_order is None or len(wp_order) != len(waypoints):
|
|
return None, None
|
|
|
|
# Extract leg distances (metres -> km), skip the return-to-origin leg
|
|
leg_km: _List[float] = []
|
|
for leg in legs[: len(waypoints)]: # first N legs only
|
|
dist_m = leg.get("distance", {}).get("value")
|
|
leg_km.append(dist_m / 1000.0 if dist_m is not None else 0.0)
|
|
|
|
logger.debug(
|
|
f"[GoogleWaypoints] Optimised {len(waypoints)} stops -> order={wp_order}"
|
|
)
|
|
return wp_order, leg_km
|
|
|
|
except Exception as _e:
|
|
logger.debug(f"[GoogleWaypoints] Failed (non-fatal): {_e}")
|
|
return None, None
|
|
|
|
# ------------------------------------------------------------------
|
|
# ROAD-AWARE VISITING ORDER (Phase 2 - opt-in, cached)
|
|
# ------------------------------------------------------------------
|
|
|
|
async def _road_duration_matrix(
|
|
self, coords: _List[Tuple[float, float]]
|
|
) -> Optional[_List[_List[float]]]:
|
|
"""
|
|
Full NxN road travel-TIME matrix (minutes) via Google Distance Matrix.
|
|
|
|
Chunks destinations to respect Google's ~100-elements-per-request limit.
|
|
Returns None on any failure so the caller falls back to aerial ordering.
|
|
"""
|
|
if not self.use_google_maps:
|
|
return None
|
|
n = len(coords)
|
|
origins = "|".join(f"{la},{lo}" for la, lo in coords)
|
|
matrix = [[0.0] * n for _ in range(n)]
|
|
dest_chunk = max(1, 100 // max(1, n))
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
for j0 in range(0, n, dest_chunk):
|
|
js = list(range(j0, min(j0 + dest_chunk, n)))
|
|
dests = "|".join(f"{coords[j][0]},{coords[j][1]}" for j in js)
|
|
resp = await client.get(
|
|
"https://maps.googleapis.com/maps/api/distancematrix/json",
|
|
params={"origins": origins, "destinations": dests,
|
|
"key": self.google_maps_api_key, "units": "metric"},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if data.get("status") != "OK":
|
|
logger.debug(f"[RoadSeq] DistanceMatrix status={data.get('status')}")
|
|
return None
|
|
for i, row in enumerate(data.get("rows", [])):
|
|
for k, el in enumerate(row.get("elements", [])):
|
|
if el.get("status") == "OK":
|
|
dur = el.get("duration", {}).get("value")
|
|
if dur is not None:
|
|
matrix[i][js[k]] = dur / 60.0
|
|
return matrix
|
|
except Exception as e:
|
|
logger.debug(f"[RoadSeq] matrix build failed: {e}")
|
|
return None
|
|
|
|
async def _road_optimal_order(
|
|
self,
|
|
start_lat: float,
|
|
start_lon: float,
|
|
points: _List[Tuple[float, float]],
|
|
) -> Optional[_List[int]]:
|
|
"""
|
|
Road-aware visiting order for `points`, starting from (start_lat, start_lon).
|
|
|
|
Builds a real road travel-TIME matrix (Google Distance Matrix) and solves
|
|
an OPEN TSP with OR-Tools (return-to-depot edge = 0), so the sequence
|
|
respects real road geometry/one-ways instead of straight-line distance.
|
|
Validated on live batches to cut real travel time ~5-13% vs aerial; note
|
|
Google's Directions optimize:true is NOT used - it optimises a closed loop
|
|
and measured *worse* than aerial for our open delivery routes.
|
|
|
|
Returns 0-based indices into `points` in optimal order, or None to signal
|
|
the caller to fall back to the existing aerial greedy + 2-opt.
|
|
|
|
Safe + cheap on the live path:
|
|
* disabled unless `routing_use_road_distance` AND a Google key is set
|
|
* only for 3..`routing_road_max_stops` stops (fewer is trivial; more
|
|
exceeds Google's waypoint-optimize cap)
|
|
* result cached in Redis (default 24h) keyed by the rounded coords in
|
|
input order, so the returned indices always map back correctly
|
|
"""
|
|
cfg = get_config()
|
|
if not cfg.get("routing_use_road_distance", False):
|
|
return None
|
|
if not self.use_google_maps:
|
|
return None
|
|
n = len(points)
|
|
if n < 3 or n > int(cfg.get("routing_road_max_stops", 25)):
|
|
return None
|
|
|
|
def _r(v) -> float:
|
|
return round(float(v), 4) # ~11 m - enough to dedupe near-identical batches
|
|
|
|
pts_key = "|".join(f"{_r(la)},{_r(lo)}" for la, lo in points)
|
|
cache_key = "roadseq:" + hashlib.sha256(
|
|
f"{_r(start_lat)},{_r(start_lon)}#{pts_key}".encode("utf-8")
|
|
).hexdigest()
|
|
|
|
try:
|
|
from app.services import cache as _cache
|
|
cached = _cache.get_json(cache_key)
|
|
if isinstance(cached, list) and len(cached) == n:
|
|
logger.debug(f"[RoadSeq] cache hit ({n} stops)")
|
|
return cached
|
|
except Exception:
|
|
pass
|
|
|
|
locs = [(start_lat, start_lon)] + list(points)
|
|
matrix = await self._road_duration_matrix(locs)
|
|
if matrix is None:
|
|
return None
|
|
# matrix contains minutes, not km — use scale=100 (centiseconds) so
|
|
# OR-Tools gets valid integer arc costs without the km→m * 1000 factor.
|
|
route = self._solve_tsp_ortools(locs, matrix, matrix_scale=100)
|
|
order = [i - 1 for i in route if i != 0]
|
|
if len(order) != n:
|
|
return None
|
|
|
|
try:
|
|
from app.services import cache as _cache
|
|
_cache.set_json(
|
|
cache_key, order,
|
|
ttl_seconds=int(cfg.get("routing_road_cache_ttl_seconds", 86400)),
|
|
)
|
|
except Exception:
|
|
pass
|
|
logger.info(f"[RoadSeq] road-optimal order for {n} stops -> {order}")
|
|
return order
|
|
|
|
# ------------------------------------------------------------------
|
|
# 2-OPT LOCAL SEARCH
|
|
# ------------------------------------------------------------------
|
|
|
|
def _two_opt_improve(
|
|
self,
|
|
route: _List[int],
|
|
dist_matrix, # numpy array or nested list
|
|
) -> _List[int]:
|
|
"""
|
|
2-opt local search: repeatedly reverses route segments to find a shorter path.
|
|
|
|
Algorithm:
|
|
For each pair (i, j) where i < j, check whether reversing the
|
|
sub-route between positions i and j reduces total distance.
|
|
If yes, apply the reversal and restart scanning.
|
|
|
|
Property:
|
|
* Guaranteed to terminate (finitely many distinct routes).
|
|
* Produces a *locally* 2-optimal solution - no 2-edge swap can
|
|
improve it further.
|
|
* Typical gain on delivery-scale instances: 815% shorter routes.
|
|
* Works on OPEN routes (depot stays at position 0, free return).
|
|
|
|
Complexity: O(n) per improvement pass, O(n) worst case total.
|
|
Acceptable for n <= 50 orders per rider.
|
|
"""
|
|
if len(route) <= 3:
|
|
return route
|
|
|
|
def d(a: int, b: int) -> float:
|
|
return float(dist_matrix[a][b])
|
|
|
|
best = list(route)
|
|
n = len(best)
|
|
improved = True
|
|
|
|
while improved:
|
|
improved = False
|
|
for i in range(1, n - 1):
|
|
for j in range(i + 1, n):
|
|
# Cost of the TWO edges that would change in this swap:
|
|
# Current : best[i-1] -> best[i] and best[j] -> best[j+1]
|
|
# Reversed: best[i-1] -> best[j] and best[i] -> best[j+1]
|
|
# (open TSP: no edge from last node back to depot)
|
|
c_before = d(best[i - 1], best[i])
|
|
c_after = d(best[i - 1], best[j])
|
|
if j + 1 < n:
|
|
c_before += d(best[j], best[j + 1])
|
|
c_after += d(best[i], best[j + 1])
|
|
|
|
# For asymmetric matrices (e.g., road duration), reversing the
|
|
# sub-route changes the cost of internal edges. We must add
|
|
# the exact difference. For symmetric matrices, this is 0.
|
|
internal_diff = 0.0
|
|
for k in range(i, j):
|
|
internal_diff += d(best[k + 1], best[k]) - d(best[k], best[k + 1])
|
|
|
|
if c_after + internal_diff < c_before - 1e-10:
|
|
best[i : j + 1] = best[i : j + 1][::-1]
|
|
improved = True
|
|
|
|
return best
|
|
|
|
# ------------------------------------------------------------------
|
|
# TSP SOLVER (with optional time windows + 2-opt post-processing)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _solve_tsp_ortools(
|
|
self,
|
|
locations: _List[Tuple[float, float]],
|
|
dist_matrix: _List[_List[float]],
|
|
time_windows: Optional[_List[Tuple[int, int]]] = None,
|
|
matrix_scale: int = 1000,
|
|
) -> _List[int]:
|
|
"""
|
|
Solve Open TSP using Google OR-Tools + GLS + 2-opt refinement.
|
|
|
|
Args:
|
|
locations : list of (lat, lon) - index 0 is the depot (kitchen).
|
|
dist_matrix : NxN cost matrix. Default unit is km (scale=1000 → metres).
|
|
Pass matrix_scale=100 when the matrix contains minutes.
|
|
time_windows: Optional list of (earliest_min, latest_min) per node.
|
|
* Node 0 (depot): typically (0, horizon).
|
|
* Delivery node i: (0, max_delivery_deadline).
|
|
If None, no time-window constraints are applied.
|
|
matrix_scale: Integer multiplier applied to matrix values before
|
|
passing to OR-Tools (which requires integer arc costs).
|
|
|
|
Returns:
|
|
Route as a list of node indices starting with 0 (depot).
|
|
"""
|
|
if not ORTOOLS_AVAILABLE:
|
|
route = self._solve_greedy(locations, dist_matrix)
|
|
return self._two_opt_improve(route, dist_matrix)
|
|
|
|
if not locations or len(locations) <= 1:
|
|
return [0]
|
|
|
|
n_nodes = len(locations)
|
|
manager = pywrapcp.RoutingIndexManager(n_nodes, 1, 0)
|
|
routing = pywrapcp.RoutingModel(manager)
|
|
|
|
# -- DISTANCE CALLBACK --------------------------------------------
|
|
# Open TSP: return-to-depot edge always costs 0 so the solver
|
|
# optimises the *path* from kitchen to last drop-off, not a closed loop.
|
|
def distance_callback(from_index, to_index):
|
|
from_node = manager.IndexToNode(from_index)
|
|
to_node = manager.IndexToNode(to_index)
|
|
if to_node == 0:
|
|
return 0
|
|
return int(dist_matrix[from_node][to_node] * matrix_scale)
|
|
|
|
transit_cb_idx = routing.RegisterTransitCallback(distance_callback)
|
|
routing.SetArcCostEvaluatorOfAllVehicles(transit_cb_idx)
|
|
|
|
# -- TIME-WINDOW DIMENSION (optional) ----------------------------
|
|
if time_windows and len(time_windows) == n_nodes:
|
|
speed_km_per_min = max(0.1, self.avg_speed_kmh / 60.0)
|
|
|
|
def time_callback(from_index, to_index):
|
|
fn = manager.IndexToNode(from_index)
|
|
tn = manager.IndexToNode(to_index)
|
|
if tn == 0:
|
|
return 0
|
|
travel_min = int(dist_matrix[fn][tn] / speed_km_per_min)
|
|
return travel_min + 4 # +4 min avg door time per stop
|
|
|
|
time_cb_idx = routing.RegisterTransitCallback(time_callback)
|
|
max_horizon = 180 # 3-hour window
|
|
|
|
routing.AddDimension(
|
|
time_cb_idx,
|
|
30, # max waiting time at any node (30 min)
|
|
max_horizon,
|
|
False, # don't force start cumul to zero
|
|
"Time",
|
|
)
|
|
time_dim = routing.GetDimensionOrDie("Time")
|
|
|
|
for node_idx, (earliest, latest) in enumerate(time_windows):
|
|
if node_idx == 0:
|
|
continue # depot has no hard window
|
|
idx = manager.NodeToIndex(node_idx)
|
|
time_dim.CumulVar(idx).SetRange(int(earliest), int(latest))
|
|
|
|
# Minimise time at end of route (encourages ASAP delivery)
|
|
time_dim.SetGlobalSpanCostCoefficient(10)
|
|
|
|
# -- SEARCH PARAMETERS --------------------------------------------
|
|
search_params = pywrapcp.DefaultRoutingSearchParameters()
|
|
search_params.first_solution_strategy = (
|
|
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
|
)
|
|
search_params.local_search_metaheuristic = (
|
|
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
|
|
)
|
|
# TSP time limit hard-capped at 2 seconds per kitchen.
|
|
# The ML hypertuner may push search_time_limit_seconds up to 8-10s
|
|
# chasing marginally better routes, but at delivery scale (< 15 stops)
|
|
# OR-Tools finds a near-optimal solution in < 200ms. Waiting 8-10s
|
|
# per kitchen x 3 kitchens x 4 riders = 96s of unnecessary waiting.
|
|
# The VRP already has its own 3s cap. This cap applies to per-rider
|
|
# TSP and the per-kitchen beatmap solves.
|
|
search_params.time_limit.seconds = min(self.search_time_limit_seconds, 2)
|
|
|
|
solution = routing.SolveWithParameters(search_params)
|
|
|
|
if solution:
|
|
index = routing.Start(0)
|
|
route = []
|
|
while not routing.IsEnd(index):
|
|
route.append(manager.IndexToNode(index))
|
|
index = solution.Value(routing.NextVar(index))
|
|
# -- 2-OPT POST-PROCESSING ---------------------------------
|
|
route = self._two_opt_improve(route, dist_matrix)
|
|
return route
|
|
else:
|
|
route = self._solve_greedy(locations, dist_matrix)
|
|
return self._two_opt_improve(route, dist_matrix)
|
|
|
|
# ------------------------------------------------------------------
|
|
# TRUE MULTI-RIDER VRP (Capacitated Pickup-and-Delivery Problem)
|
|
# ------------------------------------------------------------------
|
|
|
|
def solve_vrp_multi_rider(
|
|
self,
|
|
orders: _List[Dict[str, Any]],
|
|
rider_data: _List[Dict[str, Any]], # [{"id": rid, "lat": lat, "lon": lon}, ...]
|
|
max_orders_per_rider: int = 12,
|
|
soft_prefs: Optional[Dict] = None,
|
|
soft_home: Optional[Dict] = None,
|
|
) -> Optional[Dict[int, _List[Dict[str, Any]]]]:
|
|
"""
|
|
Capacitated VRP - assigns all orders to riders simultaneously in a
|
|
single OR-Tools model, with strong load-balancing pressure.
|
|
|
|
KEY FIXES over the previous PDPTW implementation:
|
|
-------------------------------------------------
|
|
1. CAPACITY MODEL (critical fix):
|
|
Old model used +1 at pickup and -1 at delivery (PDPTW).
|
|
Net demand per order = 0 -> solver could put ALL orders on 1 rider
|
|
by interleaving pickup/delivery pairs (capacity never exceeds 1).
|
|
New model: demand = 1 per delivery node only.
|
|
Capacity dimension tracks TOTAL orders assigned per rider (monotonic).
|
|
This correctly enforces max_orders_per_rider as a total limit.
|
|
|
|
2. MINIMUM VEHICLES + NATURAL BALANCE:
|
|
SetFixedCostOfAllVehicles(500_000) penalises each extra vehicle by
|
|
500 km equivalent - far more than any real route saves. Solver uses
|
|
ceil(orders/capacity) riders; distance cost balances loads naturally.
|
|
|
|
3. DETERMINISM:
|
|
Riders sorted by ID so vehicle-0 always maps to the lowest-ID rider,
|
|
regardless of API response ordering.
|
|
SAVINGS first-solution strategy is deterministic for identical inputs.
|
|
|
|
Node layout (total = 1 + N + M):
|
|
+-----------------------------------------------------+
|
|
| 0 : global end-depot (free return) |
|
|
| 1 .. N : rider GPS start positions |
|
|
| N+1..N+M : order delivery nodes (demand = 1 each) |
|
|
+-----------------------------------------------------+
|
|
|
|
Returns {rider_id: [ordered list of order dicts]} or None on failure.
|
|
Falls back silently to None - caller must use 2-phase fallback.
|
|
"""
|
|
if not ORTOOLS_AVAILABLE:
|
|
return None
|
|
if not orders or not rider_data:
|
|
return None
|
|
|
|
try:
|
|
M = len(orders)
|
|
N = len(rider_data)
|
|
|
|
# -- SORT RIDERS FOR DETERMINISM ------------------------------
|
|
# API response order can differ between calls. Sorting by rider ID
|
|
# ensures vehicle-v always maps to the same rider on every call
|
|
# with identical input data.
|
|
rider_data = sorted(rider_data, key=lambda r: r["id"])
|
|
|
|
def _f(v):
|
|
try: return float(v)
|
|
except: return 0.0
|
|
|
|
# -- BUILD NODE COORDINATE LIST -------------------------------
|
|
# We use DELIVERY coordinates for VRP node locations.
|
|
# Riders are scored and routed by where they actually *deliver*,
|
|
# so the solver groups geographically close deliveries together.
|
|
# Kitchen sequencing (visit Kitchen A -> deliver, Kitchen B -> deliver)
|
|
# is handled downstream by beatmap_route / TSP.
|
|
# Kitchen *affinity* is handled by the preference-discount matrix below:
|
|
# preferred riders get a cost reduction on orders from their kitchen,
|
|
# which steers the solver to assign them those orders even if a
|
|
# non-preferred rider is marginally closer by delivery location.
|
|
end_depot = (0.0, 0.0) # dummy; cost to return here = 0
|
|
rider_locs = [(r["lat"], r["lon"]) for r in rider_data]
|
|
|
|
order_locs = []
|
|
for o in orders:
|
|
dlat = _f(o.get("deliverylat") or o.get("droplat"))
|
|
dlon = _f(o.get("deliverylong") or o.get("droplon"))
|
|
if dlat == 0: # fallback to pickup coords if delivery missing
|
|
dlat = _f(o.get("pickuplat"))
|
|
dlon = _f(o.get("pickuplon") or o.get("pickuplong"))
|
|
order_locs.append((dlat, dlon))
|
|
|
|
all_locs = [end_depot] + rider_locs + order_locs
|
|
total_nodes = len(all_locs) # = 1 + N + M
|
|
|
|
# -- DISTANCE MATRIX ------------------------------------------
|
|
lats = np.array([loc[0] for loc in all_locs])
|
|
lons = np.array([loc[1] for loc in all_locs])
|
|
dist_m = (
|
|
calculate_haversine_matrix_vectorized(lats, lons) * self.road_factor
|
|
)
|
|
|
|
# -- PREFERENCE DISCOUNT MATRIX -------------------------------
|
|
# Build a per-rider discount (in metres) for orders from preferred
|
|
# kitchens. A negative arc-cost delta is achieved by reducing that
|
|
# rider's cost to reach preferred-kitchen delivery nodes.
|
|
#
|
|
# Discount = 3 000 m ~ 3 km equivalent, so a preferred rider
|
|
# up to ~3 km farther than a non-preferred rider still wins the bid.
|
|
# Home-zone bonus adds another 2 000 m for riders within 4 km of
|
|
# the kitchen's home area.
|
|
from app.config.rider_preferences import (
|
|
RIDER_PREFERRED_KITCHENS,
|
|
RIDER_HOME_LOCATIONS,
|
|
)
|
|
# SOFT steering uses learned-augmented affinity (config learned);
|
|
# the HARD kitchen constraint below stays on curated config only.
|
|
try:
|
|
if soft_prefs is not None and soft_home is not None:
|
|
_soft_prefs = soft_prefs
|
|
_soft_home = soft_home
|
|
else:
|
|
from app.services.routing.rider_affinity_service import get_rider_affinity
|
|
_aff = get_rider_affinity()
|
|
_soft_prefs = _aff.get_preferred_kitchens()
|
|
_soft_home = _aff.get_home_locations()
|
|
except Exception:
|
|
_soft_prefs = soft_prefs if soft_prefs is not None else RIDER_PREFERRED_KITCHENS
|
|
_soft_home = soft_home if soft_home is not None else RIDER_HOME_LOCATIONS
|
|
|
|
PREF_DISCOUNT_M = 3_000 # 3 km equivalent discount for preferred kitchen
|
|
HOME_4KM_BONUS_M = 2_000 # extra 2 km for rider within home zone (4 km)
|
|
HOME_2KM_BONUS_M = 4_000 # extra 4 km for rider very close to home zone
|
|
|
|
def _haversine_m(la1, lo1, la2, lo2):
|
|
import math
|
|
la1,lo1,la2,lo2 = map(math.radians,[float(la1),float(lo1),float(la2),float(lo2)])
|
|
a = math.sin((la2-la1)/2)**2 + math.cos(la1)*math.cos(la2)*math.sin((lo2-lo1)/2)**2
|
|
return 6_371_000 * 2 * math.asin(min(1.0, math.sqrt(a)))
|
|
|
|
# Precompute kitchen name for each order node.
|
|
# Field priority (confirmed from real order payload):
|
|
# pickupcustomer -> "Daily grubs(jayanthi kitchen)" primary
|
|
# locationname -> "Daily grubs(jayanthi kitchen)" same value, backup
|
|
# Legacy / alternate API versions also checked below.
|
|
_KITCHEN_KEYS = [
|
|
"pickupcustomer", # confirmed in production order JSON
|
|
"locationname", # confirmed in production order JSON
|
|
"storename", "store_name",
|
|
"restaurantname", "restaurant_name",
|
|
"kitchenname", "kitchen_name",
|
|
"partnername", "partner_name",
|
|
"tenantname", # "Daily grubs" (without branch suffix)
|
|
"brandname", "brand_name",
|
|
"providername", "provider_name",
|
|
"shopname", "shop_name",
|
|
]
|
|
order_kitchens = []
|
|
for o in orders:
|
|
kitchen = ""
|
|
for _k in _KITCHEN_KEYS:
|
|
_v = o.get(_k)
|
|
if _v and str(_v).strip():
|
|
kitchen = str(_v).strip().lower()
|
|
break
|
|
order_kitchens.append(kitchen)
|
|
|
|
# Log unique kitchen names found so user can verify they match prefs
|
|
unique_kitchens = sorted(set(k for k in order_kitchens if k))
|
|
logger.info(
|
|
f"[VRP] Kitchen names in orders ({len(unique_kitchens)} unique): {unique_kitchens}"
|
|
)
|
|
|
|
# Build discount array: discount_m[v][order_i]
|
|
# Node index for order_i in all_locs = 1 + N + order_i
|
|
discount_m = [[0] * M for _ in range(N)]
|
|
for v, rd in enumerate(rider_data):
|
|
rid = rd["id"]
|
|
prefs = [p.lower() for p in _soft_prefs.get(rid, [])]
|
|
h_lat, h_lon = _soft_home.get(rid, (0.0, 0.0))
|
|
|
|
for i, kitchen in enumerate(order_kitchens):
|
|
if not kitchen or not prefs:
|
|
continue
|
|
# Bidirectional substring match (mirrors AssignmentService logic)
|
|
if any(p in kitchen or kitchen in p for p in prefs):
|
|
disc = PREF_DISCOUNT_M
|
|
# Tiebreak when two riders prefer the same kitchen:
|
|
# give extra discount to the rider whose home zone
|
|
# is closest to the delivery location.
|
|
if h_lat != 0:
|
|
dlat, dlon = order_locs[i] # delivery coords
|
|
if dlat != 0:
|
|
home_dist_m = _haversine_m(h_lat, h_lon, dlat, dlon)
|
|
if home_dist_m <= 2_000:
|
|
disc += HOME_2KM_BONUS_M
|
|
elif home_dist_m <= 4_000:
|
|
disc += HOME_4KM_BONUS_M
|
|
discount_m[v][i] = disc
|
|
|
|
# -- OR-TOOLS MODEL -------------------------------------------
|
|
starts = list(range(1, N + 1)) # each rider starts at its own node
|
|
ends = [0] * N # all end at dummy depot (free)
|
|
|
|
manager = pywrapcp.RoutingIndexManager(total_nodes, N, starts, ends)
|
|
routing = pywrapcp.RoutingModel(manager)
|
|
|
|
# Per-vehicle distance callbacks - each rider gets a preference discount
|
|
# on delivery nodes belonging to their preferred kitchens.
|
|
# OR-Tools requires one registered callback per vehicle when costs differ.
|
|
def make_pref_cb(v_idx, disc_row):
|
|
def cb(fi, ti):
|
|
fn = manager.IndexToNode(fi)
|
|
tn = manager.IndexToNode(ti)
|
|
if tn == 0:
|
|
return 0
|
|
base = int(dist_m[fn][tn] * 1000)
|
|
# Apply discount if destination is a delivery node
|
|
order_i = tn - (1 + N)
|
|
if 0 <= order_i < M:
|
|
base = max(0, base - disc_row[order_i])
|
|
return base
|
|
return cb
|
|
|
|
for v in range(N):
|
|
cb_fn = make_pref_cb(v, discount_m[v])
|
|
cb_idx = routing.RegisterTransitCallback(cb_fn)
|
|
routing.SetArcCostEvaluatorOfVehicle(cb_idx, v)
|
|
|
|
# -- HARD KITCHEN CONSTRAINT ----------------------------------
|
|
# rider_preferences.py is the source of truth.
|
|
# If a kitchen has at least one configured rider in the active
|
|
# fleet, ONLY those riders are allowed to visit those order nodes.
|
|
# Non-preferred riders are physically excluded by the solver.
|
|
#
|
|
# Fallback: if every preferred rider for a kitchen is absent from
|
|
# the active fleet, the order is left unconstrained (any rider).
|
|
# This prevents unsolvable models when a preferred rider is off-duty.
|
|
|
|
# Build inverse map: kitchen_name_lower -> [vehicle_index, ...]
|
|
kitchen_to_vehicles: dict = {}
|
|
for v, rd in enumerate(rider_data):
|
|
rid = rd["id"]
|
|
for pref_k in RIDER_PREFERRED_KITCHENS.get(rid, []):
|
|
kitchen_to_vehicles.setdefault(pref_k.lower(), []).append(v)
|
|
|
|
_hard_applied = 0
|
|
for i, kitchen in enumerate(order_kitchens):
|
|
if not kitchen:
|
|
continue
|
|
allowed_v: set = set()
|
|
for cfg_k, vehicles in kitchen_to_vehicles.items():
|
|
if cfg_k in kitchen or kitchen in cfg_k:
|
|
allowed_v.update(vehicles)
|
|
if allowed_v:
|
|
# OR-Tools hard constraint: solver cannot assign this order
|
|
node_idx = manager.NodeToIndex(1 + N + i)
|
|
try:
|
|
routing.SetAllowedVehiclesForIndex(list(allowed_v), node_idx)
|
|
except TypeError:
|
|
# Fallback for OR-Tools wrapper bug with absl::Span<int const>
|
|
routing.VehicleVar(node_idx).SetValues([int(x) for x in allowed_v])
|
|
_hard_applied += 1
|
|
|
|
logger.info(
|
|
f"[VRP] Hard constraints: {_hard_applied}/{M} orders locked to "
|
|
f"their configured riders. "
|
|
f"({M - _hard_applied} orders unconstrained / no pref configured)"
|
|
)
|
|
|
|
has_prefs = any(any(d > 0 for d in row) for row in discount_m)
|
|
riders_with_prefs = sum(1 for row in discount_m if any(d > 0 for d in row))
|
|
logger.info(
|
|
f"[VRP] Preference discounts applied: {has_prefs} | "
|
|
f"riders with prefs: {riders_with_prefs}/{N}"
|
|
)
|
|
if not has_prefs:
|
|
active_ids = sorted(rd["id"] for rd in rider_data)
|
|
missing_ids = [rid for rid in active_ids if rid not in RIDER_PREFERRED_KITCHENS]
|
|
if missing_ids:
|
|
# IDs are not registered at all
|
|
logger.warning(
|
|
f"[VRP] ! NO preference discounts - rider IDs not in config!\n"
|
|
f" Active IDs not configured : {missing_ids}\n"
|
|
f" -> Add them to RIDER_PREFERRED_KITCHENS in "
|
|
f"app/config/rider_preferences.py"
|
|
)
|
|
else:
|
|
# IDs are fine but kitchen NAME in orders doesn't match configured prefs
|
|
config_kitchens = sorted(set(
|
|
k.lower()
|
|
for prefs in RIDER_PREFERRED_KITCHENS.values()
|
|
for k in prefs
|
|
))
|
|
logger.warning(
|
|
f"[VRP] ! NO preference discounts - kitchen NAMES don't match!\n"
|
|
f" Order kitchen names : {unique_kitchens}\n"
|
|
f" Configured prefs : {config_kitchens}\n"
|
|
f" -> These must overlap (substring match). "
|
|
f"Edit RIDER_PREFERRED_KITCHENS in rider_preferences.py "
|
|
f"to use the exact names shown in 'Order kitchen names' above."
|
|
)
|
|
|
|
# -- CAPACITY DIMENSION ---------------------------------------
|
|
# demand[delivery_node] = 1 (each order = 1 unit).
|
|
# Monotonically increases as rider picks up more orders.
|
|
# At max_orders_per_rider the rider is full - solver must use another.
|
|
demands = [0] * total_nodes
|
|
for i in range(M):
|
|
demands[1 + N + i] = 1 # delivery node i
|
|
|
|
def demand_cb(fi):
|
|
return demands[manager.IndexToNode(fi)]
|
|
|
|
dem_idx = routing.RegisterUnaryTransitCallback(demand_cb)
|
|
routing.AddDimensionWithVehicleCapacity(
|
|
dem_idx,
|
|
0, # no slack
|
|
[max_orders_per_rider] * N, # hard cap per rider
|
|
True, # start cumul at 0
|
|
"Capacity",
|
|
)
|
|
|
|
# -- LOAD BALANCING via Capacity span -------------------------
|
|
# SetGlobalSpanCostCoefficient on the CAPACITY dimension adds
|
|
# (max_load - min_load) * coeff to the objective, pushing GLS
|
|
# to redistribute orders from heavy riders to light riders.
|
|
#
|
|
# Why it is SAFE here (unlike on the distance dimension):
|
|
# Fixed vehicle cost = 500,000 m
|
|
# Max span possible = max_orders_per_rider = 12 orders
|
|
# Max span saving = 12 x 3,000 = 36,000 m
|
|
# 36,000 << 500,000 -> adding an empty rider ALWAYS costs
|
|
# +464,000 m net -> solver will NEVER activate an extra rider
|
|
# just to reduce span. Balance happens only among the
|
|
# ceil(orders/cap) riders already chosen by the fixed cost.
|
|
#
|
|
# Effect: GLS will accept moving an order to a lighter rider
|
|
# even if that rider is up to ~3 km farther, as long as doing
|
|
# so reduces the max-min imbalance by at least 1 order.
|
|
cap_dim = routing.GetDimensionOrDie("Capacity")
|
|
cap_dim.SetGlobalSpanCostCoefficient(3_000)
|
|
|
|
# -- MINIMUM VEHICLES (force fewest riders) -------------------
|
|
# Each vehicle activated adds 500 km to the objective.
|
|
# 500 km >> any realistic multi-stop route (~ 60 km max),
|
|
# so the solver always uses ceil(orders / capacity) riders.
|
|
routing.SetFixedCostOfAllVehicles(500_000)
|
|
|
|
# -- SEARCH PARAMETERS ----------------------------------------
|
|
sp = pywrapcp.DefaultRoutingSearchParameters()
|
|
# PATH_CHEAPEST_ARC: works correctly for multi-depot VRP where
|
|
# each vehicle starts at a different location (rider position).
|
|
# SAVINGS (Clarke-Wright) requires a single shared depot and
|
|
# returns None for multi-depot inputs - do NOT use SAVINGS here.
|
|
sp.first_solution_strategy = (
|
|
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
|
|
)
|
|
sp.local_search_metaheuristic = (
|
|
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
|
|
)
|
|
# VRP time budget: cap at 3 seconds so the API response is never
|
|
# blocked longer than that. The x 3 multiplier caused 15-second
|
|
# responses with the default 5-second search_time_limit.
|
|
# PATH_CHEAPEST_ARC typically finds a good solution in < 1 second
|
|
# for our typical sizes (<= 15 riders, <= 60 orders); GLS then
|
|
# improves it within the remaining budget.
|
|
sp.time_limit.seconds = min(self.search_time_limit_seconds, 3)
|
|
|
|
solution = routing.SolveWithParameters(sp)
|
|
|
|
if not solution:
|
|
logger.warning("[VRP] No solution found - falling back to 2-phase assignment")
|
|
return None
|
|
|
|
# -- EXTRACT & RETURN ASSIGNMENTS -----------------------------
|
|
rider_assignments: Dict[int, _List[Dict[str, Any]]] = {}
|
|
for v in range(N):
|
|
rider_id = rider_data[v]["id"]
|
|
rider_route_orders: _List[Dict[str, Any]] = []
|
|
|
|
idx = routing.Start(v)
|
|
while not routing.IsEnd(idx):
|
|
node = manager.IndexToNode(idx)
|
|
idx = solution.Value(routing.NextVar(idx))
|
|
|
|
# Delivery nodes are indices N+1 to N+M
|
|
if node >= 1 + N:
|
|
order_i = node - (1 + N)
|
|
if 0 <= order_i < M:
|
|
rider_route_orders.append(orders[order_i])
|
|
|
|
if rider_route_orders:
|
|
rider_assignments[rider_id] = rider_route_orders
|
|
|
|
assigned = sum(len(v) for v in rider_assignments.values())
|
|
load_counts = sorted([len(v) for v in rider_assignments.values()], reverse=True)
|
|
logger.info(
|
|
f"[VRP] Solution: {assigned}/{M} orders -> "
|
|
f"{len(rider_assignments)}/{N} riders | "
|
|
f"distribution: {load_counts}"
|
|
)
|
|
return rider_assignments
|
|
|
|
except Exception as exc:
|
|
logger.error(f"[VRP] Solver error: {exc}", exc_info=True)
|
|
return None
|
|
|
|
def _solve_greedy(self, locations, dist_matrix):
|
|
"""Simple Greedy Nearest Neighbor fallback."""
|
|
unvisited = set(range(1, len(locations)))
|
|
curr = 0
|
|
route = [0]
|
|
while unvisited:
|
|
nearest = min(unvisited, key=lambda x: dist_matrix[curr][x])
|
|
route.append(nearest)
|
|
unvisited.remove(nearest)
|
|
curr = nearest
|
|
return route
|
|
|
|
def _cleanup_coords(
|
|
self, lat: Any, lon: Any, ref_lat: float, ref_lon: float
|
|
) -> Tuple[float, float]:
|
|
"""
|
|
Heuristic to fix bad coordinates.
|
|
1. Fixes lat==lon typo.
|
|
2. Fixes missing negative signs if needed (not needed for India).
|
|
3. Projects outlier > 500km to reference (centroid).
|
|
"""
|
|
try:
|
|
lat = float(lat)
|
|
lon = float(lon)
|
|
except:
|
|
return 0.0, 0.0
|
|
|
|
if lat == 0 or lon == 0:
|
|
return lat, lon
|
|
|
|
# 1. Check strict equality (typo)
|
|
if abs(lat - lon) < 0.0001:
|
|
if ref_lon != 0:
|
|
# If reference is available, assume lat is correct and fix lon
|
|
# (Common error: copy lat to lon field)
|
|
return lat, ref_lon
|
|
|
|
# 2. Check general outlier (e.g. 500km away)
|
|
if ref_lat != 0 and ref_lon != 0:
|
|
dist = self.haversine_distance(lat, lon, ref_lat, ref_lon)
|
|
if dist > 500:
|
|
# Returning reference prevents map explosion
|
|
return ref_lat, ref_lon
|
|
|
|
return lat, lon
|
|
|
|
async def optimize_provider_payload(
|
|
self, orders: _List[Dict[str, Any]], start_coords: Optional[tuple] = None
|
|
) -> _List[Dict[str, Any]]:
|
|
"""Optimize delivery route and add step metrics (OR-Tools)."""
|
|
if not orders:
|
|
return []
|
|
|
|
# Deep copy
|
|
orders = [dict(order) for order in orders]
|
|
|
|
# 0. KALMAN FILTER - Smooth noisy delivery GPS coordinates
|
|
orders = smooth_order_coordinates(orders)
|
|
|
|
# Helpers
|
|
def _to_float(v: Any) -> float:
|
|
try:
|
|
return float(v)
|
|
except:
|
|
return 0.0
|
|
|
|
def _normalize_dt(val: Any) -> str:
|
|
if val in (None, "", 0):
|
|
return ""
|
|
s = str(val).strip()
|
|
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S"):
|
|
try:
|
|
return datetime.strptime(s, fmt).strftime("%Y-%m-%d %H:%M:%S")
|
|
except:
|
|
pass
|
|
return s
|
|
|
|
# 1. PREPARE COORDINATES & CENTROID
|
|
valid_lats = []
|
|
valid_lons = []
|
|
|
|
for o in orders:
|
|
lat = _to_float(o.get("deliverylat"))
|
|
lon = _to_float(o.get("deliverylong"))
|
|
if lat != 0 and lon != 0:
|
|
valid_lats.append(lat)
|
|
valid_lons.append(lon)
|
|
|
|
centroid_lat = sum(valid_lats) / len(valid_lats) if valid_lats else 0.0
|
|
centroid_lon = sum(valid_lons) / len(valid_lons) if valid_lons else 0.0
|
|
|
|
# 2. DETERMINE START LOCATION (With Fix)
|
|
start_lat, start_lon = 0.0, 0.0
|
|
|
|
# Try explicit start_coords first
|
|
if start_coords and len(start_coords) == 2:
|
|
try:
|
|
start_lat, start_lon = float(start_coords[0]), float(start_coords[1])
|
|
except:
|
|
pass
|
|
|
|
# Fallback to pickup location in orders
|
|
if start_lat == 0:
|
|
for o in orders:
|
|
plat = _to_float(o.get("pickuplat"))
|
|
plon = _to_float(o.get("pickuplon") or o.get("pickuplong"))
|
|
if plat != 0:
|
|
start_lat, start_lon = plat, plon
|
|
break
|
|
|
|
# Fallback to centroid
|
|
if start_lat == 0:
|
|
start_lat, start_lon = centroid_lat, centroid_lon
|
|
|
|
# FIX BAD START COORDINATES
|
|
start_lat, start_lon = self._cleanup_coords(
|
|
start_lat, start_lon, centroid_lat, centroid_lon
|
|
)
|
|
|
|
# 3. BUILD LOCATIONS LIST FOR SOLVER
|
|
# Index 0 is Start (Depot), 1..N are orders
|
|
locations = [(start_lat, start_lon)]
|
|
points_map = [] # solver_idx-1 -> original order index
|
|
order_to_delivery_loc = {} # original order index -> (lat, lon)
|
|
|
|
for idx, order in enumerate(orders):
|
|
lat = _to_float(order.get("deliverylat"))
|
|
lon = _to_float(order.get("deliverylong"))
|
|
|
|
# Project coordinates and ensure they are strings for Go compatibility
|
|
lat, lon = self._cleanup_coords(lat, lon, centroid_lat, centroid_lon)
|
|
order_str_lat, order_str_lon = str(lat), str(lon)
|
|
|
|
order["deliverylat"] = order_str_lat
|
|
order["deliverylong"] = order_str_lon
|
|
if "droplat" in order:
|
|
order["droplat"] = order_str_lat
|
|
if "droplon" in order:
|
|
order["droplon"] = order_str_lon
|
|
|
|
locations.append((lat, lon))
|
|
points_map.append(idx)
|
|
order_to_delivery_loc[idx] = (lat, lon)
|
|
|
|
# 4. COMPUTE DISTANCE MATRICES
|
|
lats = np.array([loc[0] for loc in locations])
|
|
lons = np.array([loc[1] for loc in locations])
|
|
# aerial_matrix - pure straight-line km; used for step ORDERING.
|
|
# Road-distance ordering causes non-intuitive sequences: a delivery
|
|
# that is physically 200 m away can appear "far" because the road
|
|
# route loops around. Riders handle U-turns themselves - we just
|
|
# tell them the nearest unvisited point by crow-flies distance.
|
|
# dist_matrix - aerial x road_factor; used for ETA/cost analytics only.
|
|
aerial_matrix = calculate_haversine_matrix_vectorized(lats, lons)
|
|
dist_matrix = aerial_matrix * self.road_factor
|
|
|
|
traffic = self.get_traffic_condition()
|
|
|
|
# 5b. MULTI-KITCHEN BEATMAP DETECTION
|
|
# If a rider's orders come from more than one kitchen, use kitchen-aware
|
|
# sequencing: visit Kitchen A -> deliver A's orders -> Kitchen B -> deliver B's.
|
|
# Only count real kitchens (exclude the no-pickup-coords bucket).
|
|
kitchen_groups = self._group_by_kitchen(orders, _to_float)
|
|
real_kitchen_count = sum(1 for k in kitchen_groups if k[0] != "?")
|
|
if real_kitchen_count > 1:
|
|
logger.info(
|
|
f"Beatmap routing: {len(orders)} orders across {len(kitchen_groups)} kitchens"
|
|
)
|
|
return await self._beatmap_route(
|
|
orders, kitchen_groups, order_to_delivery_loc,
|
|
start_lat, start_lon, _to_float, _normalize_dt
|
|
)
|
|
|
|
# 6. EXTRACT TIME WINDOWS (one per node, index 0 = depot/kitchen)
|
|
# -- Why here and not in the solver? -------------------------------
|
|
# pickupSlot / pickuptime is an ORDER-level field that the TSP solver
|
|
# doesn't know about by itself. We convert them to minutes-from-now
|
|
# so OR-Tools can enforce delivery deadlines without external API calls.
|
|
# ------------------------------------------------------------------
|
|
time_windows: Optional[_List[Tuple[int, int]]] = None
|
|
try:
|
|
from dateutil.parser import parse as _parse_dt
|
|
_now = datetime.now()
|
|
tw = [(0, 180)] # depot (kitchen): always open within 3-hour horizon
|
|
has_any_window = False
|
|
for order in orders:
|
|
slot = (
|
|
order.get("pickupSlot") or order.get("pickupslot")
|
|
or order.get("pickuptime") or order.get("pickup_slot")
|
|
)
|
|
if slot:
|
|
try:
|
|
t = _parse_dt(str(slot))
|
|
# Earliest: rider shouldn't deliver before kitchen is ready
|
|
# Latest: food should be delivered within 45 min of ready
|
|
ready_min = max(0, int((t - _now).total_seconds() / 60))
|
|
tw.append((0, ready_min + 45))
|
|
has_any_window = True
|
|
except Exception:
|
|
tw.append((0, 180))
|
|
else:
|
|
tw.append((0, 180))
|
|
if has_any_window:
|
|
time_windows = tw
|
|
logger.debug(f"Time windows active for {sum(1 for w in tw if w[1] < 180)} orders")
|
|
except Exception as _twe:
|
|
logger.debug(f"Time window extraction skipped: {_twe}")
|
|
|
|
# 6b. STEP ORDERING
|
|
# Prefer a road-aware visiting order (Google optimize:true) when enabled;
|
|
# it respects one-ways/turn restrictions that straight-line ordering can't.
|
|
# Fall back to aerial greedy nearest-neighbour + 2-opt (the default): greedy
|
|
# picks the closest unvisited stop, 2-opt removes crossing segments. Step
|
|
# metrics + ETA below stay aerial-based regardless, so the empirical ETA
|
|
# model (keyed on aerial buckets) remains valid.
|
|
optimized_order_indices = None
|
|
road_order = await self._road_optimal_order(
|
|
start_lat, start_lon, [locations[i] for i in range(1, len(locations))]
|
|
)
|
|
if road_order is not None:
|
|
# road_order indexes into deliveries (0-based); solver index = wp + 1
|
|
optimized_order_indices = [wp + 1 for wp in road_order]
|
|
else:
|
|
route_indices = self._solve_greedy(locations, aerial_matrix)
|
|
route_indices = self._two_opt_improve(route_indices, aerial_matrix)
|
|
optimized_order_indices = [i for i in route_indices if i != 0]
|
|
|
|
# 7. BUILD RESULT
|
|
result = []
|
|
cumulative_dist = 0.0
|
|
cumulative_eta_min = 0 # total minutes from kitchen -> current delivery
|
|
prev_idx = 0 # starts at depot (kitchen / start location)
|
|
|
|
for step_num, solver_idx in enumerate(optimized_order_indices, start=1):
|
|
order_idx = points_map[solver_idx - 1]
|
|
order = dict(orders[order_idx])
|
|
|
|
# Clean routing fields (will be recalculated)
|
|
for k in ("step", "previouskms", "cumulativekms", "eta", "actualkms", "ordertype"):
|
|
order.pop(k, None)
|
|
|
|
# Normalize dates
|
|
for field in ["orderdate", "deliverytime", "created"]:
|
|
if field in order:
|
|
order[field] = _normalize_dt(order.get(field))
|
|
|
|
# Leg distance - aerial km (matches the ordering metric)
|
|
step_dist = float(aerial_matrix[prev_idx][solver_idx])
|
|
cumulative_dist += step_dist
|
|
|
|
# Step metadata
|
|
order["step"] = int(step_num)
|
|
order["previouskms"] = int(round(step_dist)) # Bug fix: was hardcoded 0 for step 1
|
|
order["cumulativekms"] = int(round(cumulative_dist))
|
|
|
|
# actualkms = direct pickup-to-door distance (for billing)
|
|
plat, plon = start_lat, start_lon
|
|
if plat == 0:
|
|
plat, plon = (
|
|
_to_float(order.get("pickuplat")),
|
|
_to_float(order.get("pickuplon") or order.get("pickuplong")),
|
|
)
|
|
dlat, dlon = locations[solver_idx]
|
|
true_dist = self.haversine_distance(plat, plon, dlat, dlon) * 1.3
|
|
|
|
provided_kms = order.get("kms")
|
|
if provided_kms not in (None, "", 0, "0"):
|
|
try:
|
|
true_dist = float(provided_kms)
|
|
except:
|
|
pass
|
|
|
|
order["actualkms"] = str(round(true_dist, 2))
|
|
order["kms"] = str(provided_kms) if provided_kms else str(int(round(true_dist)))
|
|
|
|
if "rider_charge" in order:
|
|
order["rider_charge"] = round(float(order["rider_charge"]), 2)
|
|
if "profit" in order:
|
|
order["profit"] = round(float(order["profit"]), 2)
|
|
|
|
order["ordertype"] = (
|
|
"Economy" if true_dist <= 5
|
|
else "Premium" if true_dist <= 12
|
|
else "Risky"
|
|
)
|
|
|
|
leg_eta = self.eta_calculator.calculate_eta(
|
|
distance_km=step_dist,
|
|
is_first_order=(step_num == 1),
|
|
order_type=order["ordertype"],
|
|
time_of_day=traffic,
|
|
kitchen=order.get("pickupcustomer") or order.get("locationname"),
|
|
drop_coords=(dlat, dlon),
|
|
rider_id=order.get("userid"),
|
|
)
|
|
# -- CUMULATIVE ETA --------------------------------------------
|
|
# `eta` = leg time from previous stop (legacy field, kept)
|
|
# `cumulative_eta` = total time from kitchen -> THIS delivery
|
|
# Customers should use cumulative_eta for "when does my food arrive"
|
|
# -------------------------------------------------------------
|
|
cumulative_eta_min += leg_eta
|
|
order["eta"] = str(leg_eta)
|
|
order["cumulative_eta"] = str(cumulative_eta_min)
|
|
|
|
result.append(order)
|
|
prev_idx = solver_idx
|
|
|
|
return result
|
|
|
|
# ------------------------------------------------------------------
|
|
# Multi-kitchen beatmap helpers
|
|
# ------------------------------------------------------------------
|
|
|
|
def _group_by_kitchen(self, orders: _List[Dict], _to_float) -> Dict[tuple, _List[int]]:
|
|
"""
|
|
Group order indices by their kitchen (pickup) location.
|
|
Returns {(rounded_lat, rounded_lon): [order_idx, ...]}
|
|
Keys rounded to ~100 m so nearby pickup points merge into one kitchen.
|
|
"""
|
|
from collections import defaultdict
|
|
groups: Dict[tuple, _List[int]] = defaultdict(list)
|
|
for idx, order in enumerate(orders):
|
|
plat = _to_float(order.get("pickuplat"))
|
|
plon = _to_float(order.get("pickuplon") or order.get("pickuplong"))
|
|
if plat == 0 or plon == 0:
|
|
# No pickup coords - append to a special "no-kitchen" bucket
|
|
groups[("?", "?")].append(idx)
|
|
else:
|
|
key = (round(plat, 3), round(plon, 3))
|
|
groups[key].append(idx)
|
|
return dict(groups)
|
|
|
|
async def _beatmap_route(
|
|
self,
|
|
orders: _List[Dict],
|
|
kitchen_groups: Dict[tuple, _List[int]],
|
|
order_to_delivery_loc: Dict[int, tuple],
|
|
start_lat: float,
|
|
start_lon: float,
|
|
_to_float,
|
|
_normalize_dt,
|
|
) -> _List[Dict]:
|
|
"""
|
|
Multi-kitchen beatmap: for each kitchen (nearest first) sequence the
|
|
deliveries by aerial nearest-neighbour, then concatenate.
|
|
|
|
Sequencing: greedy nearest unvisited stop by straight-line km.
|
|
Riders handle U-turns; we just point them to the closest next drop.
|
|
|
|
Flow: Kitchen A -> A1 -> A2 -> Kitchen B -> B1 -> B2 ->
|
|
Steps are numbered continuously across all kitchens.
|
|
"""
|
|
result: _List[Dict] = []
|
|
global_step = 1
|
|
cumulative_dist = 0.0
|
|
cumulative_eta_min = 0 # total minutes from start -> current delivery
|
|
traffic = self.get_traffic_condition()
|
|
|
|
# Separate kitchens with real coords from the no-kitchen bucket
|
|
real_kitchens = {k: v for k, v in kitchen_groups.items() if k[0] != "?"}
|
|
no_kitchen_indices = kitchen_groups.get(("?", "?"), [])
|
|
|
|
# Visit kitchens: nearest to start first, then nearest to last delivery
|
|
remaining = list(real_kitchens.items())
|
|
current_pos = (start_lat, start_lon)
|
|
|
|
while remaining:
|
|
# Find nearest unvisited kitchen from current position
|
|
best_i = min(
|
|
range(len(remaining)),
|
|
key=lambda i: self.haversine_distance(
|
|
current_pos[0], current_pos[1],
|
|
remaining[i][0][0], remaining[i][0][1]
|
|
),
|
|
)
|
|
k_key, k_order_indices = remaining.pop(best_i)
|
|
k_lat, k_lon = float(k_key[0]), float(k_key[1])
|
|
|
|
# Build mini location list: kitchen at index 0, deliveries at 1..N
|
|
k_locs = [(k_lat, k_lon)]
|
|
k_idx_to_order = [] # k_solver_idx-1 -> original order idx
|
|
for order_idx in k_order_indices:
|
|
dlat, dlon = order_to_delivery_loc.get(order_idx, (0.0, 0.0))
|
|
k_locs.append((dlat, dlon))
|
|
k_idx_to_order.append(order_idx)
|
|
|
|
# Pure aerial distance matrix for this kitchen group
|
|
k_lats = np.array([loc[0] for loc in k_locs])
|
|
k_lons = np.array([loc[1] for loc in k_locs])
|
|
k_aerial = calculate_haversine_matrix_vectorized(k_lats, k_lons)
|
|
|
|
# Road-aware order for this kitchen's drops (opt-in, cached);
|
|
# else aerial greedy nearest-neighbour + 2-opt to remove crossings.
|
|
k_road = await self._road_optimal_order(
|
|
k_lat, k_lon, [k_locs[i] for i in range(1, len(k_locs))]
|
|
)
|
|
if k_road is not None:
|
|
k_delivery_seq = [wp + 1 for wp in k_road]
|
|
else:
|
|
k_route = self._solve_greedy(k_locs, k_aerial)
|
|
k_route = self._two_opt_improve(k_route, k_aerial)
|
|
k_delivery_seq = [i for i in k_route if i != 0]
|
|
|
|
k_prev_idx = 0 # start from kitchen
|
|
for k_solver_idx in k_delivery_seq:
|
|
order_idx = k_idx_to_order[k_solver_idx - 1]
|
|
order = dict(orders[order_idx])
|
|
|
|
# Clean routing fields
|
|
for fld in ("step", "previouskms", "cumulativekms", "eta", "actualkms", "ordertype"):
|
|
order.pop(fld, None)
|
|
for field in ["orderdate", "deliverytime", "created"]:
|
|
if field in order:
|
|
order[field] = _normalize_dt(order.get(field))
|
|
|
|
# Aerial leg distance (consistent with ordering metric)
|
|
step_dist = float(k_aerial[k_prev_idx][k_solver_idx])
|
|
cumulative_dist += step_dist
|
|
|
|
dlat, dlon = k_locs[k_solver_idx]
|
|
true_dist = self.haversine_distance(k_lat, k_lon, dlat, dlon) * 1.3
|
|
provided_kms = order.get("kms")
|
|
if provided_kms not in (None, "", 0, "0"):
|
|
try:
|
|
true_dist = float(provided_kms)
|
|
except Exception:
|
|
pass
|
|
|
|
order["step"] = global_step
|
|
order["previouskms"] = int(round(step_dist))
|
|
order["cumulativekms"] = int(round(cumulative_dist))
|
|
order["actualkms"] = str(round(true_dist, 2))
|
|
order["kms"] = str(provided_kms) if provided_kms else str(int(round(true_dist)))
|
|
|
|
if "rider_charge" in order:
|
|
order["rider_charge"] = round(float(order["rider_charge"]), 2)
|
|
if "profit" in order:
|
|
order["profit"] = round(float(order["profit"]), 2)
|
|
|
|
order["ordertype"] = (
|
|
"Economy" if true_dist <= 5
|
|
else "Premium" if true_dist <= 12
|
|
else "Risky"
|
|
)
|
|
leg_eta = self.eta_calculator.calculate_eta(
|
|
distance_km=step_dist,
|
|
is_first_order=(global_step == 1),
|
|
order_type=order["ordertype"],
|
|
time_of_day=traffic,
|
|
kitchen=order.get("pickupcustomer") or order.get("locationname"),
|
|
drop_coords=(dlat, dlon),
|
|
rider_id=order.get("userid"),
|
|
)
|
|
cumulative_eta_min += leg_eta
|
|
order["eta"] = str(leg_eta)
|
|
order["cumulative_eta"] = str(cumulative_eta_min)
|
|
|
|
result.append(order)
|
|
k_prev_idx = k_solver_idx
|
|
global_step += 1
|
|
|
|
# Next kitchen search starts from the last delivery of this kitchen
|
|
if k_delivery_seq:
|
|
current_pos = k_locs[k_delivery_seq[-1]]
|
|
|
|
# Append orders with no pickup coords at the end
|
|
for order_idx in no_kitchen_indices:
|
|
order = dict(orders[order_idx])
|
|
for fld in ("step", "previouskms", "cumulativekms", "eta", "actualkms", "ordertype", "cumulative_eta"):
|
|
order.pop(fld, None)
|
|
order["step"] = global_step
|
|
order["previouskms"] = 0
|
|
order["cumulativekms"] = int(round(cumulative_dist))
|
|
order["actualkms"] = "0"
|
|
# Bug fix: kms was missing for no-kitchen orders; set it consistently
|
|
# with the normal path so downstream consumers always find the field.
|
|
provided_kms = order.get("kms")
|
|
order["kms"] = str(provided_kms) if provided_kms not in (None, "", 0, "0") else "0"
|
|
order["ordertype"] = "Economy"
|
|
order["eta"] = "15"
|
|
cumulative_eta_min += 15
|
|
order["cumulative_eta"] = str(cumulative_eta_min)
|
|
result.append(order)
|
|
global_step += 1
|
|
|
|
return result
|
|
|
|
|