/root/Routes-api on 31.97.228.132 is not a git repository. Work had been done directly on the box and existed nowhere else -- a single rm -rf from being lost, and impossible to review or roll back. Deploying the previous HEAD over it would have silently reverted all of this. Most visibly the Valhalla road-backend probe in main.py, whose own comment explains why it exists: road sequencing degrades to aerial silently by design, so an unreachable backend stays invisible, "which is exactly how the expired Google key went unnoticed". Overwriting it would have reintroduced precisely the failure it was written to catch, and the service would have kept answering 200 throughout. The server had also moved from Google Maps to Valhalla for road distance (VALHALLA_URL, road_backend_status, +190 lines in route_optimizer), extended docker-compose from 44 to 95 lines, and changed rider fetching, health, dynamic config and the cache layer. Only 10 files differ in substance. The other 27 that appeared to differ were CRLF-vs-LF noise -- the server writes CRLF -- and are normalised to LF here rather than committed as spurious whole-file rewrites. Committed as-is, before any change of mine, so the diff that follows is reviewable against what is actually running. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1370 lines
60 KiB
Python
1370 lines
60 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 (self-hosted Valhalla road matrix + 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__)
|
|
|
|
|
|
async def road_backend_status() -> Dict[str, Any]:
|
|
"""
|
|
Probe the Valhalla matrix backend.
|
|
|
|
Module-level (not a RouteOptimizer method) so startup and health checks can
|
|
call it without paying for a full optimizer construction, which pulls in the
|
|
empirical ETA calculator and its history load.
|
|
"""
|
|
url = os.getenv("VALHALLA_URL", "").strip().rstrip("/")
|
|
if not url:
|
|
return {
|
|
"configured": False,
|
|
"reachable": False,
|
|
"detail": "VALHALLA_URL not set - road sequencing disabled, aerial only",
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
resp = await client.get(f"{url}/status")
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
return {
|
|
"configured": True,
|
|
"reachable": True,
|
|
"url": url,
|
|
"version": data.get("version"),
|
|
"tileset_last_modified": data.get("tileset_last_modified"),
|
|
}
|
|
except Exception as e:
|
|
return {"configured": True, "reachable": False, "url": url, "detail": str(e)}
|
|
|
|
|
|
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"))
|
|
|
|
# Road travel-time matrix backend: self-hosted Valhalla (no per-request
|
|
# cost, no rate limit). Unset VALHALLA_URL -> road sequencing stays off
|
|
# and every caller falls back to aerial ordering.
|
|
self.valhalla_url = os.getenv("VALHALLA_URL", "").strip().rstrip("/")
|
|
self.use_road_matrix = bool(self.valhalla_url)
|
|
|
|
# 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)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _aerial_minutes(
|
|
self, a: Tuple[float, float], b: Tuple[float, float]
|
|
) -> float:
|
|
"""Haversine travel-time estimate (minutes), used to patch unroutable pairs."""
|
|
km = self.haversine_distance(a[0], a[1], b[0], b[1]) * self.road_factor
|
|
return (km / (self.avg_speed_kmh or 20.0)) * 60.0
|
|
|
|
async def _road_duration_matrix(
|
|
self, coords: _List[Tuple[float, float]]
|
|
) -> Optional[_List[_List[float]]]:
|
|
"""
|
|
Full NxN road travel-TIME matrix (minutes) via self-hosted Valhalla.
|
|
|
|
One /sources_to_targets call returns the whole matrix, so unlike the old
|
|
Google Distance Matrix path there is no per-request element cap and no
|
|
chunking. Costing defaults to `motorcycle`, which models the lane access
|
|
and one-way behaviour our riders actually have — a car matrix systematically
|
|
overstates their travel time.
|
|
|
|
Valhalla reports an unroutable pair as time=null. Those are patched with an
|
|
aerial estimate rather than left at 0, because a 0-cost edge would look
|
|
free to the TSP and pull the whole sequence through it. If too many pairs
|
|
are unroutable the tileset probably doesn't cover this region, so we bail
|
|
to aerial entirely.
|
|
|
|
Returns None on any failure so the caller falls back to aerial ordering.
|
|
"""
|
|
if not self.use_road_matrix:
|
|
return None
|
|
cfg = get_config()
|
|
costing = str(cfg.get("routing_valhalla_costing", "motorcycle"))
|
|
timeout = float(cfg.get("routing_matrix_timeout_seconds", 15.0))
|
|
max_unroutable = float(cfg.get("routing_matrix_max_unroutable_pct", 20.0))
|
|
|
|
n = len(coords)
|
|
locations = [{"lat": float(la), "lon": float(lo)} for la, lo in coords]
|
|
matrix = [[0.0] * n for _ in range(n)]
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
resp = await client.post(
|
|
f"{self.valhalla_url}/sources_to_targets",
|
|
json={
|
|
"sources": locations,
|
|
"targets": locations,
|
|
"costing": costing,
|
|
"units": "km",
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
rows = data.get("sources_to_targets") or []
|
|
if len(rows) != n:
|
|
logger.debug(
|
|
f"[RoadSeq] Valhalla returned {len(rows)} rows, expected {n}"
|
|
)
|
|
return None
|
|
|
|
unroutable = 0
|
|
for i, row in enumerate(rows):
|
|
for el in row or []:
|
|
j = el.get("to_index")
|
|
if j is None or not (0 <= j < n):
|
|
continue
|
|
secs = el.get("time")
|
|
if secs is None:
|
|
unroutable += 1
|
|
matrix[i][j] = self._aerial_minutes(coords[i], coords[j])
|
|
else:
|
|
matrix[i][j] = secs / 60.0
|
|
|
|
pct = 100.0 * unroutable / max(1, n * n)
|
|
if pct > max_unroutable:
|
|
logger.warning(
|
|
f"[RoadSeq] {unroutable}/{n * n} pairs ({pct:.0f}%) unroutable via "
|
|
f"Valhalla - tileset likely missing this region; using aerial"
|
|
)
|
|
return None
|
|
if unroutable:
|
|
logger.debug(
|
|
f"[RoadSeq] patched {unroutable}/{n * n} unroutable pairs with aerial"
|
|
)
|
|
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 (Valhalla) 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.
|
|
|
|
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 on the live path:
|
|
* disabled unless `routing_use_road_distance` AND VALHALLA_URL is set
|
|
* only for 3..`routing_road_max_stops` stops (fewer is trivial; more
|
|
costs more solver time than the ordering gain is worth)
|
|
* 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_road_matrix:
|
|
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 budget, ceiling 2 seconds per kitchen. This cap applies to
|
|
# per-rider TSP and the per-kitchen beatmap solves.
|
|
#
|
|
# GUIDED_LOCAL_SEARCH runs until its time limit EXPIRES - it does not
|
|
# return early once it has the optimum. A flat cap therefore burned the
|
|
# whole budget on trivial inputs, where PATH_CHEAPEST_ARC is done in
|
|
# under 200ms. Scale with stop count; large inputs still get the ceiling.
|
|
_cap_ms = min(self.search_time_limit_seconds, 2) * 1000
|
|
search_params.time_limit.FromMilliseconds(
|
|
max(200, min(_cap_ms, 200 * len(locations)))
|
|
)
|
|
|
|
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: ceiling of 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.
|
|
#
|
|
# GUIDED_LOCAL_SEARCH runs until its time limit EXPIRES rather than
|
|
# stopping once the optimum is found, so a flat 3s spent ~2.9s of
|
|
# pure waiting on a 1-order batch (measured on live traffic).
|
|
# Scale with problem size; large batches still get the full ceiling.
|
|
_cap_ms = min(self.search_time_limit_seconds, 3) * 1000
|
|
sp.time_limit.FromMilliseconds(
|
|
max(250, min(_cap_ms, 250 * len(orders)))
|
|
)
|
|
|
|
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).
|
|
# Always the order's own kitchen, never the routing depot: when
|
|
# start_coords is the rider's current position (e.g. reconcile
|
|
# mid-route) rather than the kitchen, start_lat/start_lon no
|
|
# longer equals the pickup location.
|
|
plat, plon = (
|
|
_to_float(order.get("pickuplat")),
|
|
_to_float(order.get("pickuplon") or order.get("pickuplong")),
|
|
)
|
|
if plat == 0:
|
|
plat, plon = start_lat, start_lon
|
|
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
|
|
|
|
|