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

@@ -6,110 +6,15 @@ from math import radians, cos, sin, asin, sqrt, ceil
from typing import List, Dict, Any, Optional, Set
from collections import defaultdict
from app.config.rider_preferences import RIDER_PREFERRED_KITCHENS, BLOCKED_RIDERS as _BLOCKED_RIDERS_SET
from app.services.routing.kalman_filter import (
from app.services.routing.gps_smoother import (
smooth_rider_locations,
smooth_order_coordinates,
)
from app.config.dynamic_config import (
get_config,
get_kitchen_label_id as _get_kitchen_label_id,
get_kitchen_frequency as _get_kitchen_frequency,
update_kitchen_stats,
)
from app.services.ml.ml_data_collector import get_collector
from app.config.dynamic_config import get_config
logger = logging.getLogger(__name__)
class DataEncoder:
"""
Data Encoding Utilities for ML-ready feature engineering.
Implements techniques from idea.txt for categorical, spatial, and temporal data.
"""
EARTH_RADIUS_KM = 6371
@staticmethod
def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate great circle distance between two points."""
try:
lon1, lat1, lon2, lat2 = map(
radians, [float(lon1), float(lat1), float(lon2), float(lat2)]
)
dlon = lon2 - lon1
dlat = lat2 - lat1
a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
c = 2 * asin(min(1.0, sqrt(a)))
return c * DataEncoder.EARTH_RADIUS_KM
except:
return 0.0
@staticmethod
def cyclic_encode_hour(hour: int) -> tuple[float, float]:
"""
Cyclic time encoding - captures traffic patterns better than discrete buckets.
hour_sin = sin(2π * hour / 24)
hour_cos = cos(2π * hour / 24)
"""
hour_sin = math.sin(2 * math.pi * hour / 24)
hour_cos = math.cos(2 * math.pi * hour / 24)
return hour_sin, hour_cos
@staticmethod
def cyclic_encode_day(day_of_week: int) -> tuple[float, float]:
"""
Cyclic day encoding for weekly patterns.
"""
day_sin = math.sin(2 * math.pi * day_of_week / 7)
day_cos = math.cos(2 * math.pi * day_of_week / 7)
return day_sin, day_cos
@staticmethod
def geohash_encode(lat: float, lon: float, precision: int = 7) -> str:
"""
Geohash encoding for spatial data.
Converts lat/lon to grid cell string for locality capture.
precision=7 gives ~153m x 153m cells (good for delivery zones)
"""
try:
return _simple_geohash(lat, lon, precision)
except:
return "unknown"
def _simple_geohash(lat: float, lon: float, precision: int = 7) -> str:
"""Standard geohash encoding — 5 bits per character, BASE32 output."""
if lat == 0 or lon == 0:
return "unknown"
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
lat_min, lat_max = -90.0, 90.0
lon_min, lon_max = -180.0, 180.0
hash_chars = []
is_lon = True
for _ in range(precision):
char_bits = 0
for _ in range(5):
if is_lon:
mid = (lon_min + lon_max) / 2
if lon >= mid:
char_bits = (char_bits << 1) | 1
lon_min = mid
else:
char_bits = char_bits << 1
lon_max = mid
else:
mid = (lat_min + lat_max) / 2
if lat >= mid:
char_bits = (char_bits << 1) | 1
lat_min = mid
else:
char_bits = char_bits << 1
lat_max = mid
is_lon = not is_lon
hash_chars.append(BASE32[char_bits])
return "".join(hash_chars)
def _zone_key(lat: float, lon: float) -> str:
"""O(1) ~5 km grid cell key for zone proximity matching."""
if lat == 0 or lon == 0:
@@ -117,26 +22,6 @@ def _zone_key(lat: float, lon: float) -> str:
return f"{int(lat / 0.044)},{int(lon / 0.044)}"
# Kitchen encoding now uses persistent DB storage from dynamic_config
def update_kitchen_encoding(kitchen_name: str, profit: float = None):
"""Update kitchen stats in DB when orders are processed."""
if kitchen_name and kitchen_name != "Unknown":
if profit is not None:
update_kitchen_stats(kitchen_name, profit)
def get_kitchen_label_id(kitchen_name: str) -> int:
"""Get persistent label ID for a kitchen."""
return _get_kitchen_label_id(kitchen_name)
def get_kitchen_frequency(kitchen_name: str) -> float:
"""Get persistent frequency ratio for a kitchen."""
return _get_kitchen_frequency(kitchen_name)
class AssignmentService:
def __init__(self):
# Curated config drives HARD kitchen ownership. Copy so substitution
@@ -196,35 +81,19 @@ class AssignmentService:
self.earth_radius_km = 6371
self._cfg = get_config()
self._encoder = DataEncoder()
# Cost parameters for composite cost function
self._fuel_rate = 2.5 # Per km
self._base_rider_cost = 0.0
self._merchant_margin_avg = 5.0 # Default average margin
# Profit encoding cache
self._kitchen_profit_cache: Dict[str, List[float]] = defaultdict(list)
self._profit_mean = 0.0
self._profit_std = 1.0
def calculate_order_profit_features(
self, order: Dict[str, Any], distance_km: float
) -> Dict[str, float]:
"""
Calculate engineered profit features for ML-ready data.
Features:
- profit: order amount - rider cost
- profit_density: profit per kilometer (key signal!)
- cost_efficiency: rider cost per estimated time
- route_score: profit - composite cost (maximize this!)
- encoded_geohash: spatial encoding
- cyclic_time: hour_sin, hour_cos
Calculate profit and profit-density for one order, used as a scoring
signal (profit_bonus) when picking which rider gets a cluster.
"""
features = {}
# Extract order values
try:
order_amount = float(
order.get("orderamount") or order.get("deliveryamount") or 0
@@ -232,38 +101,17 @@ class AssignmentService:
except:
order_amount = 0.0
# Calculate rider cost: base + (distance * fuel_rate)
# Rider cost: base + (distance * fuel_rate)
rider_cost = self._base_rider_cost + (distance_km * self._fuel_rate)
features["rider_cost"] = rider_cost
# Profit = revenue - cost
profit = order_amount - rider_cost
features["profit"] = profit
# Profit density = profit / distance (HIGH SIGNAL feature!)
# High density = profitable short deliveries
if distance_km > 0:
features["profit_density"] = profit / distance_km
else:
features["profit_density"] = 0.0
# Profit density = profit / distance — high density means profitable
# short deliveries; used to prioritise clusters worth serving.
profit_density = profit / distance_km if distance_km > 0 else 0.0
# Cost efficiency = cost / time estimate (assuming 15 min per order average)
estimated_time_min = max(15, distance_km * 4) # Rough estimate
features["cost_efficiency"] = (
rider_cost / estimated_time_min if estimated_time_min > 0 else 0
)
# Route score = profit - distance_cost (what we want to MAXIMIZE)
# This is the core optimization target
features["route_score"] = profit - (
distance_km * 0.5
) # 0.5 = opportunity cost per km
# Composite edge weight for optimizer (MINIMIZE this)
# weight = cost - profit_margin_bonus
features["composite_weight"] = rider_cost - (profit * 0.3) # 30% profit bonus
return features
return {"profit": profit, "profit_density": profit_density}
def _load_config(self):
"""Load ML-tuned hyperparams fresh on every assignment call."""
@@ -478,7 +326,6 @@ class AssignmentService:
# Use caller-supplied pricing so dynamic API rates flow into scoring
self._fuel_rate = fuel_charge
self._base_rider_cost = base_pay
_call_start = time.time()
# 0. Prep
assignments: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
@@ -588,36 +435,14 @@ class AssignmentService:
orders, max_cluster_radius_km=self.MAX_KITCHEN_DISTANCE_KM
)
# 2b. ENRICH CLUSTERS WITH PROFIT FEATURES (Data Encoding: Target + Frequency)
# 2b. Tag each cluster with the set of kitchen names it covers (used
# below for hard kitchen-ownership matching).
for cluster in clusters:
cluster["kitchen_names"] = set()
for order in cluster["orders"]:
k_name = self.get_order_kitchen(order)
cluster["kitchen_names"].add(k_name)
update_kitchen_encoding(k_name)
cluster["kitchen_names"].add(self.get_order_kitchen(order))
# Update kitchen profit cache for target encoding
for order in cluster["orders"]:
k_name = self.get_order_kitchen(order)
profit = (
float(order.get("orderamount") or order.get("deliveryamount") or 50)
- 40
)
self._kitchen_profit_cache[k_name].append(profit)
# Calculate profit statistics for normalization
all_profits = [
p for profits in self._kitchen_profit_cache.values() for p in profits
]
if all_profits:
self._profit_mean = sum(all_profits) / len(all_profits)
if len(all_profits) > 1:
variance = sum((p - self._profit_mean) ** 2 for p in all_profits) / len(
all_profits
)
self._profit_std = variance**0.5
logger.info(f"Created {len(clusters)} order clusters with profit encoding")
logger.info(f"Created {len(clusters)} order clusters")
# 2c. MINIMAL RIDER PRE-SELECTION
# Calculate the theoretical minimum number of riders needed so we don't
@@ -659,15 +484,6 @@ class AssignmentService:
cluster_geohash = _zone_key(centroid_lat, centroid_lon)
for order in cluster_orders:
k_name = self.get_order_kitchen(order)
# Target encoding: use average profit for this kitchen
kitchen_profits = self._kitchen_profit_cache.get(k_name, [0])
avg_kitchen_profit = (
sum(kitchen_profits) / len(kitchen_profits)
if kitchen_profits
else 0
)
o_lat = float(order.get("pickuplat", 0))
o_lon = float(order.get("pickuplon", 0))
dist = (
@@ -1004,19 +820,10 @@ class AssignmentService:
# 6. Commit State and History
self._post_process(assignments, rider_states, state_mgr)
# 7. -- ML DATA COLLECTION -----------------------------------------
try:
elapsed_ms = (time.time() - _call_start) * 1000
get_collector().log_assignment_event(
num_orders=len(orders),
num_riders=len(riders),
hyperparams=self._cfg.get_all(),
assignments=assignments,
unassigned_count=len(unassigned_orders),
elapsed_ms=elapsed_ms,
)
except Exception as _ml_err:
logger.debug(f"ML logging skipped: {_ml_err}")
# ML event logging happens once, in the /riderassign endpoint after
# Phase-0 history merge + solo consolidation — not here — so every
# request produces exactly one assignment_ml_log row (see
# optimization.py::_bg_log_assignment).
# Log final distribution (use r_orders to avoid shadowing the outer `orders` list)
logger.info("=" * 50)