new changes in the api
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user