import logging import random import time import math 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.gps_smoother import ( smooth_rider_locations, smooth_order_coordinates, ) from app.config.dynamic_config import get_config logger = logging.getLogger(__name__) 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: return "unknown" return f"{int(lat / 0.044)},{int(lon / 0.044)}" class AssignmentService: def __init__(self): # Curated config drives HARD kitchen ownership. Copy so substitution # mutations are per-request and never bleed into the module-level dict. self.rider_preferences = dict(RIDER_PREFERRED_KITCHENS) # SOFT steering (preference bonus, distance bypass, home bonus) uses the # learned-augmented view: curated config ∪ learned affinity. Falls back to # pure config if the affinity service is unavailable. try: from app.services.routing.rider_affinity_service import get_rider_affinity _aff = get_rider_affinity() self.soft_preferences = _aff.get_preferred_kitchens() self.home_locations = _aff.get_home_locations() except Exception: from app.config.rider_preferences import RIDER_HOME_LOCATIONS as _H self.soft_preferences = dict(RIDER_PREFERRED_KITCHENS) self.home_locations = dict(_H) # Apply today's substitutions: copy absent rider's profile onto sub rider. # The sub rider appears in getriderlogs; the absent rider does not. # Reverts automatically the next day because the lookup is date-keyed. try: from app.services.rider.substitution_service import get_substitution_service _sub_map = get_substitution_service().get_today_map() # {absent_id: sub_id} if _sub_map: for _absent_id, _sub_id in _sub_map.items(): # Hard ownership: sub rider inherits absent rider's kitchens _absent_hard = self.rider_preferences.get(_absent_id, []) if _absent_hard: _sub_hard = list(self.rider_preferences.get(_sub_id, [])) _hard_lower = {k.lower() for k in _sub_hard} for _k in _absent_hard: if _k.lower() not in _hard_lower: _sub_hard.append(_k) self.rider_preferences[_sub_id] = _sub_hard # Soft preferences: merge absent rider's learned kitchens _absent_soft = self.soft_preferences.get(_absent_id, []) if _absent_soft: _sub_soft = list(self.soft_preferences.get(_sub_id, [])) _soft_lower = {k.lower() for k in _sub_soft} for _k in _absent_soft: if _k.lower() not in _soft_lower: _sub_soft.append(_k) self.soft_preferences[_sub_id] = _sub_soft # Home location: use absent rider's home if sub has none _absent_home = self.home_locations.get(_absent_id, (0.0, 0.0)) if _absent_home and _absent_home != (0.0, 0.0): _sub_home = self.home_locations.get(_sub_id, (0.0, 0.0)) if not _sub_home or _sub_home == (0.0, 0.0): self.home_locations[_sub_id] = _absent_home logger.info( f"[Substitution] {len(_sub_map)} active substitution(s) applied: " + ", ".join(f"absent={a} → sub={s}" for a, s in _sub_map.items()) ) except Exception as _e: logger.warning(f"[Substitution] Could not apply substitutions: {_e}") self.earth_radius_km = 6371 self._cfg = get_config() # 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 def calculate_order_profit_features( self, order: Dict[str, Any], distance_km: float ) -> Dict[str, float]: """ Calculate profit and profit-density for one order, used as a scoring signal (profit_bonus) when picking which rider gets a cluster. """ try: order_amount = float( order.get("orderamount") or order.get("deliveryamount") or 0 ) except: order_amount = 0.0 # Rider cost: base + (distance * fuel_rate) rider_cost = self._base_rider_cost + (distance_km * self._fuel_rate) # Profit = revenue - cost profit = order_amount - rider_cost # 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 return {"profit": profit, "profit_density": profit_density} def _load_config(self): """Load ML-tuned hyperparams fresh on every assignment call.""" cfg = self._cfg self.MAX_PICKUP_DISTANCE_KM = cfg.get("max_pickup_distance_km") self.MAX_KITCHEN_DISTANCE_KM = cfg.get("max_kitchen_distance_km") # Hard cap: ML hypertuner must never exceed 12 orders per rider. # Food delivery reality — more than 12 stops means 90+ min routes, # cold food, and rider exhaustion. The tuner found 20 optimal for # some statistical metric but that is operationally unacceptable. _raw_max = int(cfg.get("max_orders_per_rider")) self.MAX_ORDERS_PER_RIDER = min(_raw_max, 12) # Ideal load must be ≥ half of max so fill-before-spill is meaningful. # ideal_load=4 with max=12 means riders switch at 33% — too early. # Floor at 6 so a rider reaches 50% before we open a new one. _raw_ideal = int(cfg.get("ideal_load")) self.IDEAL_LOAD = max(_raw_ideal, 6) # ── DYNAMIC NEW-RIDER PENALTY ───────────────────────────────────── # WORKLOAD_PENALTY_WEIGHT must be loaded FIRST so that NEW_RIDER_PENALTY # (which depends on it) can be computed correctly. # # Bug that was here: NEW_RIDER_PENALTY was calculated on the line ABOVE # the line that sets WORKLOAD_PENALTY_WEIGHT, causing AttributeError on # the first call and stale values on every subsequent call. # # Fix: NEW_RIDER_PENALTY = WORKLOAD_PENALTY_WEIGHT × 1.5 # This guarantees non-preferred riders NEVER beat a preferred rider # at ANY load level (max preferred score = 76.82, penalty = 115.2). # Preferred riders fill to capacity before others are ever opened. self.WORKLOAD_BALANCE_THRESHOLD = cfg.get("workload_balance_threshold") self.WORKLOAD_PENALTY_WEIGHT = cfg.get("workload_penalty_weight") # ← set FIRST self.NEW_RIDER_PENALTY = self.WORKLOAD_PENALTY_WEIGHT * 1.5 # ← then derived self.DISTANCE_PENALTY_WEIGHT = cfg.get("distance_penalty_weight") self.PREFERENCE_BONUS = cfg.get("preference_bonus") self.HOME_ZONE_BONUS_4KM = cfg.get("home_zone_bonus_4km") self.HOME_ZONE_BONUS_2KM = cfg.get("home_zone_bonus_2km") self.EMERGENCY_LOAD_PENALTY = cfg.get("emergency_load_penalty") def haversine(self, lat1, lon1, lat2, lon2): """Calculate the great circle distance between two points.""" 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))) # Clamp to 1.0 to avoid domain errors return c * self.earth_radius_km def get_lat_lon(self, obj: Dict[str, Any], prefix: str = "") -> tuple[float, float]: """Generic helper to extract lat/lon from diversely named keys.""" # Try specific prefixes first candidates = [ (f"{prefix}lat", f"{prefix}lon"), (f"{prefix}lat", f"{prefix}long"), (f"{prefix}latitude", f"{prefix}longitude"), ] # Also try standard keys if prefix fails candidates.extend( [ ("lat", "lon"), ("latitude", "longitude"), ("pickuplat", "pickuplon"), ("pickuplat", "pickuplong"), ("deliverylat", "deliverylong"), ("droplat", "droplon"), ] ) for lat_key, lon_key in candidates: if lat_key in obj and lon_key in obj and obj[lat_key] and obj[lon_key]: try: return float(obj[lat_key]), float(obj[lon_key]) except: pass # Special case: nested 'pickup_location' if "pickup_location" in obj: return self.get_lat_lon(obj["pickup_location"]) return 0.0, 0.0 def get_order_kitchen(self, order: Dict[str, Any]) -> str: 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" def _select_coverage_riders( self, valid_riders: List[Dict], clusters: List[Dict], target_count: int, rider_states: Dict, ) -> Set[int]: """ Greedy set-cover: select the minimal set of riders that geographically covers all kitchen cluster centroids within MAX_PICKUP_DISTANCE_KM. Step 1: greedily pick rider that covers the most uncovered clusters. Step 2: pad up to target_count with remaining riders ordered by total distance to all cluster centroids (closest = most useful). Returns a set of rider IDs that form the preferred/pre-selected pool. """ if not valid_riders or not clusters: return {r["id"] for r in valid_riders[:target_count]} # Sort by ID so greedy selection is deterministic regardless of # the order riders arrive in the API response. valid_riders = sorted(valid_riders, key=lambda r: r["id"]) selected_ids: Set[int] = set() uncovered = list(range(len(clusters))) # Step 1 — greedy max-coverage selection while len(selected_ids) < target_count and uncovered: best_rider = None best_coverage = -1 for r in valid_riders: if r["id"] in selected_ids: continue coverage = sum( 1 for ci in uncovered if self.haversine( r["lat"], r["lon"], clusters[ci]["centroid"][0], clusters[ci]["centroid"][1], ) <= self.MAX_PICKUP_DISTANCE_KM ) if coverage > best_coverage: best_coverage = coverage best_rider = r if not best_rider or best_coverage == 0: break selected_ids.add(best_rider["id"]) # Remove clusters now covered by this rider uncovered = [ ci for ci in uncovered if self.haversine( best_rider["lat"], best_rider["lon"], clusters[ci]["centroid"][0], clusters[ci]["centroid"][1], ) > self.MAX_PICKUP_DISTANCE_KM ] # Step 2 — pad with closest-aggregate riders up to target_count if len(selected_ids) < target_count: def total_dist_to_clusters(r): return sum( self.haversine( r["lat"], r["lon"], c["centroid"][0], c["centroid"][1], ) for c in clusters ) remaining = sorted( [r for r in valid_riders if r["id"] not in selected_ids], key=total_dist_to_clusters, ) for r in remaining: selected_ids.add(r["id"]) if len(selected_ids) >= target_count: break return selected_ids async def assign_orders( self, orders: List[Dict[str, Any]], riders: List[Dict[str, Any]], reshuffle: bool = False, fuel_charge: float = 2.5, base_pay: float = 0.0, ) -> tuple[Dict[int, List[Dict[str, Any]]], List[Dict[str, Any]]]: """ ENHANCED: Cluster-Based Load-Balanced Assignment with Minimal Rider Selection. Strategy: 1. Cluster orders by kitchen proximity 2. Pre-select the MINIMUM set of riders needed to handle all orders (ceil(total_orders / max_orders_per_rider)) via greedy set-cover. 3. FILL-BEFORE-SPILL: prefer already-started riders until ideal_load before activating a fresh rider — reduces rider count for small batches. 4. Assign clusters to best-fit riders (proximity + workload balance) 5. Rebalance if needed If reshuffle=True, controlled randomness is injected into rider scoring so that retrying the same input can explore alternative assignments. """ from app.services.rider.rider_history_service import RiderHistoryService from app.services.rider.rider_state_manager import RiderStateManager from app.services.routing.clustering_service import ClusteringService # -- Load ML-tuned hyperparameters (or defaults on first run) ------ self._load_config() # Use caller-supplied pricing so dynamic API rates flow into scoring self._fuel_rate = fuel_charge self._base_rider_cost = base_pay # 0. Prep assignments: Dict[int, List[Dict[str, Any]]] = defaultdict(list) unassigned_orders: List[Dict[str, Any]] = [] rider_states = {} # Track live load # 0a. KALMAN FILTER - Smooth rider GPS locations before scoring riders = smooth_rider_locations(list(riders)) # 0b. KALMAN FILTER - Smooth order delivery coordinates before clustering orders = smooth_order_coordinates(list(orders)) # 1. Parse and Filter Riders valid_riders = [] BLOCKED_RIDERS = _BLOCKED_RIDERS_SET # frozenset — single source of truth, O(1) lookup # Load Existing State (Persistence) state_mgr = RiderStateManager() for r in riders: # Robust ID Extraction rid_raw = r.get("userid") or r.get("riderid") or r.get("id") or r.get("_id") try: rid = int(rid_raw) except (ValueError, TypeError): continue if rid in BLOCKED_RIDERS: continue # Robust Status Check # Keep if: onduty (1, "1", True) OR status is active/idle/online is_onduty = str(r.get("onduty")) in ["1", "True"] or r.get("onduty") is True is_active = r.get("status") in ["active", "idle", "online"] if not (is_onduty or is_active): continue # Location lat, lon = self.get_lat_lon(r) # Fetch previous state to know if they are already busy p_state = state_mgr.get_rider_state(rid) # If rider has valid GPS, use it. If not, fallback to Last Drop or Home. if lat == 0 or lon == 0: if p_state["last_drop_lat"]: lat, lon = p_state["last_drop_lat"], p_state["last_drop_lon"] else: # Home Location Fallback (learned-augmented) lat, lon = self.home_locations.get(rid, (0.0, 0.0)) valid_riders.append({"id": rid, "lat": lat, "lon": lon, "obj": r}) # Initialize rider state with existing workload existing_load = ( p_state.get("minutes_remaining", 0) / 15 ) # Convert minutes to order estimate rider_states[rid] = { "lat": lat, "lon": lon, "kitchens": set(), "count": int(existing_load), # Start with existing workload "workload_score": existing_load, # For prioritization } if not valid_riders: logger.warning( "No riders passed on-duty filter. Retrying with all available riders as emergency rescue..." ) # If no on-duty riders, we take ANY rider provided by the API to ensure assignment for r in riders: rid = int(r.get("userid", 0)) if rid in _BLOCKED_RIDERS_SET: continue lat, lon = self.get_lat_lon(r) if lat == 0 or lon == 0: lat, lon = self.home_locations.get(rid, (0.0, 0.0)) if lat != 0: valid_riders.append({"id": rid, "lat": lat, "lon": lon, "obj": r}) rider_states[rid] = { "lat": lat, "lon": lon, "kitchens": set(), "count": 0, "workload_score": 0, } if not valid_riders: logger.error("DANGER: Absolutely no riders available for assignment.") # Mark all as unassigned for o in orders: o["unassigned_reason"] = ( "No riders found (check partner online status)." ) unassigned_orders.append(o) return assignments, unassigned_orders logger.info(f"Found {len(valid_riders)} active riders") # 2. CLUSTER ORDERS BY KITCHEN PROXIMITY clustering_service = ClusteringService() clusters = clustering_service.cluster_orders_by_kitchen( orders, max_cluster_radius_km=self.MAX_KITCHEN_DISTANCE_KM ) # 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"]: cluster["kitchen_names"].add(self.get_order_kitchen(order)) 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 # spray 40 orders across 9 riders when 4-5 would suffice. min_riders_needed = max(1, ceil(len(orders) / self.MAX_ORDERS_PER_RIDER)) # Cap at available riders min_riders_needed = min(min_riders_needed, len(valid_riders)) # Fair-share target: maximum orders any single rider should ideally carry. # Used as a per-batch cap so no rider consumes an entire large cluster # while other riders sit idle (the 15-16 vs 3-4 imbalance pattern). target_orders_per_rider = ceil(len(orders) / min_riders_needed) preferred_rider_ids = self._select_coverage_riders( valid_riders, clusters, min_riders_needed, rider_states ) logger.info( f"[MinRider] {len(orders)} orders → min {min_riders_needed} riders needed " f"(capacity {self.MAX_ORDERS_PER_RIDER}/rider). " f"Pre-selected coverage pool: {preferred_rider_ids}" ) # 3. ASSIGN CLUSTERS TO RIDERS (Fill-Before-Spill, Load-Balanced) for cluster_idx, cluster in enumerate(clusters): centroid_lat, centroid_lon = cluster["centroid"] cluster_orders = cluster["orders"] cluster_size = len(cluster_orders) logger.info( f"Assigning cluster {cluster_idx + 1}/{len(clusters)}: {cluster_size} orders at ({centroid_lat:.4f}, {centroid_lon:.4f})" ) # Find best riders for this cluster candidate_riders = [] # Calculate cluster-level profit features (Data Encoding: Engineered Features) cluster_profit = 0.0 cluster_profit_density = 0.0 cluster_geohash = _zone_key(centroid_lat, centroid_lon) for order in cluster_orders: o_lat = float(order.get("pickuplat", 0)) o_lon = float(order.get("pickuplon", 0)) dist = ( self.haversine(centroid_lat, centroid_lon, o_lat, o_lon) if o_lat else 2.0 ) profit_features = self.calculate_order_profit_features(order, dist) cluster_profit += profit_features["profit"] cluster_profit_density += profit_features["profit_density"] avg_profit_density = ( cluster_profit_density / cluster_size if cluster_size > 0 else 0 ) # ── HARD KITCHEN OWNERSHIP (rider_preferences.py = source of truth) ── # Step 1: find which rider IDs are configured for this cluster's kitchens cluster_preferred_rids: set = set() for k_name in cluster["kitchen_names"]: k_lower = k_name.lower() for cfg_rid, cfg_prefs in self.rider_preferences.items(): for p in cfg_prefs: if p.lower() in k_lower or k_lower in p.lower(): cluster_preferred_rids.add(cfg_rid) # Step 2: among those, which are actually in the active fleet with capacity? active_preferred_with_cap = { r["id"] for r in valid_riders if r["id"] in cluster_preferred_rids and rider_states[r["id"]]["count"] < self.MAX_ORDERS_PER_RIDER } # RULE: if at least one configured rider is available with capacity, # only that rider (or those riders) can be assigned this cluster's orders. # Non-configured riders are completely skipped. # If NO configured rider is available (all full / off-duty), open to all. kitchen_has_owner = bool(active_preferred_with_cap) if cluster_preferred_rids: logger.info( f" [Kitchen owner] cluster kitchens={list(cluster['kitchen_names'])} " f"→ configured riders={cluster_preferred_rids} " f"| active+capacity={active_preferred_with_cap} " f"| hard_lock={'YES' if kitchen_has_owner else 'NO (overflow)'}" ) for r in valid_riders: rid = r["id"] r_state = rider_states[rid] # ── HARD KITCHEN LOCK ──────────────────────────────────────── # If this kitchen has an owner and this rider isn't the owner → skip if kitchen_has_owner and rid not in active_preferred_with_cap: continue # Calculate distance to cluster centroid dist = self.haversine( r_state["lat"], r_state["lon"], centroid_lat, centroid_lon ) # Preference flag (SOFT: distance-limit bypass + preference bonus). # Uses learned-augmented prefs so riders who actually serve a # kitchen are steered there even if not in the curated config. prefs = self.soft_preferences.get(rid, []) has_preference = False for k_name in cluster["kitchen_names"]: if any( p.lower() in k_name.lower() or k_name.lower() in p.lower() for p in prefs ): has_preference = True break # Dynamic Limit: 6km default, 10km for preferred kitchens allowed_dist = self.MAX_PICKUP_DISTANCE_KM if has_preference: allowed_dist = max(allowed_dist, 10.0) # Skip if too far if dist > allowed_dist: continue # Calculate workload utilization (0.0 to 1.0) utilization = r_state["count"] / self.MAX_ORDERS_PER_RIDER # ── FILL-BEFORE-SPILL workload scoring ────────────────────── # Goal: fill a started rider to IDEAL_LOAD before routing orders # to a fresh rider. This naturally minimises active rider count. # # • count == 0, not in pre-selected pool → NEW_RIDER_PENALTY # (heavy cost to discourage opening a new rider unnecessarily) # • count == 0, in pre-selected pool → neutral (no penalty) # • 0 < count < IDEAL_LOAD → FILL_BONUS (negative = better score) # bonus grows as the rider is closer to full # • count >= IDEAL_LOAD → linear workload penalty (ML-tuned) # ───────────────────────────────────────────────────────────── # Use the dynamic NEW_RIDER_PENALTY computed in _load_config # (= WORKLOAD_PENALTY_WEIGHT × 1.5, always above any possible # workload score so non-preferred riders never open early). NEW_RIDER_PENALTY = self.NEW_RIDER_PENALTY FILL_BONUS_PER_SLOT = 8.0 # reward per empty slot below ideal_load is_preferred = rid in preferred_rider_ids current_count = r_state["count"] if current_count == 0: if is_preferred: workload_penalty = 0.0 # Pre-selected, neutral cost else: workload_penalty = NEW_RIDER_PENALTY # Discourage extra riders elif current_count < self.IDEAL_LOAD: # Strong incentive to fill an already-started rider slots_remaining = self.IDEAL_LOAD - current_count workload_penalty = -(slots_remaining * FILL_BONUS_PER_SLOT) else: # Above ideal load — apply ML-tuned linear penalty workload_penalty = utilization * self.WORKLOAD_PENALTY_WEIGHT distance_penalty = dist * self.DISTANCE_PENALTY_WEIGHT # Preference bonus (ML-tuned) preference_bonus = self.PREFERENCE_BONUS if has_preference else 0 # Home zone bonus (ML-tuned). Uses learned-augmented home coords. h_lat, h_lon = self.home_locations.get(rid, (0.0, 0.0)) home_bonus = 0 if h_lat != 0: home_dist = self.haversine(h_lat, h_lon, centroid_lat, centroid_lon) if home_dist <= 4.0: home_bonus = self.HOME_ZONE_BONUS_4KM if home_dist <= 2.0: home_bonus = self.HOME_ZONE_BONUS_2KM # PROFIT-AWARE SCORING (Data Encoding: Composite Cost Function) # Higher profit density clusters should get priority (lower score = better) profit_bonus = avg_profit_density * 10 # Scale up profit density signal # Geohash zone proximity bonus # The _simple_geohash produces a binary bit-string (only '0'/'1' chars). # precision=25 → 13 lon bits + 12 lat bits (alternating). # Global lon range (360°) / 2^13 = 0.044° ≈ 4.9 km per cell. # Global lat range (180°) / 2^12 = 0.044° ≈ 4.9 km per cell. # Full 25-char prefix comparison = exact same ~5km cell. h_geohash = _zone_key(h_lat, h_lon) if h_lat != 0 else "" geohash_match_bonus = 0 if h_geohash and cluster_geohash not in ("", "unknown"): if h_geohash == cluster_geohash: geohash_match_bonus = -15 # Negative = better score (≈6km zone match) score = ( workload_penalty + distance_penalty + preference_bonus + home_bonus - profit_bonus + geohash_match_bonus # Profit-aware scoring ) # RESHUFFLE: Add controlled noise so retries explore different riders if reshuffle: noise = random.uniform(-15.0, 15.0) score += noise candidate_riders.append( { "id": rid, "score": score, "distance": dist, "utilization": utilization, "current_load": r_state["count"], # Store per-candidate bonuses so the re-scorer inside the # inner while-loop can reconstruct the full score correctly # (Bug fix: old re-scorer dropped these, reverting to # plain distance+workload after the first batch assignment). "_preference_bonus": preference_bonus, "_home_bonus": home_bonus, "_profit_bonus": profit_bonus, "_geohash_match_bonus": geohash_match_bonus, } ) if not candidate_riders: logger.warning(f"No riders available for cluster {cluster_idx + 1}") for o in cluster_orders: o["unassigned_reason"] = ( f"No riders within {self.MAX_PICKUP_DISTANCE_KM}km radius of kitchen." ) unassigned_orders.append(o) continue # Sort by score (best first) candidate_riders.sort(key=lambda x: x["score"]) # SMART DISTRIBUTION: Split cluster if needed remaining_orders = cluster_orders[:] while remaining_orders and candidate_riders: best_rider = candidate_riders[0] rid = best_rider["id"] r_state = rider_states[rid] # How many orders can this rider take? available_capacity = self.MAX_ORDERS_PER_RIDER - r_state["count"] if available_capacity <= 0: # Rider is full, remove from candidates candidate_riders.pop(0) continue # Decide batch size # ── FAIR-SHARE CAP ──────────────────────────────────────────── # Never give a rider more than (target_orders_per_rider - current) # in a single iteration. This prevents one rider consuming an # entire large cluster (e.g. 15 orders) while 3 others stay idle. # max(1, ...) ensures the loop always makes forward progress even # if the rider has slightly exceeded their target. _fairshare_remaining = max(1, target_orders_per_rider - r_state["count"]) if best_rider["utilization"] < self.WORKLOAD_BALANCE_THRESHOLD: # Rider has capacity — cap at fair share for balanced distribution batch_size = min(available_capacity, len(remaining_orders), _fairshare_remaining) else: # Rider is getting busy, be conservative (IDEAL_LOAD from ML) batch_size = min( self.IDEAL_LOAD - r_state["count"], len(remaining_orders), available_capacity, _fairshare_remaining, ) batch_size = max(1, batch_size) # At least 1 order # Assign batch batch = remaining_orders[:batch_size] remaining_orders = remaining_orders[batch_size:] assignments[rid].extend(batch) # Update rider state r_state["count"] += len(batch) r_state["lat"] = centroid_lat r_state["lon"] = centroid_lon r_state["kitchens"].update(cluster["kitchen_names"]) r_state["workload_score"] = r_state["count"] / self.MAX_ORDERS_PER_RIDER logger.info( f" -> Assigned {len(batch)} orders to Rider {rid} (load: {r_state['count']}/{self.MAX_ORDERS_PER_RIDER})" ) # Re-sort candidates by updated scores (use ML-tuned weights, not hardcoded). # Only the rider that just received orders needs a score update; all others # keep their current scores (their workload didn't change). for candidate in candidate_riders: if candidate["id"] == rid: new_count = rider_states[candidate["id"]]["count"] new_util = new_count / self.MAX_ORDERS_PER_RIDER candidate["utilization"] = new_util candidate["current_load"] = new_count # Reapply fill-before-spill workload component if new_count == 0: _wp = 0.0 if candidate["id"] in preferred_rider_ids else self.NEW_RIDER_PENALTY elif new_count < self.IDEAL_LOAD: _wp = -((self.IDEAL_LOAD - new_count) * 8.0) else: _wp = new_util * self.WORKLOAD_PENALTY_WEIGHT # Bug fix: restore ALL scoring components, not just workload+distance. # The previous code dropped preference_bonus, home_bonus, profit_bonus, # and geohash_match_bonus, reverting to plain distance+workload # for every cluster iteration after the first batch was assigned. candidate["score"] = ( _wp + candidate["distance"] * self.DISTANCE_PENALTY_WEIGHT + candidate["_preference_bonus"] + candidate["_home_bonus"] - candidate["_profit_bonus"] + candidate["_geohash_match_bonus"] ) candidate_riders.sort(key=lambda x: x["score"]) # If any orders left in the cluster after exhaustion of candidates if remaining_orders: # Instead of giving up, keep them in a pool for mandatory assignment unassigned_orders.extend(remaining_orders) # 4. EMERGENCY MANDATORY ASSIGNMENT (Ensures 0 unassigned if riders exist) if unassigned_orders and valid_riders: logger.info( f"[ALERT] Starting Emergency Mandatory Assignment for {len(unassigned_orders)} orders..." ) force_pool = unassigned_orders[:] unassigned_orders.clear() for o in force_pool: # Determine pickup location o_lat, o_lon = self.get_lat_lon(o, prefix="pickup") if o_lat == 0: o["unassigned_reason"] = "Could not geolocate order (0,0)." unassigned_orders.append(o) continue # Find the 'least bad' rider (Closest + Balanced Load) best_emergency_rider = None best_emergency_score = float("inf") for r in valid_riders: rid = r["id"] r_state = rider_states[rid] dist = self.haversine(r_state["lat"], r_state["lon"], o_lat, o_lon) # For emergency: Distance is important, but load prevents one rider taking EVERYTHING # Score = distance + ML-tuned penalty per existing order e_score = dist + (r_state["count"] * self.EMERGENCY_LOAD_PENALTY) if e_score < best_emergency_score: best_emergency_score = e_score best_emergency_rider = rid if best_emergency_rider: assignments[best_emergency_rider].append(o) rider_states[best_emergency_rider]["count"] += 1 logger.info( f" Force-Assigned order {o.get('orderid')} to Rider {best_emergency_rider} (Score: {best_emergency_score:.2f})" ) else: unassigned_orders.append(o) # 5. FINAL REBALANCING (Optional) # Check if any rider is overloaded while others are idle self._rebalance_workload(assignments, rider_states, valid_riders) # 6. Commit State and History self._post_process(assignments, rider_states, state_mgr) # 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) logger.info("FINAL ASSIGNMENT DISTRIBUTION:") for rid, r_orders in sorted(assignments.items()): logger.info(f" Rider {rid}: {len(r_orders)} orders") if unassigned_orders: logger.warning( f" [ALERT] STILL UNASSIGNED: {len(unassigned_orders)} (Reason: No riders online or invalid coords)" ) else: logger.info(" [OK] ALL ORDERS ASSIGNED SUCCESSFULLY") logger.info("=" * 50) return assignments, unassigned_orders def _rebalance_workload( self, assignments: Dict[int, List], rider_states: Dict, valid_riders: List ): """ Rebalance if workload is heavily skewed. Move orders from overloaded riders to idle ones if possible. """ if not assignments: return # Calculate average load — divide by ACTIVE riders only (those with ≥1 order), # not all valid riders. Using all riders artificially lowers avg_load and causes # false-positive over/under-load detection. total_orders = sum(len(rl) for rl in assignments.values()) active_rider_count = sum(1 for r in valid_riders if rider_states[r["id"]]["count"] > 0) avg_load = total_orders / active_rider_count if active_rider_count > 0 else 0 # Find overloaded and underutilized riders overloaded = [] underutilized = [] for r in valid_riders: rid = r["id"] load = rider_states[rid]["count"] if load > avg_load * 1.5 and load > self.IDEAL_LOAD: # 50% above average overloaded.append(rid) elif load < avg_load * 0.5: # 50% below average underutilized.append(rid) if not overloaded or not underutilized: return logger.info( f"Rebalancing: {len(overloaded)} overloaded, {len(underutilized)} underutilized riders" ) # Try to move orders from overloaded to underutilized for over_rid in overloaded: over_orders = assignments[over_rid] over_state = rider_states[over_rid] # Try to offload some orders for under_rid in underutilized: under_state = rider_states[under_rid] under_capacity = self.MAX_ORDERS_PER_RIDER - under_state["count"] if under_capacity <= 0: continue # Find orders that are closer to underutilized rider transferable = [] for order in over_orders: o_lat, o_lon = self.get_lat_lon(order, prefix="pickup") if o_lat == 0: continue dist_to_under = self.haversine( under_state["lat"], under_state["lon"], o_lat, o_lon ) dist_to_over = self.haversine( over_state["lat"], over_state["lon"], o_lat, o_lon ) # Transfer if underutilized rider is closer or similar distance if ( dist_to_under <= self.MAX_PICKUP_DISTANCE_KM and dist_to_under <= dist_to_over * 1.2 ): transferable.append(order) if transferable: # Transfer up to capacity. # over_state["count"] - IDEAL_LOAD is the surplus above ideal; # guard with max(1, ...) so we always move at least one order # and never get a zero/negative transfer_count. surplus = max(1, over_state["count"] - self.IDEAL_LOAD) transfer_count = min( len(transferable), under_capacity, surplus, ) transfer_batch = transferable[:transfer_count] # Move orders for order in transfer_batch: over_orders.remove(order) assignments[under_rid].append(order) # Update states over_state["count"] -= len(transfer_batch) under_state["count"] += len(transfer_batch) logger.info( f" Rebalanced: {len(transfer_batch)} orders from Rider {over_rid} -> {under_rid}" ) def _post_process(self, assignments, rider_states, state_mgr): """Update History and Persistence.""" from app.services.rider.rider_history_service import RiderHistoryService history_service = RiderHistoryService() ts = time.time() for rid, rider_orders in assignments.items(): if not rider_orders: continue # Calculate actual cumulative distance for this rider instead of # the old hardcoded 5.0 km placeholder. total_km = 0.0 for o in rider_orders: try: total_km += float(o.get("actualkms") or o.get("kms") or 0) except (TypeError, ValueError): pass history_service.update_rider_stats(rid, total_km, len(rider_orders)) st = rider_states[rid] # ETA per order ≈ 15 min (used for workload decay in RiderStateManager) state_mgr.states[rid] = { "minutes_remaining": len(rider_orders) * 15, "last_drop_lat": st["lat"], "last_drop_lon": st["lon"], "active_kitchens": st["kitchens"], "last_updated_ts": ts, } state_mgr._save_states()