Files
routesapi/app/services/routing/route_optimizer.py
2026-07-06 15:15:51 +05:30

1280 lines
57 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
"""
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.gps_smoother 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 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"))
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
# ------------------------------------------------------------------
# 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.
# search_time_limit_seconds can be tuned up to 8-10s via config, 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