Files
routesapi/app/routes/optimization.py
2026-07-06 15:15:51 +05:30

1252 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Provider payload optimization endpoints."""
import asyncio as _asyncio
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor
from math import radians, cos, sin, asin, sqrt as _math_sqrt
from typing import Any
from fastapi import APIRouter, Body, Request, Depends, status, HTTPException, Query
from app.controllers.route_controller import RouteController
from app.core.exceptions import APIException
logger = logging.getLogger(__name__)
# ── Thread pool for parallel per-rider TSP route optimization ────────────────
#
# Problem: asyncio.gather on async coroutines that call OR-Tools internally
# does NOT give true CPU parallelism. Python's GIL means only one thread
# runs Python bytecode at a time, so "parallel" async TSP calls actually
# execute sequentially on a single core.
#
# Solution: run each rider's optimize_provider_payload in a ThreadPoolExecutor.
# OR-Tools is a C++ extension that RELEASES the GIL during solving, so threads
# genuinely run on separate CPU cores simultaneously.
#
# Result: 8 riders' routes are solved in parallel instead of serially.
# Typical gain: 8 × 200ms serial → ~200ms parallel (4-8× speedup).
#
# Pool size: capped at 12 workers (enough for max expected simultaneous riders;
# more workers than cores still helps because OR-Tools releases the GIL).
_route_executor = ThreadPoolExecutor(
max_workers=min(12, (os.cpu_count() or 4)),
thread_name_prefix="route-tsp",
)
def _sync_optimize_route(rider_orders: list) -> list:
"""
Thread-pool worker: run optimize_provider_payload inside an isolated event loop.
Design decisions:
- Fresh RouteOptimizer per call: the class is cheap to construct (just
config reads) and creates no shared mutable state, so this is safe
and avoids any future thread-safety risk from shared instances.
- Fresh asyncio event loop per thread: event loops are NOT thread-safe;
each OS thread must own its own loop. asyncio.new_event_loop() +
run_until_complete is the documented way to call async code from a
synchronous thread context.
"""
from app.services.routing.route_optimizer import RouteOptimizer as _RO
_opt = _RO()
_loop = _asyncio.new_event_loop()
try:
return _loop.run_until_complete(
_opt.optimize_provider_payload(rider_orders, start_coords=None)
)
except Exception:
logger.exception("[ThreadTSP] Route optimization failed in worker thread")
return []
finally:
_loop.close()
# ── Background thread pool for ML tasks (logging, retraining, auto-tuning) ───
# Kept deliberately small (2 workers) — these jobs are lightweight SQLite
# writes and in-memory ID3 fits that finish in < 1 s each. They must never
# compete with the route-tsp pool for CPU during a live request.
_ml_executor = ThreadPoolExecutor(
max_workers=2,
thread_name_prefix="ml-bg",
)
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Great-circle distance in km (inline, no external dep)."""
try:
la1, lo1, la2, lo2 = map(radians, [float(lat1), float(lon1), float(lat2), float(lon2)])
dlat = la2 - la1
dlon = lo2 - lo1
a = sin(dlat / 2) ** 2 + cos(la1) * cos(la2) * sin(dlon / 2) ** 2
return 2 * asin(min(1.0, _math_sqrt(a))) * 6371.0
except Exception:
return float("inf")
def _bg_log_assignment(
num_orders: int,
num_riders: int,
hyperparams: dict,
assignments: dict,
unassigned_count: int,
elapsed_ms: float,
) -> None:
"""Background worker: log the assignment event. Runs in _ml_executor so
it never blocks the API response."""
try:
from app.services.ml.ml_data_collector import get_collector as _gc
collector = _gc()
quality_score = collector.log_assignment_event(
num_orders=num_orders,
num_riders=num_riders,
hyperparams=hyperparams,
assignments=assignments,
unassigned_count=unassigned_count,
elapsed_ms=elapsed_ms,
) or 50.0
count = collector.count_records()
logger.debug(f"[ML BG] Logged event #{count}, quality={quality_score:.1f}")
except Exception as _e:
logger.warning(f"[ML BG] Background task failed (non-fatal): {_e}")
router = APIRouter(
prefix="/api/v1/optimization",
tags=["Route Optimization"],
responses={
400: {"description": "Bad request - Invalid input parameters"},
422: {"description": "Validation error - Request validation failed"},
500: {"description": "Internal server error"},
},
)
def get_route_controller() -> RouteController:
"""Dependency injection for route controller."""
return RouteController()
# Legacy single-route endpoint removed; provider flow only.
@router.post(
"/createdeliveries",
status_code=status.HTTP_200_OK,
summary="Optimize provider payload (forwarding paused)",
description="""
Accepts the provider's orders array, reorders it using greedy nearest-neighbor, adds only:
- step (1..N)
- previouskms (distance from previous stop in km)
- cumulativekms (total distance so far in km)
- actualkms (direct pickup-to-delivery distance)
Forwarding is temporarily paused: returns the optimized array in the response.
""",
responses={
200: {
"description": "Upstream response",
"content": {
"application/json": {
"example": {
"code": 200,
"details": [],
"message": "Success",
"status": True,
}
}
},
}
},
)
async def provider_optimize_forward(
body: list[dict], controller: RouteController = Depends(get_route_controller)
):
"""
Accept provider JSON array, reorder by greedy nearest-neighbor, annotate each item with:
- step (1..N)
- previouskms (km from previous point)
- cumulativekms (km so far)
- actualkms (pickup to delivery distance)
Then forward the optimized array to the external API and return only its response.
"""
try:
url = "https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries"
result = await controller.optimize_and_forward_provider_payload(body, url)
return result
except APIException:
raise
except Exception as e:
logger.error(
f"Unexpected error in provider_optimize_forward: {e}", exc_info=True
)
raise HTTPException(status_code=500, detail="Internal server error")
@router.get("/createdeliveries", summary="Usage info for provider optimize forward")
async def provider_optimize_forward_info():
"""Return usage info; this endpoint accepts POST only for processing."""
return {
"message": "Use POST with a JSON array of orders to optimize and forward.",
"method": "POST",
"path": "/api/v1/optimization/provider-optimize-forward",
}
# ---------------------------------------------------------------------------
# STEP RECONCILIATION
# ---------------------------------------------------------------------------
def _detect_step_anomaly(orders: list[dict]) -> tuple[bool, str]:
"""
Check whether a rider's order list has a clean sequential step sequence.
Returns (anomaly_found, reason_string).
Anomaly cases:
- duplicate step numbers
- gap in step numbers (e.g. 1,2,3,5,6 — missing 4)
- step numbers don't start at 1 for pending-only list
- an order has no "step" field at all
"""
steps = []
for o in orders:
s = o.get("step")
if s is None:
return True, "missing_step"
try:
steps.append(int(s))
except (ValueError, TypeError):
return True, "invalid_step"
if not steps:
return False, "empty"
steps_sorted = sorted(steps)
# Duplicate check
if len(steps_sorted) != len(set(steps_sorted)):
return True, "duplicate"
# Gap / non-sequential check
expected = list(range(steps_sorted[0], steps_sorted[0] + len(steps_sorted)))
if steps_sorted != expected:
return True, "gap"
# Should start from 1 (not from some arbitrary offset)
if steps_sorted[0] != 1:
return True, "wrong_start"
return False, "ok"
@router.post(
"/reconcile-steps",
status_code=status.HTTP_200_OK,
summary="Reconcile step numbers after manual rider reassignment",
description="""
When the operations team manually moves an order from one rider to another,
step numbers become inconsistent:
- Donor rider gets a gap (steps 1,2,3,5,6 — step 4 was transferred)
- Recipient rider gets a new order with a conflicting or missing step
This endpoint:
1. Detects anomalies per rider (gap, duplicate, missing, wrong order)
2. Separates each rider's already-delivered orders from pending ones
3. Re-runs the route optimizer on the pending orders to get the best sequence
4. Returns clean sequential steps starting from 1 for each rider
Input:
{
"riders": [
{
"rider_id": 883,
"orders": [ ...full order objects with current step values... ]
}
]
}
The "orders" array for each rider should include ALL current orders
(delivered + pending). The endpoint determines delivered status from
the "deliverytime" field (non-null/non-empty = delivered).
""",
)
async def reconcile_steps(body: Any = Body(default=None)):
if not body or not isinstance(body, dict):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail='Body must be {"riders": [{"rider_id": X, "orders": [...]}]}',
)
riders_input: list = body.get("riders") or []
if not riders_input:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No riders provided.",
)
from app.services.routing.route_optimizer import RouteOptimizer
results = []
for rider_block in riders_input:
rider_id = rider_block.get("rider_id") or rider_block.get("userid")
all_orders: list[dict] = rider_block.get("orders") or []
# Resolve rider name: explicit input > order username field > fallback
rider_name = (
rider_block.get("rider_name")
or rider_block.get("username")
or next(
(o.get("username") or o.get("rider") for o in all_orders
if o.get("username") or o.get("rider")),
None,
)
or f"Rider {rider_id}"
)
if not all_orders:
results.append({
"rider_id": rider_id,
"rider_name": rider_name,
"anomaly_detected": False,
"anomaly_type": "empty",
"delivered_count": 0,
"pending_count": 0,
"resequenced": False,
"orders": [],
})
continue
# ── Split delivered vs pending ──────────────────────────────────────
# Delivered = deliverytime is non-null and non-empty string.
# Pending = everything else (in-transit or not yet started).
delivered: list[dict] = []
pending: list[dict] = []
for o in all_orders:
dt = o.get("deliverytime")
if dt and str(dt).strip() not in ("", "0", "null", "None"):
delivered.append(dict(o))
else:
pending.append(dict(o))
# ── Detect anomaly on the FULL order list ───────────────────────────
anomaly, reason = _detect_step_anomaly(all_orders)
if not anomaly and not pending:
# Everything delivered, steps clean — nothing to do
results.append({
"rider_id": rider_id,
"rider_name": rider_name,
"anomaly_detected": False,
"anomaly_type": "ok",
"delivered_count": len(delivered),
"pending_count": 0,
"resequenced": False,
"orders": all_orders,
})
continue
# ── Step-value helper (used by both fallback sort and delivered sort) ─
def _step_val(o: dict) -> int:
try:
return int(o.get("step") or 0)
except (ValueError, TypeError):
return 0
# ── Coordinate normalizer ───────────────────────────────────────────
# optimize_provider_payload reads only deliverylat / deliverylong.
# Orders from reconcile callers may carry coords under droplat/droplon
# or dlat/dlon. Normalise so the optimizer always gets valid coords.
def _norm_coords(o: dict) -> dict:
o = dict(o)
def _fv(v):
try: return float(v or 0)
except: return 0.0
if _fv(o.get("deliverylat")) == 0:
o["deliverylat"] = o.get("droplat") or o.get("dlat") or o.get("deliverylat") or ""
if _fv(o.get("deliverylong")) == 0:
o["deliverylong"] = (
o.get("droplon") or o.get("dlon")
or o.get("deliverylong") or ""
)
return o
# ── Re-optimise pending orders ──────────────────────────────────────
# Run the same route optimizer used at assignment time so step order
# matches the greedy + 2-opt algorithm (now with crossings eliminated).
# Fallback: sort by existing step value so at least the sequence is
# deterministic when the optimizer cannot run.
resequenced_pending: list[dict] = sorted(pending, key=_step_val)
if pending:
try:
_opt = RouteOptimizer()
_optimized = await _opt.optimize_provider_payload(
[_norm_coords(o) for o in pending],
start_coords=None,
)
if _optimized:
# Optimizer returns list in route order (index 0 = first stop).
# Use it; otherwise keep the sorted fallback.
resequenced_pending = _optimized
else:
logger.warning(
f"[Reconcile] Optimizer returned empty result for rider "
f"{rider_id} — keeping step-sorted fallback."
)
except Exception as _oe:
logger.warning(
f"[Reconcile] Route re-optimize failed for rider {rider_id} "
f"(non-fatal, keeping step-sorted fallback): {_oe}"
)
# ── Renumber: delivered keep positions 1..N, pending continue after ─
# Sort delivered by their existing step so the sequence is stable.
delivered_sorted = sorted(delivered, key=_step_val)
# Reassign delivered steps cleanly (fills any gap in delivered portion)
for i, o in enumerate(delivered_sorted, start=1):
o["step"] = i
max_delivered_step = len(delivered_sorted)
# Pending steps start immediately after the last delivered step.
# resequenced_pending is already in route order (optimizer output or
# step-sorted fallback), so enumerate order == correct delivery order.
for i, o in enumerate(resequenced_pending, start=max_delivered_step + 1):
o["step"] = i
final_orders = delivered_sorted + resequenced_pending
results.append({
"rider_id": rider_id,
"rider_name": rider_name,
"anomaly_detected": anomaly,
"anomaly_type": reason,
"delivered_count": len(delivered),
"pending_count": len(pending),
"resequenced": True,
"orders": final_orders,
})
logger.info(
f"[Reconcile] Rider {rider_id}: anomaly={reason} "
f"delivered={len(delivered)} pending={len(pending)} "
f"resequenced={len(resequenced_pending)}"
)
return {
"status": True,
"reconciled_riders": len(results),
"riders": results,
}
@router.post(
"/riderassign",
status_code=status.HTTP_200_OK,
summary="Assign created orders to active riders",
description="""
Assigns orders to riders based on kitchen preferences, proximity, and load.
- If a payload of orders is provided, processes those.
- If payload is empty, fetches all 'created' orders from the external API.
- Fetches active riders and matches them.
""",
responses={
200: {
"description": "Assignment Result",
"content": {
"application/json": {
"example": {
"code": 200,
"details": {"1234": [{"orderid": "..."}]},
"message": "Success",
"status": True,
}
}
},
}
},
)
async def assign_orders_to_riders(
request: Request,
body: Any = Body(default=None),
reshuffle: bool = Query(False, alias="reshuffle"),
):
"""
Smart assignment of orders to riders.
Accepts two payload formats:
- Legacy: flat JSON array of order objects
- New: {"deliveries": [...], "absent_riders": [{"userid": 123, "username": "..."}]}
Riders listed in absent_riders are excluded from all assignment phases for
this call — even if they are the best pattern match.
"""
from app.services.rider.get_active_riders import (
fetch_active_riders,
fetch_created_orders,
)
from app.services.core.assignment_service import AssignmentService
from app.services.routing.route_optimizer import RouteOptimizer
from app.services.routing.empirical_eta_calculator import EmpiricalETACalculator
from datetime import datetime, timedelta
from dateutil.parser import parse as parse_date
import asyncio
eta_calculator = EmpiricalETACalculator()
try:
_t0 = time.time() # wall-clock start for elapsed_ms logging
# ── Parse new payload format ─────────────────────────────────────────
# {"deliveries": [...], "absent_riders": [{"userid": X}, ...]}
# vs legacy flat list [...].
absent_rider_ids: set = set()
if isinstance(body, dict):
_absent_list = body.get("absent_riders") or []
for _ar in _absent_list:
try:
_arid = int(_ar.get("userid") or _ar.get("id") or 0)
if _arid:
absent_rider_ids.add(_arid)
except (ValueError, TypeError):
pass
body = body.get("deliveries") or []
if absent_rider_ids:
logger.info(
f"[Absent] Excluding {len(absent_rider_ids)} absent riders "
f"from this batch: {sorted(absent_rider_ids)}"
)
# Accept both ?reshuffle and legacy typo variants in URL for backwards compat
q_params = request.query_params
do_reshuffle = reshuffle or any(
k in q_params for k in ["resuffle", "rehuffle"]
)
# 1. Fetch riders and (if body is empty) orders in parallel.
if body:
riders = await fetch_active_riders()
orders = body
logger.info(f"[PROCESS] Received {len(orders)} orders from payload.")
else:
logger.info(
"[PROCESS] No payload — fetching riders and orders in parallel."
)
riders, orders = await asyncio.gather(
fetch_active_riders(),
fetch_created_orders(),
)
if orders:
logger.info(f"[PROCESS] Fetched {len(orders)} created orders from external API.")
else:
logger.info("[PROCESS] No created orders returned from external API.")
fuel_charge = 2.5
base_pay = 0.0
# 2. Validate absent_rider_ids against the live roster
_live_ids: set = {
int(r.get("userid") or r.get("riderid") or r.get("id") or 0)
for r in riders
} - {0}
if absent_rider_ids:
_unknown_absent = absent_rider_ids - _live_ids
_confirmed_absent = absent_rider_ids & _live_ids
if _unknown_absent:
logger.warning(
f"[Absent] Rider ID(s) not in active roster (typo?): "
f"{sorted(_unknown_absent)} — these will have no effect."
)
if _confirmed_absent:
logger.info(
f"[Absent] Confirmed absent (in roster, now excluded): "
f"{sorted(_confirmed_absent)}"
)
else:
_unknown_absent = set()
_confirmed_absent = set()
# 3. Log summary after all data is in hand
mode_str = "reshuffle" if do_reshuffle else "normal"
logger.info(
f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"[API HIT] POST /api/v1/optimization/riderassign\n"
f"[CONFIG] Mode: {mode_str.upper()} | Active Riders: {len(riders)}\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
logger.info(f"[API] riderassign ▶ orders={len(orders)} riders={len(riders)} mode={mode_str}")
if not orders:
return {
"code": 200,
"details": {},
"message": "No orders found to assign.",
"status": True,
"meta": {"active_riders_count": len(riders)},
}
_faiss_corrected = 0
_faiss_store = None
# 2b. FAISS coordinate verification — correct noisy delivery coords
# using historically verified rider delivery positions.
try:
from app.services.vector.faiss_customer_store import get_faiss_store
_faiss_store = get_faiss_store()
_faiss_corrected = 0
for _order in orders:
# Resolve phone number across common field name variants
_phone = (
_order.get("customerphonenumber")
or _order.get("customerphone")
or _order.get("phonenumber")
or _order.get("mobile")
or _order.get("phone")
or _order.get("customer_phone")
)
_name = (
_order.get("customername")
or _order.get("customer_name")
or _order.get("deliveryname")
or ""
)
try:
_raw_lat = float(
_order.get("deliverylat") or _order.get("droplat") or 0
)
_raw_lon = float(
_order.get("deliverylong") or _order.get("droplon") or 0
)
except (ValueError, TypeError):
_raw_lat, _raw_lon = 0.0, 0.0
if not _phone or _raw_lat == 0.0 or _raw_lon == 0.0:
continue
_v_lat, _v_lon, _corrected = _faiss_store.get_verified_coords(
_phone, _name, _raw_lat, _raw_lon
)
if _corrected:
# Replace delivery coords with verified historical coords
_order["deliverylat"] = _v_lat
_order["deliverylong"] = _v_lon
if "droplat" in _order:
_order["droplat"] = _v_lat
if "droplon" in _order:
_order["droplon"] = _v_lon
_order["_coord_source"] = "faiss_verified"
_faiss_corrected += 1
else:
_order["_coord_source"] = "input"
logger.info(
f"[FAISS] Verified {_faiss_corrected} out of {len(orders)} orders "
f"using historical coordinates (from {_faiss_store.record_count()} stored records)."
)
except Exception as _fe:
logger.warning(f"[FAISS] Coord verification failed (non-fatal): {_fe}")
# ── PHASE 0: FAISS DELIVERY HISTORY — pre-assign well-known routes ────
# For each order look up the rider who covered that kitchen → delivery
# area most in the last 30-day CSV. Only pre-assigns when:
# (a) that rider wins ≥ 50 % of filtered historical neighbours
# (b) that rider is currently active
# (c) that rider is not blocked
# Remaining orders go to the VRP solver as normal.
history_assignments: dict = {}
history_pre_assigned_ids: set = set()
_history_hits = 0
_KITCHEN_KEYS_PH0 = [
"pickupcustomer", "locationname", "storename", "store_name",
"restaurantname", "restaurant_name", "kitchenname", "kitchen_name",
"partnername", "partner_name", "tenantname",
]
try:
from app.services.vector.delivery_history_store import get_delivery_history_store
from app.config.rider_preferences import BLOCKED_RIDERS as _BLOCKED_H
_hist_store = get_delivery_history_store()
if _hist_store.record_count() > 0:
# Build set of active, non-blocked, non-absent rider IDs for validation
_active_rider_ids: set = set()
for _r in riders:
try:
_rid0 = int(_r.get("userid") or _r.get("riderid") or _r.get("id") or 0)
if _rid0 and _rid0 not in _BLOCKED_H and _rid0 not in absent_rider_ids:
_active_rider_ids.add(_rid0)
except (ValueError, TypeError):
pass
for _order in orders:
# Extract kitchen name
_kitchen_h = ""
for _kk in _KITCHEN_KEYS_PH0:
_kv = _order.get(_kk)
if _kv and str(_kv).strip():
_kitchen_h = str(_kv).strip()
break
try:
_h_plat = float(_order.get("pickuplat") or 0)
_h_plon = float(_order.get("pickuplon") or _order.get("pickuplong") or 0)
_h_dlat = float(_order.get("deliverylat") or _order.get("droplat") or 0)
_h_dlon = float(_order.get("deliverylong") or _order.get("droplon") or 0)
except (TypeError, ValueError):
continue
if not _h_dlat:
continue # no delivery coords, skip
_hit = _hist_store.find_rider(
_kitchen_h, _h_plat, _h_plon, _h_dlat, _h_dlon
)
if _hit and _hit["userid"] in _active_rider_ids:
_h_rid = _hit["userid"]
history_assignments.setdefault(_h_rid, []).append(_order)
history_pre_assigned_ids.add(id(_order))
_history_hits += 1
logger.info(
f"[FAISS History] Pre-assigned {_history_hits}/{len(orders)} orders "
f"from delivery history ({_hist_store.record_count()} records). "
f"Riders: {list(history_assignments.keys())}"
)
else:
logger.info("[FAISS History] Store empty — skipping history pre-assignment.")
except Exception as _he:
logger.warning(f"[FAISS History] Pre-assignment failed (non-fatal): {_he}")
# Orders not covered by FAISS history go to the VRP / 2-phase solver.
vrp_orders = [o for o in orders if id(o) not in history_pre_assigned_ids]
# 3. Run Assignment (AssignmentService)
from app.config.dynamic_config import get_config
_cfg = get_config()
optimizer = RouteOptimizer()
# ── PHASE 3a: TRUE VRP (primary solver) ──────────────────────────────
# Attempt to solve assignment + routing simultaneously with OR-Tools VRP.
# This is globally optimal — one model for all riders and all orders.
# Falls back to 2-phase if OR-Tools unavailable or solver finds no solution.
# ─────────────────────────────────────────────────────────────────────
vrp_assignments: dict = {}
unassigned_orders: list = []
used_vrp = False
if not do_reshuffle: # VRP not meaningful during reshuffle (intentional exploration)
try:
# Build minimal rider info for VRP
from app.services.routing.gps_smoother import smooth_rider_locations
_smooth_riders = smooth_rider_locations(list(riders))
from app.services.core.assignment_service import AssignmentService as _AS
_svc_tmp = _AS()
# Import the single source-of-truth blocked-rider set.
from app.config.rider_preferences import BLOCKED_RIDERS as _BLOCKED_RIDERS
rider_data_for_vrp = []
for r in _smooth_riders:
rid_raw = r.get("userid") or r.get("riderid") or r.get("id")
try:
rid = int(rid_raw)
except (ValueError, TypeError):
continue
if rid in _BLOCKED_RIDERS or rid in absent_rider_ids:
continue
lat, lon = _svc_tmp.get_lat_lon(r)
if lat == 0 or lon == 0:
continue
rider_data_for_vrp.append({"id": rid, "lat": lat, "lon": lon})
if rider_data_for_vrp:
import math as _math
# Hard cap at 12 orders/rider regardless of what ML tunes.
# This gives ceil(35/12) = 3 riders for a typical 35-order load,
# which matches the real-world target for this fleet.
# Preference discounts (below) steer each of those 3 riders
# toward their own kitchen's orders — so cap=12 is safe.
_max_opr = int(_cfg.get("max_orders_per_rider", 12))
_vrp_cap = min(_max_opr, 12) # never let ML push this above 12
logger.info(
f"[VRP] Capacity: {_vrp_cap}/rider | "
f"{len(vrp_orders)} orders (of {len(orders)} total, "
f"{_history_hits} pre-assigned by FAISS history) / "
f"{len(rider_data_for_vrp)} riders → "
f"expect ≈{_math.ceil(max(1, len(vrp_orders)) / _vrp_cap)} active riders"
)
vrp_result = optimizer.solve_vrp_multi_rider(
orders=vrp_orders, # excludes Phase-0 pre-assigned orders
rider_data=rider_data_for_vrp,
max_orders_per_rider=_vrp_cap,
soft_prefs=_svc_tmp.soft_preferences,
soft_home=_svc_tmp.home_locations,
)
if vrp_result:
vrp_assignments = vrp_result
# Orders not in any VRP route AND not in Phase-0 → unassigned
_vrp_assigned_ids = {
id(o) for rider_ords in vrp_assignments.values() for o in rider_ords
}
unassigned_orders = [
o for o in orders
if id(o) not in _vrp_assigned_ids
and id(o) not in history_pre_assigned_ids
]
used_vrp = True
# Removed logging from here, moved to after both solvers run
except Exception as _vrp_err:
logger.warning(f"[VRP] Primary solver skipped: {_vrp_err}")
# ── PHASE 3b: 2-PHASE FALLBACK (cluster → score → assign) ────────────
if not used_vrp:
service = AssignmentService()
_available_riders = [
r for r in riders
if int(r.get("userid") or r.get("riderid") or r.get("id") or 0)
not in absent_rider_ids
] if absent_rider_ids else riders
vrp_assignments, _fb_unassigned = await service.assign_orders(
riders=_available_riders,
orders=vrp_orders, # excludes Phase-0 pre-assigned orders
fuel_charge=fuel_charge,
base_pay=base_pay,
reshuffle=do_reshuffle,
)
# Unassigned = 2-phase leftovers (history orders are always assigned)
unassigned_orders = list(_fb_unassigned)
# Merge Phase-0 FAISS history pre-assignments into VRP/fallback result
for _h_rid, _h_ords in history_assignments.items():
if _h_rid in vrp_assignments:
# Prepend history orders (rider knows these routes best)
vrp_assignments[_h_rid] = list(_h_ords) + vrp_assignments[_h_rid]
else:
vrp_assignments[_h_rid] = list(_h_ords)
assignments = vrp_assignments
# ── SOLO RIDER CONSOLIDATION ─────────────────────────────────────────
# A rider deployed for a single order is almost always a net loss.
# Rule: if a rider has exactly 1 order and another rider is already
# running a route from the same (or nearby) kitchen, transfer the
# order there instead of deploying a solo trip.
#
# Priority for matching a host:
# 0 → same pickuplocationid (same kitchen counter, zero extra pickup)
# 1 → same kitchen name (logical same kitchen, different ID edge-case)
# 2 → pickup within 1.5 km (very close kitchen cluster)
#
# Among equal-priority hosts: pick the one whose delivery centroid is
# nearest to the solo order's drop point (minimises route extension).
#
# Pass 1: merge solos into multi-order riders (≥ 2 orders).
# Pass 2: merge remaining solos with each other (same kitchen only).
#
# Hard cap: never pile > 10 orders onto one host.
# Safety: if no suitable host found, keep the solo assignment as-is
# (unassigned is worse than a solo trip).
# ─────────────────────────────────────────────────────────────────────
_SOLO_MAX_PICKUP_KM = 1.5
_SOLO_ORDER_CAP = 10
_consolidation_moves = 0
def _ord_pickup_locid(o: dict) -> int:
try: return int(o.get("pickuplocationid") or 0)
except: return 0
def _ord_kitchen(o: dict) -> str:
for _k in ("pickupcustomer", "locationname", "tenantname"):
_v = o.get(_k, "")
if _v: return str(_v).strip().lower()
return ""
def _ord_pickup_coords(o: dict):
try:
return (float(o.get("pickuplat") or 0),
float(o.get("pickuplon") or o.get("pickuplong") or 0))
except: return (0.0, 0.0)
def _delivery_centroid(ords: list):
lats = [float(o.get("deliverylat") or o.get("droplat") or 0) for o in ords]
lons = [float(o.get("deliverylong") or o.get("droplon") or 0) for o in ords]
lats = [x for x in lats if x]; lons = [x for x in lons if x]
if not lats: return (0.0, 0.0)
return (sum(lats) / len(lats), sum(lons) / len(lons))
# Rider efficiency scores (from 30-day CSV history) used as tiebreaker
_rider_efficiency: dict = {}
try:
from app.services.vector.delivery_history_store import get_delivery_history_store as _gds
_rider_efficiency = _gds().get_rider_efficiency_scores()
except Exception:
pass
def _best_host(solo_order: dict, host_pool: dict,
min_host_orders: int) -> "tuple | None":
"""Return (host_rider_id, score) or None if no match.
Score tuple: (priority, drop_dist, -efficiency)
lower priority = better kitchen match
lower drop_dist = less route extension
higher efficiency = prefer that rider as tiebreaker
"""
_s_locid = _ord_pickup_locid(solo_order)
_s_kname = _ord_kitchen(solo_order)
_s_plat, _s_plon = _ord_pickup_coords(solo_order)
try:
_s_dlat = float(solo_order.get("deliverylat") or solo_order.get("droplat") or 0)
_s_dlon = float(solo_order.get("deliverylong") or solo_order.get("droplon") or 0)
except: _s_dlat = _s_dlon = 0.0
_best_rid, _best_score = None, None
for _hrid, _hords in host_pool.items():
if len(_hords) < min_host_orders or len(_hords) >= _SOLO_ORDER_CAP:
continue
_h0 = _hords[0]
_h_locid = _ord_pickup_locid(_h0)
_h_kname = _ord_kitchen(_h0)
_h_plat, _h_plon = _ord_pickup_coords(_h0)
_pickup_dist = (
_haversine_km(_s_plat, _s_plon, _h_plat, _h_plon)
if _s_plat and _h_plat else 999.0
)
if _s_locid and _h_locid and _s_locid == _h_locid: _pri = 0
elif _s_kname and _h_kname and _s_kname == _h_kname: _pri = 1
elif _pickup_dist <= _SOLO_MAX_PICKUP_KM: _pri = 2
else: continue
_centroid = _delivery_centroid(_hords)
_drop_dist = (
_haversine_km(_s_dlat, _s_dlon, _centroid[0], _centroid[1])
if _s_dlat and _centroid[0] else 999.0
)
# Efficiency tiebreaker: higher score = better host (negate for min-sort)
_eff = _rider_efficiency.get(_hrid, {}).get("efficiency_score", 0.5)
_score = (_pri, round(_drop_dist, 2), round(1.0 - _eff, 4))
if _best_score is None or _score < _best_score:
_best_score = _score; _best_rid = _hrid
return (_best_rid, _best_score) if _best_rid is not None else None
# --- Pass 1: solo → multi-order host (≥ 2 existing orders) ----------
_solo_rids = [rid for rid, ords in assignments.items() if len(ords) == 1]
_active_pool = {rid: ords for rid, ords in assignments.items() if len(ords) >= 2}
for _srid in _solo_rids:
if not assignments.get(_srid): continue # already cleared
_solo_ord = assignments[_srid][0]
_match = _best_host(_solo_ord, _active_pool, min_host_orders=2)
if _match:
_hrid, _hscore = _match
_active_pool[_hrid].append(_solo_ord)
assignments[_hrid] = _active_pool[_hrid]
assignments[_srid] = []
_consolidation_moves += 1
logger.info(
f"[Consolidate] Rider {_srid} (1 order) → merged into rider "
f"{_hrid} (now {len(_active_pool[_hrid])} orders, "
f"kitchen-priority={_hscore[0]}, drop-dist={_hscore[1]:.1f}km)"
)
# --- Pass 2: remaining solos → each other (same kitchen only) --------
_still_solo = [rid for rid in _solo_rids if len(assignments.get(rid, [])) == 1]
_solo_pool = {rid: assignments[rid] for rid in _still_solo}
_merged_in_p2: set = set()
for _srid in _still_solo:
if _srid in _merged_in_p2: continue
_solo_ord = assignments[_srid][0]
# Only same-kitchen merges in pass 2 (pickup_locid or name match)
_candidates = {
rid: ords for rid, ords in _solo_pool.items()
if rid != _srid and rid not in _merged_in_p2
}
_match = _best_host(_solo_ord, _candidates, min_host_orders=1)
if _match:
_hrid, _hscore = _match
if _hscore[0] <= 1: # only locid/name matches in pass 2
assignments[_hrid].append(_solo_ord)
assignments[_srid] = []
_merged_in_p2.add(_srid)
_consolidation_moves += 1
logger.info(
f"[Consolidate-P2] Solo {_srid} merged into solo "
f"{_hrid} (now {len(assignments[_hrid])} orders, "
f"kitchen-priority={_hscore[0]})"
)
if _consolidation_moves:
logger.info(
f"[Consolidate] {_consolidation_moves} solo order(s) merged into "
f"existing routes — {len([r for r in _solo_rids if not assignments.get(r)])} "
f"rider(s) freed up."
)
# Log outcome for whichever solver was used
assigned_count = sum(len(v) for v in assignments.values())
solver_name = "VRP Optimal" if used_vrp else "2-Phase Heuristic"
logger.info(
f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
f"[SUMMARY] Solver Used: {solver_name}\n"
f"[OUTCOME] Assigned {assigned_count} out of {len(orders)} orders.\n"
f"[RIDERS] Utilized {len([v for v in assignments.values() if v])} out of {len(riders)} active riders.\n"
f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
)
# No explicit restore needed — contextvars are scoped to this async task.
# When the task ends the ContextVar value is automatically discarded.
if do_reshuffle:
logger.info(
"[RESHUFFLE] Retry mode active - exploring alternative rider assignments."
)
# 4. Optimize Routes for Each Rider and Flatten Response
# (OR-Tools TSP + 2-opt per rider, now with time windows if pickupSlot present)
flat_orders_list = []
# Build an ordered list of (rider_id, orders) pairs so we can align
# task_contexts with results after the parallel gather.
task_items = [
(rider_id, rider_orders)
for rider_id, rider_orders in assignments.items()
if rider_orders
]
task_contexts = [rider_id for rider_id, _ in task_items]
total_assigned = 0
if task_items:
# ── TRUE PARALLEL TSP via ThreadPoolExecutor ─────────────────────
# OR-Tools (C++ extension) releases the GIL during solving, so
# threads genuinely run on separate CPU cores at the same time.
# asyncio.get_running_loop().run_in_executor schedules each worker
# on the thread pool and yields control to the event loop while
# the threads compute, keeping FastAPI fully responsive.
loop = asyncio.get_running_loop()
results = await asyncio.gather(*[
loop.run_in_executor(
_route_executor,
_sync_optimize_route,
rider_orders,
)
for _, rider_orders in task_items
])
# Create a lookup for rider details
rider_info_map = {}
for r in riders:
# Use string conversion for robust ID matching
r_id = str(r.get("userid") or r.get("_id", ""))
if r_id:
rider_info_map[r_id] = {
"name": r.get("username", ""),
"contactno": r.get("contactno", ""),
}
# Process results matching them back to riders
for stored_rider_id, optimized_route in zip(task_contexts, results):
r_id_str = str(stored_rider_id)
r_info = rider_info_map.get(r_id_str, {})
rider_name = r_info.get("name", "")
rider_contact = r_info.get("contactno", "")
# Calculate total distance for this rider
total_rider_kms = 0
if optimized_route:
# Usually the last order has the max cumulative kms if steps are 1..N
try:
total_rider_kms = max(
[float(o.get("cumulativekms", 0)) for o in optimized_route]
)
except:
total_rider_kms = sum(
[
float(o.get("actualkms", o.get("kms", 0)))
for o in optimized_route
]
)
for order in optimized_route:
order["userid"] = stored_rider_id
order["username"] = rider_name
# Populate the specific fields requested by the user
order["rider"] = rider_name
order["ridercontactno"] = rider_contact
order["riderkms"] = str(round(total_rider_kms, 2))
# --- DYNAMIC ETA COMPUTATION -----------------------------
# Try various cases and names for pickup slot
pickup_slot_str = (
order.get("pickupSlot")
or order.get("pickupslot")
or order.get("pickup_slot")
or order.get("pickuptime")
)
if pickup_slot_str:
try:
# Robust date parsing (handles almost any format magically)
pickup_time = parse_date(str(pickup_slot_str))
# Use cumulative_eta (total time from kitchen → this stop)
# if the route optimizer produced it; fall back to
# a fresh per-leg calculation using cumulativekms.
if order.get("cumulative_eta"):
eta_mins = int(order["cumulative_eta"])
else:
dist_km = float(
order.get("cumulativekms")
or order.get("actualkms", order.get("kms", 0))
)
step = int(order.get("step", 1))
order_type = order.get("ordertype", "Economy")
from app.services.routing.realistic_eta_calculator import get_time_of_day_category
_dcoords = None
try:
_dlat = float(order.get("deliverylat") or order.get("droplat") or 0)
_dlon = float(order.get("deliverylong") or order.get("droplon") or 0)
if _dlat and _dlon:
_dcoords = (_dlat, _dlon)
except (TypeError, ValueError):
_dcoords = None
eta_mins = eta_calculator.calculate_eta(
distance_km=dist_km,
is_first_order=(step == 1),
order_type=order_type,
time_of_day=get_time_of_day_category(),
kitchen=order.get("pickupcustomer") or order.get("locationname"),
drop_coords=_dcoords,
rider_id=order.get("userid"),
)
expected_time = pickup_time + timedelta(minutes=eta_mins)
# Format output as requested: "2026-03-24 08:25 AM"
order["expectedDeliveryTime"] = expected_time.strftime(
"%Y-%m-%d %I:%M %p"
)
order["transitMinutes"] = eta_mins
order["calculationDistanceKm"] = round(
float(order.get("cumulativekms") or order.get("actualkms", 0)), 2
)
except Exception as e:
logger.warning(
f"Could not calculate ETA from pickupSlot '{pickup_slot_str}': {e}"
)
# ---------------------------------------------------------
flat_orders_list.append(order)
total_assigned += len(optimized_route)
# The ID3 "risk" tree was retired (it never affected assignment). Key kept
# static so existing response consumers don't break.
risk_meta: dict = {"label": "n/a", "model_trained": False, "deprecated": True}
# ── BACKGROUND ML LOGGING ────────────────────────────────────────────
# Fire-and-forget: log this assignment event to SQLite.
# Uses _ml_executor so the API response is never delayed.
try:
_elapsed_ms = (time.time() - _t0) * 1000
_hyp_snapshot = get_config().get_all() # frozen copy for this call
_ml_executor.submit(
_bg_log_assignment,
len(orders),
len(riders),
_hyp_snapshot,
{rid: list(ords) for rid, ords in assignments.items()},
len(unassigned_orders),
round(_elapsed_ms, 1),
)
except Exception as _mle:
logger.debug(f"[ML BG] Submit failed (non-fatal): {_mle}")
# ── Compact exit trace (grep one request_id to see the whole request) ──
try:
from app.services.routing.delivery_history_service import get_delivery_history_service
_road_on = bool(get_config().get("routing_use_road_distance", False))
_eta_src = (
"empirical"
if (get_config().get("eta_empirical_enabled", True)
and get_delivery_history_service().has_data())
else "formula"
)
logger.info(
f"[API] riderassign ◀ solver={'vrp_optimal' if used_vrp else '2phase'} "
f"assigned={total_assigned}/{len(orders)} unassigned={len(unassigned_orders)} "
f"riders_used={len([v for v in assignments.values() if v])}/{len(riders)} "
f"road_seq={'on' if _road_on else 'off'} eta={_eta_src} "
f"elapsed={round(_elapsed_ms)}ms"
)
except Exception:
pass
# 5. Zone Processing
from app.services.routing.zone_service import ZoneService
zone_service = ZoneService()
zone_data = zone_service.group_by_zones(
flat_orders_list,
unassigned_orders,
fuel_charge=fuel_charge,
base_pay=base_pay,
)
zones_structure = zone_data["detailed_zones"]
zone_analysis = zone_data["zone_analysis"]
return {
"code": 200,
"zone_summary": zone_analysis, # High-level zone metrics
"zones": zones_structure, # Detailed data
"details": flat_orders_list, # Flat list
"message": "Success",
"status": True,
"meta": {
"total_orders": len(orders),
"utilized_riders": len([rid for rid, rl in assignments.items() if rl]),
"active_riders_pool": len(riders),
"assigned_orders": total_assigned,
"unassigned_orders": len(unassigned_orders),
"total_profit": round(sum(z["total_profit"] for z in zone_analysis), 2),
"fuel_charge_base": fuel_charge,
"unassigned_details": [
{
"orderid": o.get("orderid") or o.get("_id"),
"reason": o.get(
"unassigned_reason", "Unknown capacity/proximity issue"
),
}
for o in unassigned_orders
],
"distribution_summary": {
rid: len(rl) for rid, rl in assignments.items() if rl
},
"reshuffle_mode": do_reshuffle,
"solver_mode": "vrp_optimal" if used_vrp else "2phase_heuristic",
"faiss_coord_corrections": _faiss_corrected,
"faiss_customer_records": _faiss_store.record_count() if _faiss_store else 0,
"faiss_history_preassigned": _history_hits,
"solo_consolidations": _consolidation_moves,
"absent_riders_excluded": sorted(_confirmed_absent),
"absent_riders_unknown": sorted(_unknown_absent),
"risk_assessment": risk_meta,
},
}
except Exception as e:
logger.error(f"Error in rider assignment: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail="Internal server error during assignment"
)