Initial commit

This commit is contained in:
2026-06-22 17:40:08 +05:30
commit c742ef0e53
308 changed files with 68519 additions and 0 deletions

View File

@@ -0,0 +1,860 @@
"""
Batch Efficiency Analyser
=========================
Pure-function service that analyses a delivery batch and returns:
- fleet_summary : aggregate metrics + load-balance scores
- rider_timelines : per-rider start/finish/pace/utilisation
- substitution_opportunities : ranked, scored transfer plans
- top_recommendation : best action with confidence, root-cause & risk factors
No DB access here. The route handler owns fetching; this layer is
fully testable with any list of dicts.
Expected delivery dict keys (all optional except userid):
userid : int rider id
pickupcustomer : str e.g. "Daily Grubs Bhuvaneshwari"
assigntime : str "YYYY-MM-DD HH:MM:SS"
pickuptime : str "YYYY-MM-DD HH:MM:SS" or None
deliverytime : str "YYYY-MM-DD HH:MM:SS" or None
dlat / droplat : float delivery lat (either key accepted)
dlon / droplon : float delivery lon (either key accepted)
deliveryid : int order id (for transfer manifests)
"""
from __future__ import annotations
import math
import statistics
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
DEFAULT_KITCHEN_COORDS: dict[str, tuple[float, float]] = {
"vidhya": (11.01633, 77.01478),
"jayanthi": (11.03887, 76.93008),
"nandhini": (11.04324, 77.00068),
"bhuvaneshwari": (11.00352, 76.95455),
"selvarani": (10.99274, 77.00535),
}
DEFAULT_KITCHEN_FRAGMENTS: list[str] = [
"bhuvaneshwari", "jayanthi", "nandhini", "vidhya", "selvarani"
]
DEFAULT_ROAD_KMH: float = 13.0
DEFAULT_IDLE_THRESHOLD_MIN: float = 30.0
DEFAULT_MAX_TRANSFER: int = 4
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _hav(la1: float, lo1: float, la2: float, lo2: float) -> float:
R = 6371.0
la1, lo1, la2, lo2 = map(math.radians, [la1, lo1, la2, lo2])
a = (math.sin((la2 - la1) / 2) ** 2
+ math.cos(la1) * math.cos(la2) * math.sin((lo2 - lo1) / 2) ** 2)
return R * 2 * math.asin(math.sqrt(max(0.0, min(1.0, a))))
def _travel_min(km: float, kmh: float = DEFAULT_ROAD_KMH) -> float:
return km / kmh * 60.0
def _parse_ts(s: Any) -> datetime | None:
if not s:
return None
s = str(s).strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _fmt(dt: datetime | None) -> str | None:
return dt.strftime("%H:%M:%S") if dt else None
def _get_coord(order: dict) -> tuple[float, float] | None:
lat = order.get("dlat") or order.get("droplat") or order.get("deliverylat")
lon = order.get("dlon") or order.get("droplon") or order.get("deliverylong")
try:
return float(lat), float(lon)
except (TypeError, ValueError):
return None
def _detect_kitchen(
pickupcustomer: str | None,
fragments: list[str],
) -> str | None:
kl = (pickupcustomer or "").lower()
for frag in fragments:
if frag in kl:
return frag
return None
def _stdev(values: list[float]) -> float:
"""Population stdev; returns 0 for fewer than 2 values."""
if len(values) < 2:
return 0.0
try:
return statistics.stdev(values)
except Exception:
return 0.0
def _score_candidate(
o: dict,
arrive_at_kitchen: datetime,
k_coord: tuple[float, float],
road_kmh: float,
) -> float:
"""
Score a candidate order for transfer to the idle rider.
Higher = better candidate.
Two components:
time_gain : minutes saved vs the original delivery time.
Positive means idle rider genuinely arrives earlier.
geo_penalty: haversine distance from kitchen to the order drop point.
Penalises far orders that inflate the idle rider's extra km.
Orders with negative time_gain (idle rider would be slower) still get a
score, allowing the caller to filter them out with a feasibility check.
"""
d_ts = _parse_ts(o.get("deliverytime"))
coord = _get_coord(o)
if not d_ts or not coord:
return -9999.0
dist_km = _hav(k_coord[0], k_coord[1], coord[0], coord[1])
est_deliver = arrive_at_kitchen + timedelta(minutes=_travel_min(dist_km, road_kmh))
time_gain_min = (d_ts - est_deliver).total_seconds() / 60.0
# Weight: time gain matters more than geography (70/30 split).
# Penalise 2 min per km of extra distance so nearby clusters float up.
return time_gain_min * 0.7 - dist_km * 2.0 * 0.3
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def analyse_batch(
deliveries: list[dict],
rider_names: dict[int, str] | None = None,
kitchen_coords: dict[str, tuple[float, float]] | None = None,
kitchen_fragments: list[str] | None = None,
road_kmh: float = DEFAULT_ROAD_KMH,
idle_threshold_min: float = DEFAULT_IDLE_THRESHOLD_MIN,
max_transfer: int = DEFAULT_MAX_TRANSFER,
) -> dict:
"""
Analyse a delivery batch and return substitution opportunities.
Parameters
----------
deliveries : list of order dicts (see module docstring)
rider_names : optional {userid: name} override
kitchen_coords : optional kitchen pickup coordinates override
kitchen_fragments : optional kitchen detection strings override
road_kmh : estimated loaded-rider road speed
idle_threshold_min: minimum idle window to flag as opportunity
max_transfer : maximum orders to suggest transferring to one rider
"""
if kitchen_coords is None:
kitchen_coords = DEFAULT_KITCHEN_COORDS
if kitchen_fragments is None:
kitchen_fragments = DEFAULT_KITCHEN_FRAGMENTS
if rider_names is None:
rider_names = {}
# ------------------------------------------------------------------
# 1. Group deliveries by rider
# ------------------------------------------------------------------
by_rider: dict[int, list[dict]] = defaultdict(list)
for o in deliveries:
uid = o.get("userid")
if uid is not None:
by_rider[int(uid)].append(o)
if not by_rider:
return {
"fleet_summary": {},
"rider_timelines": [],
"substitution_opportunities": [],
"top_recommendation": None,
"error": "No deliveries with valid userid found.",
}
# ------------------------------------------------------------------
# 2. Per-rider timeline
# ------------------------------------------------------------------
timelines: list[dict] = []
for uid, orders in by_rider.items():
# Detect primary kitchen by majority vote
kitchen_votes: dict[str, int] = defaultdict(int)
for o in orders:
k = _detect_kitchen(o.get("pickupcustomer"), kitchen_fragments)
if k:
kitchen_votes[k] += 1
primary_kitchen = (
max(kitchen_votes, key=kitchen_votes.get) if kitchen_votes else None
)
kitchen_confidence = (
round(kitchen_votes[primary_kitchen] / len(orders), 2)
if primary_kitchen else 0.0
)
# Timestamps
finish_ts = None
start_ts = None
last_coord: tuple[float, float] | None = None
completed_orders = 0
for o in orders:
a = _parse_ts(o.get("assigntime"))
d = _parse_ts(o.get("deliverytime"))
if a and (start_ts is None or a < start_ts):
start_ts = a
if d:
completed_orders += 1
if finish_ts is None or d > finish_ts:
finish_ts = d
coord = _get_coord(o)
if coord:
last_coord = coord
# Active duration and pace
active_minutes: float | None = None
pace_orders_per_hour: float | None = None
if start_ts and finish_ts and finish_ts > start_ts:
active_minutes = round((finish_ts - start_ts).total_seconds() / 60, 1)
pace_orders_per_hour = round(completed_orders / (active_minutes / 60), 1) if active_minutes else None
timelines.append({
"userid": uid,
"name": rider_names.get(uid, f"Rider {uid}"),
"kitchen": primary_kitchen,
"kitchen_confidence": kitchen_confidence,
"order_count": len(orders),
"completed_orders": completed_orders,
"pending_orders": len(orders) - completed_orders,
"started_at": _fmt(start_ts),
"finished_at": _fmt(finish_ts),
"active_minutes": active_minutes,
"pace_orders_per_hour": pace_orders_per_hour,
"_finish_dt": finish_ts,
"_start_dt": start_ts,
"last_position": (
{"lat": round(last_coord[0], 6), "lon": round(last_coord[1], 6)}
if last_coord else None
),
"_last_coord": last_coord,
})
# Sort by finish time
timelines.sort(key=lambda t: t["_finish_dt"] or datetime.min)
# ------------------------------------------------------------------
# 3. Fleet summary
# ------------------------------------------------------------------
valid_start = [t["_start_dt"] for t in timelines if t["_start_dt"]]
valid_finish = [t["_finish_dt"] for t in timelines if t["_finish_dt"]]
fleet_start = min(valid_start) if valid_start else None
fleet_done = max(valid_finish) if valid_finish else None
# Load balance: stdev of order counts and finish-time spread
order_counts = [t["order_count"] for t in timelines]
finish_offsets_min = (
[(f - fleet_start).total_seconds() / 60 for f in valid_finish]
if fleet_start and valid_finish else []
)
finish_spread_min = (
round((max(valid_finish) - min(valid_finish)).total_seconds() / 60)
if len(valid_finish) >= 2 else 0
)
load_balance_stdev = round(_stdev([float(c) for c in order_counts]), 2)
finish_time_stdev = round(_stdev(finish_offsets_min), 1)
# Utilisation: how much of the batch window each rider was actively delivering
batch_duration_min = (
round((fleet_done - fleet_start).total_seconds() / 60)
if fleet_start and fleet_done else None
)
avg_active_minutes = (
round(
sum(t["active_minutes"] for t in timelines if t["active_minutes"])
/ max(1, sum(1 for t in timelines if t["active_minutes"])),
1,
)
if any(t["active_minutes"] for t in timelines) else None
)
avg_utilisation_pct = (
round(avg_active_minutes / batch_duration_min * 100, 1)
if avg_active_minutes and batch_duration_min else None
)
fleet_summary = {
"total_orders": len(deliveries),
"total_riders": len(by_rider),
"fleet_start": _fmt(fleet_start),
"fleet_done": _fmt(fleet_done),
"total_duration_minutes": batch_duration_min,
"orders_per_rider_avg": round(len(deliveries) / len(by_rider), 1),
"load_balance_stdev": load_balance_stdev,
"finish_time_spread_minutes": finish_spread_min,
"finish_time_stdev_minutes": finish_time_stdev,
"avg_utilisation_pct": avg_utilisation_pct,
"avg_active_minutes": avg_active_minutes,
}
if not fleet_done:
return {
"fleet_summary": fleet_summary,
"rider_timelines": _clean_timelines(timelines, fleet_done),
"substitution_opportunities": [],
"top_recommendation": None,
"error": "No completed deliveries found.",
}
# Mark each rider's idle status and free window
for t in timelines:
fd = t["_finish_dt"]
if fd:
idle_min = (fleet_done - fd).total_seconds() / 60.0
t["idle_minutes"] = round(idle_min)
t["free_window_minutes"] = round(idle_min) # time available for substitution
t["status"] = "idle" if idle_min >= idle_threshold_min else "active"
else:
t["idle_minutes"] = 0
t["free_window_minutes"] = 0
t["status"] = "unknown"
# ------------------------------------------------------------------
# 4. Substitution opportunities
# ------------------------------------------------------------------
opportunities: list[dict] = []
idle_riders = [t for t in timelines if t["status"] == "idle" and t["_last_coord"]]
for idle in idle_riders:
idle_uid = idle["userid"]
idle_finish = idle["_finish_dt"]
idle_coord = idle["_last_coord"]
free_window_min = idle["free_window_minutes"]
if idle["kitchen"] is None:
continue # can't determine origin kitchen
for target_kitchen, k_coord in kitchen_coords.items():
if target_kitchen == idle["kitchen"]:
continue # skip own kitchen
# Travel from idle rider's last drop to the target kitchen
travel_km = _hav(idle_coord[0], idle_coord[1], k_coord[0], k_coord[1])
travel_min = _travel_min(travel_km, road_kmh)
# Skip if idle rider can't even reach the kitchen before fleet is done
if travel_min >= free_window_min:
continue
arrive_at_kitchen = idle_finish + timedelta(minutes=travel_min)
# ----------------------------------------------------------
# Candidate selection: score every order from this kitchen
# that was delivered after the idle rider could arrive.
# Score = time_gain (70%) + geo proximity (30%).
# This prefers orders where idle rider is genuinely faster
# AND that are close to the kitchen (less detour).
# ----------------------------------------------------------
candidate_orders: list[tuple[int, dict, float]] = [] # (rid, order, score)
for rid, r_orders in by_rider.items():
if rid == idle_uid:
continue
r_kitchen = next(
(t["kitchen"] for t in timelines if t["userid"] == rid), None
)
if r_kitchen != target_kitchen:
continue
for o in r_orders:
d_ts = _parse_ts(o.get("deliverytime"))
if not d_ts or d_ts <= arrive_at_kitchen:
continue
score = _score_candidate(o, arrive_at_kitchen, k_coord, road_kmh)
candidate_orders.append((rid, o, score))
if not candidate_orders:
continue
# Sort best candidates first (highest score = most time gained, closest)
candidate_orders.sort(key=lambda x: x[2], reverse=True)
take_pool = candidate_orders[:max_transfer]
# Greedy nearest-neighbour route from the kitchen through selected orders
unvisited = list(range(len(take_pool)))
curr_nn = k_coord
greedy_take: list[tuple[int, dict]] = []
while unvisited:
ni = min(
unvisited,
key=lambda i: (
_hav(curr_nn[0], curr_nn[1], *c)
if (c := _get_coord(take_pool[i][1])) else 999.0
),
)
unvisited.remove(ni)
greedy_take.append((take_pool[ni][0], take_pool[ni][1]))
coord = _get_coord(take_pool[ni][1])
if coord:
curr_nn = coord
# Simulate idle rider executing the greedy route
curr_pos = k_coord
est_time = arrive_at_kitchen
transfer_manifests: list[dict] = []
total_delivery_leg_min = 0.0
for orig_rid, o in greedy_take:
coord = _get_coord(o)
if not coord:
continue
d_km = _hav(curr_pos[0], curr_pos[1], coord[0], coord[1])
d_min = _travel_min(d_km, road_kmh)
total_delivery_leg_min += d_min
est_deliver = est_time + timedelta(minutes=d_min)
orig_deliver = _parse_ts(o.get("deliverytime"))
improvement = (
round((orig_deliver - est_deliver).total_seconds() / 60)
if orig_deliver and est_deliver else None
)
is_feasible = improvement is not None and improvement > 0
transfer_manifests.append({
"deliveryid": o.get("deliveryid"),
"from_rider_id": orig_rid,
"from_rider_name": rider_names.get(orig_rid, f"Rider {orig_rid}"),
"original_delivery_time": _fmt(orig_deliver),
"estimated_delivery_time": _fmt(est_deliver),
"improvement_minutes": improvement,
"is_feasible": is_feasible,
"location": {"lat": round(coord[0], 6), "lon": round(coord[1], 6)},
})
curr_pos = coord
est_time = est_deliver
# Only keep orders where idle rider is actually faster
feasible_manifests = [m for m in transfer_manifests if m["is_feasible"]]
if not feasible_manifests:
continue
idle_new_finish = est_time
# Check idle rider can complete within their free window
total_obligation_min = travel_min + total_delivery_leg_min
if total_obligation_min > free_window_min:
# Idle rider would finish after fleet, extending rather than helping
continue
# New fleet done after the transfer
taken_order_objs = {id(o) for (_, o) in greedy_take}
new_finish_by_rider: dict[int, datetime | None] = {}
for rid, r_orders in by_rider.items():
if rid == idle_uid:
new_finish_by_rider[rid] = idle_new_finish
continue
remaining = [o for o in r_orders if id(o) not in taken_order_objs]
finishes = [_parse_ts(o.get("deliverytime")) for o in remaining]
valid = [f for f in finishes if f]
new_finish_by_rider[rid] = max(valid) if valid else None
new_fleet_done = max(
(v for v in new_finish_by_rider.values() if v),
default=fleet_done,
)
fleet_improvement = round(
(fleet_done - new_fleet_done).total_seconds() / 60
)
# Most relieved rider
orig_last_by_rider = {
rid: max(
(f for f in [_parse_ts(o.get("deliverytime")) for o in r_o] if f),
default=None,
)
for rid, r_o in by_rider.items()
}
most_impacted_rid = max(
(rid for rid in {r for r, _ in greedy_take}),
key=lambda r: (orig_last_by_rider.get(r) or datetime.min),
default=None,
)
orig_overloaded_finish = orig_last_by_rider.get(most_impacted_rid)
new_overloaded_finish = new_finish_by_rider.get(most_impacted_rid)
time_saved = (
round((orig_overloaded_finish - new_overloaded_finish).total_seconds() / 60)
if orig_overloaded_finish and new_overloaded_finish else 0
)
# Total extra km for idle rider (idle→kitchen + all delivery legs)
total_extra_km = travel_km + sum(
_hav(
(k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[0],
(k_coord if i == 0 else (_get_coord(greedy_take[i-1][1]) or k_coord))[1],
*(_get_coord(o) or k_coord),
)
for i, (_, o) in enumerate(greedy_take)
if _get_coord(o)
)
# ----------------------------------------------------------
# Confidence score (0-100)
# Measures how comfortable this transfer is given real constraints.
#
# Component 1 Slack ratio: how much free time the idle rider
# has beyond the time they'll spend doing the transfer.
# (free_window - total_obligation) / free_window → 0..1
#
# Component 2 Feasibility ratio: what fraction of the
# transferred orders actually deliver earlier than original.
# feasible_count / total_transferred → 0..1
#
# Component 3 Fleet gain ratio: minutes saved as a fraction
# of total batch duration. Capped at 20 min improvement for
# full score so small batches don't produce inflated scores.
# ----------------------------------------------------------
slack_ratio = max(0.0, (free_window_min - total_obligation_min) / free_window_min)
feasibility_ratio = len(feasible_manifests) / max(1, len(transfer_manifests))
fleet_gain_ratio = min(1.0, fleet_improvement / 20.0) if fleet_improvement > 0 else 0.0
confidence_score = round(
(slack_ratio * 0.4 + feasibility_ratio * 0.35 + fleet_gain_ratio * 0.25) * 100
)
efficiency_ratio = (
round(fleet_improvement / max(0.1, total_extra_km), 2)
if total_extra_km > 0 else 0.0
)
if efficiency_ratio >= 5:
efficiency_rating = "high"
elif efficiency_ratio >= 2:
efficiency_rating = "medium"
else:
efficiency_rating = "low"
opportunities.append({
"idle_rider": {
"userid": idle_uid,
"name": idle["name"],
"primary_kitchen": idle["kitchen"],
"order_count": idle["order_count"],
"finished_at": _fmt(idle_finish),
"idle_minutes": idle["idle_minutes"],
"free_window_minutes": free_window_min,
"last_position": idle["last_position"],
},
"target_kitchen": target_kitchen,
"travel_to_kitchen_km": round(travel_km, 1),
"travel_to_kitchen_minutes": round(travel_min),
"arrive_at_kitchen": _fmt(arrive_at_kitchen),
"orders_to_transfer": transfer_manifests,
"total_orders_transferred": len(feasible_manifests),
"feasible_orders_count": len(feasible_manifests),
"most_relieved_rider": {
"userid": most_impacted_rid,
"name": rider_names.get(most_impacted_rid, f"Rider {most_impacted_rid}"),
"original_finish": _fmt(orig_overloaded_finish),
"new_finish": _fmt(new_overloaded_finish),
"time_saved_minutes": time_saved,
},
"extra_km_for_idle_rider": round(total_extra_km, 1),
"total_obligation_minutes": round(total_obligation_min),
"idle_rider_new_finish": _fmt(idle_new_finish),
"original_fleet_done": _fmt(fleet_done),
"new_fleet_done": _fmt(new_fleet_done),
"fleet_improvement_minutes": fleet_improvement,
"confidence_score": confidence_score,
"efficiency_ratio": efficiency_ratio,
"efficiency_rating": efficiency_rating,
})
# Keep only net-positive opportunities
opportunities = [o for o in opportunities if o["fleet_improvement_minutes"] > 0]
# Sort: confidence first (overall quality), then fleet improvement, then rider time saved
opportunities.sort(
key=lambda x: (
-x["confidence_score"],
-x["fleet_improvement_minutes"],
-x["most_relieved_rider"]["time_saved_minutes"],
)
)
# ------------------------------------------------------------------
# 5. Top recommendation
# ------------------------------------------------------------------
top_recommendation = _build_recommendation(
opportunities, idle_threshold_min, fleet_done, timelines, fleet_summary
)
return {
"fleet_summary": fleet_summary,
"rider_timelines": _clean_timelines(timelines, fleet_done),
"substitution_opportunities": opportunities,
"top_recommendation": top_recommendation,
}
# ---------------------------------------------------------------------------
# Helpers for clean output
# ---------------------------------------------------------------------------
def _clean_timelines(
timelines: list[dict],
fleet_done: datetime | None,
) -> list[dict]:
out = []
for t in timelines:
out.append({
"userid": t["userid"],
"name": t["name"],
"kitchen": t["kitchen"],
"kitchen_confidence": t.get("kitchen_confidence", 0.0),
"order_count": t["order_count"],
"completed_orders": t.get("completed_orders", t["order_count"]),
"pending_orders": t.get("pending_orders", 0),
"started_at": t["started_at"],
"finished_at": t["finished_at"],
"active_minutes": t.get("active_minutes"),
"pace_orders_per_hour": t.get("pace_orders_per_hour"),
"idle_minutes": t.get("idle_minutes", 0),
"free_window_minutes": t.get("free_window_minutes", 0),
"status": t.get("status", "unknown"),
"last_position": t.get("last_position"),
})
return out
def _build_recommendation(
opportunities: list[dict],
idle_threshold: float,
fleet_done: datetime | None,
timelines: list[dict],
fleet_summary: dict,
) -> dict | None:
if not opportunities:
# Diagnose WHY there are no opportunities even if some riders were idle
idle_count = sum(1 for t in timelines if t.get("status") == "idle")
if idle_count == 0:
reason = "All riders finished within the idle threshold window — batch was well balanced."
else:
reason = (
f"{idle_count} rider(s) finished early but no feasible substitution found: "
"either travel time exceeds the idle window, or all candidate orders "
"would be delivered later by the idle rider than the original."
)
return {
"action": "none",
"reason": reason,
"fleet_balance_assessment": _balance_assessment(fleet_summary),
}
best = opportunities[0]
idle = best["idle_rider"]
target = best["target_kitchen"]
relieved = best["most_relieved_rider"]
primary_kitchen = idle["primary_kitchen"] or "unknown"
confidence = best["confidence_score"]
# Root cause: why was this rider idle?
root_cause = _diagnose_root_cause(idle, timelines, fleet_summary)
# Risk factors
risk_factors = _identify_risks(best, idle_threshold)
description = (
f"{idle['name']} ({primary_kitchen}) finished all {idle['order_count']} orders "
f"at {idle['finished_at']}{idle['idle_minutes']} min before the fleet finished. "
f"Assigning {best['feasible_orders_count']} {target} orders: "
f"travel {best['travel_to_kitchen_km']} km ({best['travel_to_kitchen_minutes']} min), "
f"arrive at {target} kitchen at {best['arrive_at_kitchen']}. "
f"Relieves {relieved['name']} by {relieved['time_saved_minutes']} min "
f"({relieved['original_finish']}{relieved['new_finish']}). "
f"Fleet finishes {best['fleet_improvement_minutes']} min earlier "
f"({best['original_fleet_done']}{best['new_fleet_done']}). "
f"Confidence: {confidence}/100."
)
# Dynamic thresholds derived from the actual batch data
idle_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == primary_kitchen]
target_rider_loads = [t["order_count"] for t in timelines if t.get("kitchen") == target]
activate_idle_threshold = max(6, idle.get("order_count", 6) + 2)
activate_target_threshold = max(8, round(sum(target_rider_loads) / max(1, len(target_rider_loads)) * 1.1))
activate_rule = {
"condition": "AND",
"rules": [
{
"field": f"{primary_kitchen}_order_count",
"operator": "<=",
"value": activate_idle_threshold,
"reason": (
f"{idle['name']} had {idle['order_count']} orders today and was idle "
f"{idle['idle_minutes']} min. Dual-kitchen kicks in when their load "
f"stays at or below {activate_idle_threshold}."
),
},
{
"field": f"{target}_order_count",
"operator": ">=",
"value": activate_target_threshold,
"reason": (
f"{target.capitalize()} had enough orders today to justify the detour "
f"({sum(target_rider_loads)} total across {len(target_rider_loads)} rider(s)). "
f"Activate when {target} load is ≥ {activate_target_threshold}."
),
},
],
}
return {
"action": "dual_kitchen_assignment",
"idle_rider_id": idle["userid"],
"idle_rider_name": idle["name"],
"primary_kitchen": primary_kitchen,
"second_kitchen": target,
"second_kitchen_dispatch_after": best["arrive_at_kitchen"],
"description": description,
"fleet_improvement_minutes": best["fleet_improvement_minutes"],
"confidence_score": confidence,
"efficiency_rating": best["efficiency_rating"],
"root_cause": root_cause,
"risk_factors": risk_factors,
"activate_when": activate_rule,
"fleet_balance_assessment": _balance_assessment(fleet_summary),
"api_hint": {
"endpoint": "/api/v1/optimize",
"note": (
f"In the next batch, pre-assign the last "
f"{best['feasible_orders_count']} {target} orders to "
f"rider {idle['userid']} ({idle['name']}) with a "
f"dispatch-after time of {best['arrive_at_kitchen']}."
),
},
}
def _diagnose_root_cause(
idle: dict,
timelines: list[dict],
fleet_summary: dict,
) -> str:
"""
Explain WHY this rider finished early by comparing their load
against the fleet average and their kitchen's order volume.
"""
avg_orders = fleet_summary.get("orders_per_rider_avg", 0)
rider_orders = idle["order_count"]
kitchen = idle["primary_kitchen"] or "their kitchen"
name = idle["name"]
if avg_orders > 0 and rider_orders < avg_orders * 0.7:
shortfall = round(avg_orders - rider_orders, 1)
return (
f"{name} received {rider_orders} orders vs fleet average of {avg_orders:.1f} "
f"({shortfall:.1f}). {kitchen.capitalize()} kitchen generated fewer orders "
f"than the fleet needed to keep this rider fully utilised. "
f"This is a systematic under-loading of the {kitchen} kitchen in this batch."
)
elif rider_orders <= 4:
return (
f"{name} had only {rider_orders} orders — a very light load regardless of fleet average. "
f"Likely a short-demand window at {kitchen} kitchen. "
f"Dual-kitchen assignment is especially effective when primary kitchen load is ≤ 4 orders."
)
else:
spread = fleet_summary.get("finish_time_spread_minutes", 0)
return (
f"{name} is simply faster than peers — finished {idle['idle_minutes']} min ahead "
f"despite a normal load of {rider_orders} orders. "
f"Fleet finish-time spread is {spread} min, indicating uneven workload distribution."
)
def _identify_risks(best: dict, idle_threshold: float) -> list[str]:
"""
Enumerate operational risks for the recommended substitution.
"""
risks: list[str] = []
travel_min = best["travel_to_kitchen_minutes"]
obligation = best["total_obligation_minutes"]
free_window = best["idle_rider"]["free_window_minutes"]
confidence = best["confidence_score"]
extra_km = best["extra_km_for_idle_rider"]
slack_min = free_window - obligation
if slack_min < 10:
risks.append(
f"Tight schedule: only {slack_min} min of slack between idle rider's "
f"estimated finish and fleet completion. Any delay (traffic, kitchen wait) "
f"would eliminate the benefit."
)
if travel_min > 15:
risks.append(
f"Long commute to target kitchen ({travel_min} min). "
f"Kitchen departure time must be precise — a late start erodes time savings."
)
if extra_km > 8:
risks.append(
f"Extra {extra_km} km for the idle rider adds fuel cost and rider fatigue. "
f"Verify this is worthwhile if fleet improvement is marginal."
)
if confidence < 50:
risks.append(
f"Low confidence ({confidence}/100): limited slack or few feasible transfers. "
f"Consider this as a contingency plan rather than a guaranteed improvement."
)
if best.get("feasible_orders_count", 0) < best.get("total_orders_transferred", 1):
risks.append(
"Not all proposed transfers save time — some orders are included to fill "
"the idle rider's route but don't improve individual delivery times."
)
if not risks:
risks.append("No significant risks identified. Transfer looks operationally sound.")
return risks
def _balance_assessment(fleet_summary: dict) -> str:
"""Short human-readable verdict on batch balance quality."""
spread = fleet_summary.get("finish_time_spread_minutes", 0)
stdev = fleet_summary.get("load_balance_stdev", 0)
util = fleet_summary.get("avg_utilisation_pct")
if spread <= 10 and stdev <= 1:
verdict = "Excellent — riders finished close together with balanced loads."
elif spread <= 20 and stdev <= 2:
verdict = "Good — minor imbalance, acceptable for this fleet size."
elif spread <= 35:
verdict = f"Moderate imbalance — {spread} min spread between earliest and latest finish."
else:
verdict = (
f"High imbalance — {spread} min spread. Some riders sat idle while others overran. "
f"Pre-planning dual-kitchen assignments is strongly recommended."
)
if util is not None:
verdict += f" Average rider utilisation: {util}% of batch window."
return verdict