new changes in the api
This commit is contained in:
@@ -10,7 +10,6 @@ FEATURES:
|
||||
- Automatic outlier detection and coordinate correction
|
||||
- Hybrid distance calculation (Google Maps + Haversine fallback)
|
||||
- Robust error handling for invalid inputs
|
||||
- Composite cost function (idea.txt: distance + profit - margin)
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -21,7 +20,7 @@ import asyncio
|
||||
from typing import Dict, Any, List as _List, Optional, Tuple, Union
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
from app.services.routing.kalman_filter import smooth_order_coordinates
|
||||
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
|
||||
@@ -38,191 +37,6 @@ except ImportError:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CompositeCostCalculator:
|
||||
"""
|
||||
Composite Cost Function for Profit-Aware Route Optimization.
|
||||
|
||||
Based on idea.txt data encoding techniques:
|
||||
- Total Cost = Distance Cost + Rider Cost - Merchant Profit
|
||||
- Edge Cost = (distance * fuel_rate) + rider_cost - merchant_margin
|
||||
- route_score = profit - composite_cost (what we want to MAXIMIZE)
|
||||
|
||||
This transforms the problem from:
|
||||
"Find shortest route" -> "Find most profitable route"
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Cost parameters (can be ML-tuned via DynamicConfig)
|
||||
self.fuel_rate_per_km = 2.5
|
||||
self.base_rider_cost = 0.0
|
||||
self.opportunity_cost_per_km = 0.5 # Cost of rider's time per km
|
||||
self.profit_weight = 0.3 # How much profit influences routing (0-1)
|
||||
self.traffic_multiplier_peak = 1.5 # Peak hour traffic penalty
|
||||
self.traffic_multiplier_normal = 1.2 # Normal traffic multiplier
|
||||
|
||||
# Defaults for orders without profit data
|
||||
self.default_order_amount = 80.0
|
||||
self.default_merchant_margin = 5.0
|
||||
|
||||
def calculate_composite_cost(
|
||||
self,
|
||||
distance_km: float,
|
||||
order_amount: float = None,
|
||||
merchant_margin: float = None,
|
||||
traffic_factor: float = 1.0,
|
||||
time_of_day: str = "NORMAL",
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate composite cost for a route edge.
|
||||
|
||||
Args:
|
||||
distance_km: Distance for this leg
|
||||
order_amount: Revenue from this order (if known)
|
||||
merchant_margin: Merchant's margin for this order (if known)
|
||||
traffic_factor: Traffic multiplier (1.0 = normal)
|
||||
time_of_day: Traffic time category ("PEAK", "NORMAL", "OFF_PEAK")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- distance_cost: Raw distance cost
|
||||
- rider_cost: Total rider cost for this leg
|
||||
- gross_profit: Revenue - rider cost
|
||||
- net_cost: Cost after profit adjustment (MINIMIZE THIS)
|
||||
- route_score: Profitability score (MAXIMIZE THIS)
|
||||
"""
|
||||
# Distance cost = fuel + opportunity cost
|
||||
distance_cost = distance_km * self.fuel_rate_per_km
|
||||
|
||||
# Rider cost = base + distance cost
|
||||
rider_cost = self.base_rider_cost + distance_cost
|
||||
|
||||
# Apply traffic penalty
|
||||
if time_of_day == "PEAK":
|
||||
rider_cost *= self.traffic_multiplier_peak
|
||||
elif time_of_day == "NORMAL":
|
||||
rider_cost *= self.traffic_multiplier_normal
|
||||
|
||||
# Apply custom traffic factor
|
||||
rider_cost *= traffic_factor
|
||||
|
||||
# Profit calculation (target encoding: use order data if available)
|
||||
if order_amount is None:
|
||||
order_amount = self.default_order_amount
|
||||
if merchant_margin is None:
|
||||
merchant_margin = self.default_merchant_margin
|
||||
|
||||
gross_profit = order_amount - rider_cost
|
||||
|
||||
# Net cost = rider cost - profit contribution
|
||||
# This means high-profit orders have LOWER cost (more desirable)
|
||||
profit_adjustment = gross_profit * self.profit_weight
|
||||
net_cost = rider_cost - profit_adjustment
|
||||
|
||||
# Route score = profit - opportunity cost (for route planning)
|
||||
route_score = gross_profit - (distance_km * self.opportunity_cost_per_km)
|
||||
|
||||
# Ensure net_cost is never negative (minimum cost for any delivery)
|
||||
net_cost = max(net_cost, 5.0) # Minimum 5 km equivalent cost
|
||||
|
||||
return {
|
||||
"distance_cost": round(distance_cost, 2),
|
||||
"rider_cost": round(rider_cost, 2),
|
||||
"gross_profit": round(gross_profit, 2),
|
||||
"net_cost": round(net_cost, 2),
|
||||
"route_score": round(route_score, 2),
|
||||
}
|
||||
|
||||
def calculate_cost_matrix(
|
||||
self,
|
||||
dist_matrix: np.ndarray,
|
||||
orders: _List[Dict[str, Any]] = None,
|
||||
traffic_condition: str = "NORMAL",
|
||||
) -> Tuple[np.ndarray, Dict[str, Any]]:
|
||||
"""
|
||||
Calculate composite cost matrix for all node pairs.
|
||||
|
||||
Args:
|
||||
dist_matrix: Distance matrix (N x N)
|
||||
orders: List of orders (for profit data)
|
||||
traffic_condition: Traffic condition ("PEAK", "NORMAL", "OFF_PEAK")
|
||||
|
||||
Returns:
|
||||
Tuple of (cost_matrix, summary_stats)
|
||||
"""
|
||||
n = len(dist_matrix)
|
||||
cost_matrix = np.zeros((n, n))
|
||||
|
||||
# Extract order data for profit encoding
|
||||
order_amounts = []
|
||||
merchant_margins = []
|
||||
|
||||
if orders:
|
||||
for o in orders:
|
||||
try:
|
||||
amount = float(
|
||||
o.get("orderamount")
|
||||
or o.get("deliveryamount")
|
||||
or self.default_order_amount
|
||||
)
|
||||
margin = float(
|
||||
o.get("merchant_margin") or self.default_merchant_margin
|
||||
)
|
||||
except:
|
||||
amount = self.default_order_amount
|
||||
margin = self.default_merchant_margin
|
||||
order_amounts.append(amount)
|
||||
merchant_margins.append(margin)
|
||||
else:
|
||||
order_amounts = [self.default_order_amount] * n
|
||||
merchant_margins = [self.default_merchant_margin] * n
|
||||
|
||||
# Calculate costs for each pair
|
||||
total_cost = 0.0
|
||||
total_profit = 0.0
|
||||
high_cost_count = 0
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i == j:
|
||||
cost_matrix[i][j] = 0
|
||||
continue
|
||||
|
||||
dist = dist_matrix[i][j]
|
||||
|
||||
# Use order j's profit data (destination)
|
||||
order_amount = (
|
||||
order_amounts[j - 1] if j > 0 else self.default_order_amount
|
||||
)
|
||||
merchant_margin = (
|
||||
merchant_margins[j - 1] if j > 0 else self.default_merchant_margin
|
||||
)
|
||||
|
||||
cost_data = self.calculate_composite_cost(
|
||||
distance_km=dist,
|
||||
order_amount=order_amount,
|
||||
merchant_margin=merchant_margin,
|
||||
time_of_day=traffic_condition,
|
||||
)
|
||||
|
||||
cost_matrix[i][j] = cost_data["net_cost"]
|
||||
total_cost += cost_data["net_cost"]
|
||||
total_profit += cost_data["gross_profit"]
|
||||
|
||||
if cost_data["net_cost"] > 50:
|
||||
high_cost_count += 1
|
||||
|
||||
summary = {
|
||||
"total_cost": round(total_cost, 2),
|
||||
"total_profit": round(total_profit, 2),
|
||||
"avg_cost": round(total_cost / (n * n) if n > 0 else 0, 2),
|
||||
"avg_profit": round(total_profit / (n * n) if n > 0 else 0, 2),
|
||||
"high_cost_legs": high_cost_count,
|
||||
"traffic_condition": traffic_condition,
|
||||
}
|
||||
|
||||
return cost_matrix, summary
|
||||
|
||||
|
||||
class RouteOptimizer:
|
||||
"""Route optimization using Google OR-Tools (Async)."""
|
||||
|
||||
@@ -253,8 +67,6 @@ class RouteOptimizer:
|
||||
# Solver time limit (ML-tuned)
|
||||
self.search_time_limit_seconds = int(_cfg.get("search_time_limit_seconds"))
|
||||
|
||||
self.cost_calculator = CompositeCostCalculator()
|
||||
|
||||
def haversine_distance(
|
||||
self, lat1: float, lon1: float, lat2: float, lon2: float
|
||||
) -> float:
|
||||
@@ -274,153 +86,6 @@ class RouteOptimizer:
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
async def _get_google_maps_distances_batch(
|
||||
self, origin_lat: float, origin_lon: float, destinations: _List[tuple]
|
||||
) -> Dict[tuple, float]:
|
||||
"""Get road distances for multiple destinations from Google Maps API. (Async, Parallel)"""
|
||||
if not self.use_google_maps or not destinations:
|
||||
return {}
|
||||
|
||||
results = {}
|
||||
batch_size = 25
|
||||
chunks = [
|
||||
destinations[i : i + batch_size]
|
||||
for i in range(0, len(destinations), batch_size)
|
||||
]
|
||||
|
||||
async def process_batch(batch):
|
||||
batch_result = {}
|
||||
try:
|
||||
dest_str = "|".join([f"{lat},{lon}" for lat, lon in batch])
|
||||
url = "https://maps.googleapis.com/maps/api/distancematrix/json"
|
||||
params = {
|
||||
"origins": f"{origin_lat},{origin_lon}",
|
||||
"destinations": dest_str,
|
||||
"key": self.google_maps_api_key,
|
||||
"units": "metric",
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") == "OK":
|
||||
rows = data.get("rows", [])
|
||||
if rows:
|
||||
elements = rows[0].get("elements", [])
|
||||
for idx, element in enumerate(elements):
|
||||
if idx < len(batch):
|
||||
dest_coord = batch[idx]
|
||||
if element.get("status") == "OK":
|
||||
dist = element.get("distance", {}).get("value")
|
||||
dur = element.get("duration", {}).get("value")
|
||||
if dist is not None:
|
||||
batch_result[dest_coord] = {
|
||||
"distance": dist / 1000.0,
|
||||
"duration": dur / 60.0 if dur else None,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Google Maps batch call failed: {e}")
|
||||
return batch_result
|
||||
|
||||
batch_results_list = await asyncio.gather(
|
||||
*[process_batch(chunk) for chunk in chunks]
|
||||
)
|
||||
for res in batch_results_list:
|
||||
results.update(res)
|
||||
return results
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GOOGLE DIRECTIONS - WAYPOINT OPTIMISATION
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _optimize_waypoints_google(
|
||||
self,
|
||||
origin_lat: float,
|
||||
origin_lon: float,
|
||||
waypoints: _List[Tuple[float, float]],
|
||||
) -> Tuple[Optional[_List[int]], Optional[_List[float]]]:
|
||||
"""
|
||||
Ask Google Directions API to find the optimal visiting order for a set
|
||||
of delivery points starting from a kitchen/pickup location.
|
||||
|
||||
Uses `optimize:true` in the waypoints parameter - Google solves the TSP
|
||||
internally using actual road geometry (turn restrictions, one-way
|
||||
streets, real distances) rather than Haversine approximation.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(waypoint_order, leg_km)
|
||||
waypoint_order 0-based indices into `waypoints` in optimal order.
|
||||
e.g. [2, 0, 1] means visit wp[2] -> wp[0] -> wp[1].
|
||||
leg_km Actual road distance (km) for each leg in order:
|
||||
leg_km[0] = kitchen -> wp[order[0]],
|
||||
leg_km[1] = wp[order[0]] -> wp[order[1]], etc.
|
||||
Both are None on any failure - caller falls back to OR-Tools.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Supports up to 25 intermediate waypoints (Google's standard limit).
|
||||
- destination = origin (closed-loop TSP); the return leg is discarded.
|
||||
- One API call per rider per assignment - cheap at delivery scale.
|
||||
"""
|
||||
if not self.use_google_maps or not waypoints or len(waypoints) < 2:
|
||||
return None, None
|
||||
if len(waypoints) > 25:
|
||||
return None, None # fall back to OR-Tools for unusually large routes
|
||||
|
||||
try:
|
||||
wp_str = "optimize:true|" + "|".join(
|
||||
f"{lat},{lon}" for lat, lon in waypoints
|
||||
)
|
||||
params = {
|
||||
"origin": f"{origin_lat},{origin_lon}",
|
||||
"destination": f"{origin_lat},{origin_lon}", # closed loop
|
||||
"waypoints": wp_str,
|
||||
"key": self.google_maps_api_key,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
resp = await client.get(
|
||||
"https://maps.googleapis.com/maps/api/directions/json",
|
||||
params=params,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
status_code = data.get("status")
|
||||
if status_code != "OK":
|
||||
logger.debug(
|
||||
f"[GoogleWaypoints] status={status_code} "
|
||||
f"error='{data.get('error_message', '')}'"
|
||||
)
|
||||
return None, None
|
||||
|
||||
routes = data.get("routes", [])
|
||||
if not routes:
|
||||
return None, None
|
||||
|
||||
route = routes[0]
|
||||
wp_order = route.get("waypoint_order")
|
||||
legs = route.get("legs", [])
|
||||
|
||||
if wp_order is None or len(wp_order) != len(waypoints):
|
||||
return None, None
|
||||
|
||||
# Extract leg distances (metres -> km), skip the return-to-origin leg
|
||||
leg_km: _List[float] = []
|
||||
for leg in legs[: len(waypoints)]: # first N legs only
|
||||
dist_m = leg.get("distance", {}).get("value")
|
||||
leg_km.append(dist_m / 1000.0 if dist_m is not None else 0.0)
|
||||
|
||||
logger.debug(
|
||||
f"[GoogleWaypoints] Optimised {len(waypoints)} stops -> order={wp_order}"
|
||||
)
|
||||
return wp_order, leg_km
|
||||
|
||||
except Exception as _e:
|
||||
logger.debug(f"[GoogleWaypoints] Failed (non-fatal): {_e}")
|
||||
return None, None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ROAD-AWARE VISITING ORDER (Phase 2 - opt-in, cached)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -698,8 +363,8 @@ class RouteOptimizer:
|
||||
routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
|
||||
)
|
||||
# TSP time limit hard-capped at 2 seconds per kitchen.
|
||||
# The ML hypertuner may push search_time_limit_seconds up to 8-10s
|
||||
# chasing marginally better routes, but at delivery scale (< 15 stops)
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user