861 lines
35 KiB
Python
861 lines
35 KiB
Python
"""
|
||
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
|