new changes in the api

This commit is contained in:
2026-07-06 15:15:51 +05:30
parent c742ef0e53
commit 871981035a
43 changed files with 414 additions and 1975 deletions

View File

@@ -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