Files
routesapi/app/services/routing/clustering_service.py
2026-06-22 17:40:08 +05:30

301 lines
11 KiB
Python

"""
Geographic Clustering Service for Order Assignment
Uses K-means clustering to group orders by kitchen location.
Enhanced with Geohash encoding for spatial learning (idea.txt data encoding).
"""
import logging
import numpy as np
from typing import List, Dict, Any, Tuple
from collections import defaultdict
from math import radians, cos, sin, asin, sqrt
logger = logging.getLogger(__name__)
class GeohashEncoder:
"""
Geohash Encoding for spatial data.
Converts lat/lon coordinates to grid cell strings for locality capture.
Precision levels:
- 4 chars: ~156km x 156km (regional)
- 5 chars: ~39km x 19km (city-level)
- 6 chars: ~4.9km x 4.9km (neighborhood)
- 7 chars: ~1.2km x 609m (local zone)
"""
BASE32 = "0123456789bcdefghjkmnpqrstuvwxyz"
@classmethod
def encode(cls, lat: float, lon: float, precision: int = 6) -> str:
"""
Encode lat/lon to geohash string.
Args:
lat: Latitude (-90 to 90)
lon: Longitude (-180 to 180)
precision: Number of characters (4-12)
Returns:
Geohash string
"""
if lat == 0 and lon == 0:
return "unknown"
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(cls.BASE32[char_bits])
return "".join(hash_chars)
@classmethod
def get_zone_from_geohash(cls, geohash: str) -> Dict[str, Any]:
"""
Extract zone metadata from geohash for ML features.
Returns:
Dict with zone info: zone_id, precision, cell_size, etc.
"""
if not geohash or geohash == "unknown":
return {"zone_id": "unknown", "precision": 0}
precision = len(geohash)
# Approximate cell sizes (in km)
lat_error = 180.0 / (2 ** (precision * 5 // 2)) / 2
lon_error = 360.0 / (2 ** (precision * 5 // 2 + 1)) / 2
# Cell size approximation
cell_width_km = lat_error * 111 # 1 degree lat ≈ 111km
cell_height_km = (
lon_error * 111 * cos(11 * 3.14159 / 180)
) # Adjust for Coimbatore lat
return {
"zone_id": geohash,
"precision": precision,
"cell_width_km": round(cell_width_km, 3),
"cell_height_km": round(cell_height_km, 3),
"prefix_4": geohash[:4] if len(geohash) >= 4 else geohash,
"prefix_5": geohash[:5] if len(geohash) >= 5 else geohash,
"prefix_6": geohash[:6] if len(geohash) >= 6 else geohash,
}
class ClusteringService:
"""Clusters orders geographically to enable balanced rider assignment."""
def __init__(self):
self.earth_radius_km = 6371
self.geohash_encoder = GeohashEncoder()
def haversine(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance between two points in km."""
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 * self.earth_radius_km
def get_kitchen_location(self, order: Dict[str, Any]) -> Tuple[float, float]:
"""Extract kitchen coordinates from order."""
try:
lat = float(order.get("pickuplat", 0))
lon = float(order.get("pickuplon") or order.get("pickuplong", 0))
if lat != 0 and lon != 0:
return lat, lon
except (ValueError, TypeError):
pass
return 0.0, 0.0
def _encode_location_features(self, lat: float, lon: float) -> Dict[str, Any]:
"""
Encode location with multiple spatial features (Data Encoding: Geohash + Distance).
Returns:
Dict with geohash, zone info, distance features
"""
features = {}
# Primary geohash encoding (6 chars = ~5km zone for Coimbatore)
geohash_6 = self.geohash_encoder.encode(lat, lon, 6)
features["geohash_6"] = geohash_6
# Fine-grained geohash (7 chars = ~1.2km zone)
geohash_7 = self.geohash_encoder.encode(lat, lon, 7)
features["geohash_7"] = geohash_7
# Coarse geohash for regional grouping (4 chars = ~156km)
geohash_4 = self.geohash_encoder.encode(lat, lon, 4)
features["geohash_4"] = geohash_4
# Zone metadata
features["zone_info"] = self.geohash_encoder.get_zone_from_geohash(geohash_6)
return features
def cluster_orders_by_kitchen(
self, orders: List[Dict[str, Any]], max_cluster_radius_km: float = 3.0
) -> List[Dict[str, Any]]:
"""
Cluster orders by kitchen proximity.
Returns list of clusters, each containing:
- centroid: (lat, lon) of cluster center
- orders: list of orders in this cluster
- kitchen_names: set of kitchen names in cluster
- total_orders: count
- geohash_6: geohash encoding of centroid (NEW)
- zone_info: zone metadata (NEW)
- spatial_features: all location encodings (NEW)
"""
if not orders:
return []
# Group by kitchen location
kitchen_groups = defaultdict(list)
kitchen_coords = {}
for order in orders:
k_name = self._get_kitchen_name(order)
k_lat, k_lon = self.get_kitchen_location(order)
if k_lat == 0:
# Fallback: use delivery location if pickup missing
k_lat = float(order.get("deliverylat", 0))
k_lon = float(order.get("deliverylong", 0))
if k_lat != 0:
kitchen_groups[k_name].append(order)
kitchen_coords[k_name] = (k_lat, k_lon)
# Now cluster kitchens that are close together
clusters = []
processed_kitchens = set()
for k_name, k_orders in kitchen_groups.items():
if k_name in processed_kitchens:
continue
# Start a new cluster with this kitchen
cluster_kitchens = [k_name]
cluster_orders = k_orders[:]
processed_kitchens.add(k_name)
k_lat, k_lon = kitchen_coords[k_name]
# ── CHAIN-MERGE FIX ──────────────────────────────────────────
# Original bug: only checked against the SEED kitchen.
# Example: A→B = 2km, B→C = 2km, A→C = 4km (max_radius = 3km).
# Old code: C is NOT merged (A→C > 3km).
# New code: iteratively re-check remaining kitchens against the
# CURRENT centroid after each merge, so chains like A→B→C
# are correctly collapsed into one cluster.
# ─────────────────────────────────────────────────────────────
changed = True
while changed:
changed = False
# Recompute centroid of current cluster
c_lats = [kitchen_coords[n][0] for n in cluster_kitchens if n in kitchen_coords]
c_lons = [kitchen_coords[n][1] for n in cluster_kitchens if n in kitchen_coords]
c_lat = sum(c_lats) / len(c_lats) if c_lats else k_lat
c_lon = sum(c_lons) / len(c_lons) if c_lons else k_lon
for other_name, other_coords in kitchen_coords.items():
if other_name in processed_kitchens:
continue
other_lat, other_lon = other_coords
dist = self.haversine(c_lat, c_lon, other_lat, other_lon)
if dist <= max_cluster_radius_km:
cluster_kitchens.append(other_name)
cluster_orders.extend(kitchen_groups[other_name])
processed_kitchens.add(other_name)
changed = True # centroid shifted — re-scan remaining
# Calculate cluster centroid
lats = []
lons = []
for order in cluster_orders:
lat, lon = self.get_kitchen_location(order)
if lat != 0:
lats.append(lat)
lons.append(lon)
if lats:
centroid_lat = sum(lats) / len(lats)
centroid_lon = sum(lons) / len(lons)
else:
centroid_lat, centroid_lon = k_lat, k_lon
# ENHANCED: Add geohash encoding features
spatial_features = self._encode_location_features(
centroid_lat, centroid_lon
)
clusters.append(
{
"centroid": (centroid_lat, centroid_lon),
"orders": cluster_orders,
"kitchen_names": set(cluster_kitchens),
"total_orders": len(cluster_orders),
# NEW: Geohash encoding features
"geohash_6": spatial_features["geohash_6"],
"geohash_7": spatial_features["geohash_7"],
"geohash_4": spatial_features["geohash_4"],
"zone_info": spatial_features["zone_info"],
"spatial_features": spatial_features,
}
)
# Sort clusters by order count (largest first)
clusters.sort(key=lambda x: x["total_orders"], reverse=True)
logger.info(
f"Created {len(clusters)} clusters from {len(kitchen_groups)} kitchens with geohash encoding"
)
return clusters
def _get_kitchen_name(self, order: Dict[str, Any]) -> str:
"""Extract kitchen name from order."""
possible_keys = [
"pickupcustomer", # confirmed primary field in production orders
"locationname", # confirmed backup field in production orders
"storename", "store_name",
"restaurantname", "restaurant_name",
"kitchenname", "kitchen_name",
"partnername", "partner_name",
"tenantname",
]
for key in possible_keys:
if key in order and order[key]:
return str(order[key]).strip()
return "Unknown"