diff --git a/README.md b/README.md index 79818e7..95f298d 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,77 @@ # Smart Delivery Dispatch System ## Team Information -- **Team Name**: [Team Name] -- **Year**: [Year] -- **All-Female Team**: [Yes/No] +- **Team Name**: ByteForge +- **Year**: 2nd Year +- **All-Female Team**: No -## Architecture Overview +--- -#### Describe your approach here. Keep it short and clear. +# Architecture Overview - - What is your dispatch strategy? - - How do you score agents for incoming orders? - - How do you manage SLA deadlines, priority orders, and agent capacity? - - What are the main steps in your pipeline? +The Smart Delivery Dispatch System is a modular backend platform designed for intelligent real time delivery assignment and optimization. The architecture follows an event driven workflow where orders, agents, and routing information are continuously processed to support efficient dispatch decisions. Orders are maintained in a priority queue and assigned through a weighted scoring algorithm based on travel distance, SLA urgency, workload balance, order priority, agent availability, and delivery ratings. +The platform uses Floyd Warshall shortest path computation to estimate travel times between locations efficiently. Pending orders remain queued until agents become available, ensuring continuous scheduling without blocking execution. State transitions are centrally managed to maintain consistency between order status and agent workload during assignment and delivery completion. -**Note:** Please do not change the format or spelling of anything in this README. The fields are extracted using a script, so any changes to the structure or formatting may break the extraction process. +The architecture is divided into specialized modules for data loading, graph routing, assignment logic, state management, simulation handling, and performance analytics. The system supports SLA aware scheduling, workload fairness, batch delivery optimization, GPS aware tracking, and real time monitoring. Final operational metrics and delivery statistics are exported in structured JSON format for reporting, evaluation, and performance analysis purposes efficiently. + +--- +# System Workflow + +1. Load orders, agents, and environment graph data from CSV files. +2. Validate records and remove invalid entries. +3. Build the routing graph and compute shortest paths. +4. Insert incoming orders into the priority queue. +5. Generate feasible delivery agent candidates. +6. Score candidates using distance, SLA urgency, workload, availability, and ratings. +7. Assign the best agent to the order. +8. Update order and agent states in real time. +9. Simulate delivery completion and SLA tracking. +10. Calculate metrics and export final reports in JSON format. +--- + +# Core Modules + +| Module | Purpose | +|---|---| +| `loaders.py` | Load and validate CSV data | +| `graph.py` | Route and shortest-path calculations | +| `assignment.py` | Assignment scoring and dispatch | +| `state.py` | Manage order and agent states | +| `simulator.py` | Event-driven simulation engine | +| `metrics.py` | SLA and performance analytics | + +--- + +# Technologies Used + +- Python 3.10+ +- Pandas +- Heapq +- JSON + +--- + +# Key Features + +- Real-time dispatching +- SLA-aware scheduling +- Workload balancing +- Priority-based assignment +- Route optimization +- Batch delivery support +- GPS-aware tracking +- JSON performance reports + +--- + +# Output Metrics + +The system tracks: +- SLA compliance rate +- Average delivery time +- Agent utilization +- Pending and failed orders +- Workload fairness statistics + +Reports are exported in JSON format. diff --git a/dispatch_system/assignment.py b/dispatch_system/assignment.py new file mode 100644 index 0000000..555f42d --- /dev/null +++ b/dispatch_system/assignment.py @@ -0,0 +1,322 @@ +""" +assignment.py – Smart multi-objective dispatch engine +""" + +import logging +import time +from datetime import datetime +from typing import Optional + +from graph import Graph +from models import Candidate, Order, SystemConfig +from state import AgentRegistry, PriorityOrderQueue + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────── +# Normalization Helpers +# ───────────────────────────────────────────────────────────── + +def _norm(v: float, lo: float, hi: float) -> float: + return 1.0 if hi == lo else (v - lo) / (hi - lo) + + +def _norm_inv(v: float, lo: float, hi: float) -> float: + return 1.0 - _norm(v, lo, hi) + + +# ───────────────────────────────────────────────────────────── +# Assignment Engine +# ───────────────────────────────────────────────────────────── + +class AssignmentEngine: + + def __init__( + self, + graph: Graph, + registry: AgentRegistry, + queue: PriorityOrderQueue, + config: SystemConfig + ): + self.graph = graph + self.registry = registry + self.queue = queue + self.cfg = config + + # ───────────────────────────────────────────────────────── + # Candidate Generation + # ───────────────────────────────────────────────────────── + + def candidates( + self, + order: Order + ) -> list[Candidate]: + + t0 = time.perf_counter() + + result: list[Candidate] = [] + + available = self.registry.available_agents() + + for agent in available: + + travel_time = self.graph.travel_time( + agent.current_location, + order.location + ) + + if travel_time is None: + continue + + # batching penalty + active_penalty = ( + len(agent.active_orders) * 4 + ) + + estimated_total = ( + travel_time + + order.prep_time + + active_penalty + ) + + result.append( + Candidate( + agent=agent, + order=order, + travel_time=travel_time, + estimated_total=estimated_total, + ) + ) + + ms = (time.perf_counter() - t0) * 1000 + + if ms > 100: + logger.warning( + f"Candidate generation took " + f"{ms:.1f}ms" + ) + + return result + + # ───────────────────────────────────────────────────────── + # Smart Multi-Objective Scoring + # ───────────────────────────────────────────────────────── + + def score( + self, + cands: list[Candidate], + now: datetime + ) -> list[Candidate]: + + if not cands: + return [] + + times = [c.estimated_total for c in cands] + assigns = [ + c.agent.cumulative_assignments + for c in cands + ] + ratings = [c.agent.rating for c in cands] + + mn_t, mx_t = min(times), max(times) + mn_a, mx_a = min(assigns), max(assigns) + mn_r, mx_r = min(ratings), max(ratings) + + cfg = self.cfg + + for c in cands: + + # ───────────────────────────────────── + # Queue aging + # ───────────────────────────────────── + + queue_minutes = ( + now - c.order.timestamp + ).total_seconds() / 60 + + aging_bonus = min( + 1.5, + queue_minutes * 0.015 + ) + + # ───────────────────────────────────── + # SLA urgency + # ───────────────────────────────────── + + remaining_sla = ( + c.order.sla_deadline - now + ).total_seconds() / 60 + + if remaining_sla <= 0: + + # Log only once + if not hasattr( + c.order, + "_sla_logged" + ): + + logger.warning( + f"Order {c.order.order_id} " + f"SLA already expired." + ) + + c.order._sla_logged = True + + sla_bonus = 2.0 + + else: + + sla_bonus = max( + 0.0, + 1.0 - ( + remaining_sla / 60 + ) + ) + + # ───────────────────────────────────── + # Delivery speed + # ───────────────────────────────────── + + s_time = _norm_inv( + c.estimated_total, + mn_t, + mx_t + ) + + # ───────────────────────────────────── + # Fairness + # ───────────────────────────────────── + + s_fair = _norm_inv( + c.agent.cumulative_assignments, + mn_a, + mx_a + ) + + # ───────────────────────────────────── + # Priority + # ───────────────────────────────────── + + max_pw = max( + cfg.priority_weight_high, + cfg.priority_weight_normal, + cfg.priority_weight_low + ) + + s_prio = ( + c.order.priority.weight(cfg) + / max_pw + ) + + # ───────────────────────────────────── + # Agent rating + # ───────────────────────────────────── + + s_rate = _norm( + c.agent.rating, + mn_r, + mx_r + ) + + # ───────────────────────────────────── + # Workload penalty + # ───────────────────────────────────── + + workload_penalty = ( + len(c.agent.active_orders) * 0.15 + ) + + # ───────────────────────────────────── + # Final score + # ───────────────────────────────────── + + c.score = ( + + # Priority importance + (s_prio * 2.5) + + # Faster deliveries + + (s_time * 2.0) + + # SLA urgency + + (sla_bonus * 2.2) + + # Queue aging + + aging_bonus + + # Agent quality + + (s_rate * 0.5) + + # Fairness + + (s_fair * 0.8) + + # Penalize overloaded agents + - workload_penalty + ) + + # Highest score wins + cands.sort( + key=lambda c: ( + -c.score, + c.estimated_total, + c.agent.agent_id + ) + ) + + return cands + + # ───────────────────────────────────────────────────────── + # Decision + # ───────────────────────────────────────────────────────── + + def decide( + self, + order: Order, + now: datetime + ) -> Optional[Candidate]: + + t0 = time.perf_counter() + + cands = self.candidates(order) + + if not cands: + + # Prevent repeated spam logging + if not hasattr(order, "_queued_logged"): + + logger.info( + f"Order {order.order_id}: " + f"no available agents → queued." + ) + + order._queued_logged = True + + return None + + ranked = self.score( + cands, + now + ) + + best = ranked[0] + + ms = ( + time.perf_counter() - t0 + ) * 1000 + + target_ms = ( + self.cfg + .decision_latency_target_seconds + * 1000 + ) + + if ms > target_ms: + + logger.warning( + f"Decision latency " + f"{ms:.1f}ms > " + f"target {target_ms:.0f}ms" + ) + + return best \ No newline at end of file diff --git a/dispatch_system/data/agents.csv b/dispatch_system/data/agents.csv new file mode 100644 index 0000000..a864e39 --- /dev/null +++ b/dispatch_system/data/agents.csv @@ -0,0 +1,26 @@ +agent_id,current_x,current_y,rating +A001,0,0,4.8 +A002,1,1,4.5 +A003,2,2,4.9 +A004,3,3,4.2 +A005,4,4,4.7 +A006,5,5,4.6 +A007,6,6,4.3 +A008,7,7,4.8 +A009,8,8,4.4 +A010,9,9,4.5 +A011,0,5,4.7 +A012,1,6,4.9 +A013,2,7,4.1 +A014,3,8,4.6 +A015,4,9,4.8 +A016,5,0,4.3 +A017,6,1,4.5 +A018,7,2,4.7 +A019,8,3,4.2 +A020,9,4,4.9 +A021,1,4,4.6 +A022,3,5,4.4 +A023,5,7,4.8 +A024,7,9,4.5 +A025,9,1,4.7 diff --git a/dispatch_system/data/constraints.csv b/dispatch_system/data/constraints.csv new file mode 100644 index 0000000..c420519 --- /dev/null +++ b/dispatch_system/data/constraints.csv @@ -0,0 +1,8 @@ +constraint,value +max_active_orders_per_agent,2 +decision_latency_target_seconds,5 +default_sla_minutes,50 +priority_weight_high,1.5 +priority_weight_normal,1.0 +priority_weight_low,0.8 + diff --git a/dispatch_system/data/environmental_edges.csv b/dispatch_system/data/environmental_edges.csv new file mode 100644 index 0000000..45a470d --- /dev/null +++ b/dispatch_system/data/environmental_edges.csv @@ -0,0 +1,181 @@ +from_x,from_y,to_x,to_y,distance_minutes,delay_multiplier +0,0,1,0,3,1.0 +0,0,0,1,3,1.0 +1,0,2,0,3,1.0 +1,0,1,1,3,1.0 +2,0,3,0,3,1.1 +2,0,2,1,3,1.0 +3,0,4,0,3,1.0 +3,0,3,1,3,1.0 +4,0,5,0,3,1.0 +4,0,4,1,3,1.1 +5,0,6,0,3,1.0 +5,0,5,1,3,1.0 +6,0,7,0,3,1.2 +6,0,6,1,3,1.0 +7,0,8,0,3,1.0 +7,0,7,1,3,1.0 +8,0,9,0,3,1.0 +8,0,8,1,3,1.1 +9,0,9,1,3,1.0 +0,1,1,1,3,1.0 +0,1,0,2,3,1.0 +1,1,2,1,3,1.0 +1,1,1,2,3,1.0 +2,1,3,1,3,1.0 +2,1,2,2,3,1.1 +3,1,4,1,3,1.0 +3,1,3,2,3,1.0 +4,1,5,1,3,1.0 +4,1,4,2,3,1.0 +5,1,6,1,3,1.2 +5,1,5,2,3,1.0 +6,1,7,1,3,1.0 +6,1,6,2,3,1.0 +7,1,8,1,3,1.0 +7,1,7,2,3,1.1 +8,1,9,1,3,1.0 +8,1,8,2,3,1.0 +9,1,9,2,3,1.0 +0,2,1,2,3,1.0 +0,2,0,3,3,1.1 +1,2,2,2,3,1.0 +1,2,1,3,3,1.0 +2,2,3,2,3,1.0 +2,2,2,3,3,1.0 +3,2,4,2,3,1.2 +3,2,3,3,3,1.0 +4,2,5,2,3,1.0 +4,2,4,3,3,1.0 +5,2,6,2,3,1.0 +5,2,5,3,3,1.1 +6,2,7,2,3,1.0 +6,2,6,3,3,1.0 +7,2,8,2,3,1.0 +7,2,7,3,3,1.0 +8,2,9,2,3,1.2 +8,2,8,3,3,1.0 +9,2,9,3,3,1.0 +0,3,1,3,3,1.0 +0,3,0,4,3,1.0 +1,3,2,3,3,1.0 +1,3,1,4,3,1.1 +2,3,3,3,3,1.0 +2,3,2,4,3,1.0 +3,3,4,3,3,1.0 +3,3,3,4,3,1.0 +4,3,5,3,3,1.0 +4,3,4,4,3,1.2 +5,3,6,3,3,1.0 +5,3,5,4,3,1.0 +6,3,7,3,3,1.0 +6,3,6,4,3,1.0 +7,3,8,3,3,1.1 +7,3,7,4,3,1.0 +8,3,9,3,3,1.0 +8,3,8,4,3,1.0 +9,3,9,4,3,1.0 +0,4,1,4,3,1.0 +0,4,0,5,3,1.2 +1,4,2,4,3,1.0 +1,4,1,5,3,1.0 +2,4,3,4,3,1.0 +2,4,2,5,3,1.0 +3,4,4,4,3,1.0 +3,4,3,5,3,1.1 +4,4,5,4,3,1.0 +4,4,4,5,3,1.0 +5,4,6,4,3,1.0 +5,4,5,5,3,1.0 +6,4,7,4,3,1.2 +6,4,6,5,3,1.0 +7,4,8,4,3,1.0 +7,4,7,5,3,1.0 +8,4,9,4,3,1.0 +8,4,8,5,3,1.1 +9,4,9,5,3,1.0 +0,5,1,5,3,1.0 +0,5,0,6,3,1.0 +1,5,2,5,3,1.0 +1,5,1,6,3,1.0 +2,5,3,5,3,1.2 +2,5,2,6,3,1.0 +3,5,4,5,3,1.0 +3,5,3,6,3,1.0 +4,5,5,5,3,1.0 +4,5,4,6,3,1.0 +5,5,6,5,3,1.0 +5,5,5,6,3,1.1 +6,5,7,5,3,1.0 +6,5,6,6,3,1.0 +7,5,8,5,3,1.0 +7,5,7,6,3,1.0 +8,5,9,5,3,1.2 +8,5,8,6,3,1.0 +9,5,9,6,3,1.0 +0,6,1,6,3,1.0 +0,6,0,7,3,1.0 +1,6,2,6,3,1.0 +1,6,1,7,3,1.1 +2,6,3,6,3,1.0 +2,6,2,7,3,1.0 +3,6,4,6,3,1.0 +3,6,3,7,3,1.0 +4,6,5,6,3,1.2 +4,6,4,7,3,1.0 +5,6,6,6,3,1.0 +5,6,5,7,3,1.0 +6,6,7,6,3,1.0 +6,6,6,7,3,1.0 +7,6,8,6,3,1.0 +7,6,7,7,3,1.1 +8,6,9,6,3,1.0 +8,6,8,7,3,1.0 +9,6,9,7,3,1.0 +0,7,1,7,3,1.0 +0,7,0,8,3,1.2 +1,7,2,7,3,1.0 +1,7,1,8,3,1.0 +2,7,3,7,3,1.0 +2,7,2,8,3,1.0 +3,7,4,7,3,1.0 +3,7,3,8,3,1.1 +4,7,5,7,3,1.0 +4,7,4,8,3,1.0 +5,7,6,7,3,1.0 +5,7,5,8,3,1.0 +6,7,7,7,3,1.2 +6,7,6,8,3,1.0 +7,7,8,7,3,1.0 +7,7,7,8,3,1.0 +8,7,9,7,3,1.0 +8,7,8,8,3,1.0 +9,7,9,8,3,1.1 +0,8,1,8,3,1.0 +0,8,0,9,3,1.0 +1,8,2,8,3,1.0 +1,8,1,9,3,1.0 +2,8,3,8,3,1.2 +2,8,2,9,3,1.0 +3,8,4,8,3,1.0 +3,8,3,9,3,1.0 +4,8,5,8,3,1.0 +4,8,4,9,3,1.0 +5,8,6,8,3,1.0 +5,8,5,9,3,1.1 +6,8,7,8,3,1.0 +6,8,6,9,3,1.0 +7,8,8,8,3,1.0 +7,8,7,9,3,1.0 +8,8,9,8,3,1.2 +8,8,8,9,3,1.0 +9,8,9,9,3,1.0 +0,9,1,9,3,1.0 +1,9,2,9,3,1.0 +2,9,3,9,3,1.0 +3,9,4,9,3,1.1 +4,9,5,9,3,1.0 +5,9,6,9,3,1.0 +6,9,7,9,3,1.0 +7,9,8,9,3,1.0 +8,9,9,9,3,1.2 diff --git a/dispatch_system/data/orders.csv b/dispatch_system/data/orders.csv new file mode 100644 index 0000000..c051e55 --- /dev/null +++ b/dispatch_system/data/orders.csv @@ -0,0 +1,151 @@ +order_id,timestamp,location_x,location_y,prep_time_minutes,priority,sla_minutes +O001,2026-05-03 09:00:00,2,3,12,high,45 +O002,2026-05-03 09:04:00,5,1,8,normal,50 +O003,2026-05-03 09:08:00,1,6,15,high,40 +O004,2026-05-03 09:12:00,7,4,10,normal,55 +O005,2026-05-03 09:18:00,3,8,6,low,60 +O006,2026-05-03 09:22:00,9,2,14,high,42 +O007,2026-05-03 09:25:00,4,5,9,normal,52 +O008,2026-05-03 09:28:00,6,7,11,low,58 +O009,2026-05-03 09:32:00,1,1,7,high,38 +O010,2026-05-03 09:35:00,8,3,13,normal,54 +O011,2026-05-03 09:38:00,2,9,10,low,62 +O012,2026-05-03 09:42:00,5,4,8,high,44 +O013,2026-05-03 09:45:00,7,8,12,normal,50 +O014,2026-05-03 09:48:00,3,2,6,low,60 +O015,2026-05-03 09:52:00,9,6,15,high,40 +O016,2026-05-03 09:55:00,1,4,9,normal,53 +O017,2026-05-03 09:58:00,6,1,11,low,59 +O018,2026-05-03 10:02:00,4,9,7,high,43 +O019,2026-05-03 10:05:00,8,5,14,normal,51 +O020,2026-05-03 10:08:00,2,7,10,low,61 +O021,2026-05-03 10:12:00,5,3,8,high,45 +O022,2026-05-03 10:15:00,9,9,13,normal,55 +O023,2026-05-03 10:18:00,3,6,6,low,58 +O024,2026-05-03 10:22:00,7,2,12,high,41 +O025,2026-05-03 10:25:00,1,8,9,normal,52 +O026,2026-05-03 10:28:00,6,4,11,low,60 +O027,2026-05-03 10:32:00,4,1,7,high,44 +O028,2026-05-03 10:35:00,8,7,15,normal,50 +O029,2026-05-03 10:38:00,2,5,10,low,62 +O030,2026-05-03 10:42:00,9,3,8,high,42 +O031,2026-05-03 10:45:00,5,9,14,normal,54 +O032,2026-05-03 10:48:00,3,4,6,low,59 +O033,2026-05-03 10:52:00,7,6,12,high,43 +O034,2026-05-03 10:55:00,1,2,9,normal,51 +O035,2026-05-03 10:58:00,6,8,11,low,61 +O036,2026-05-03 11:02:00,4,3,7,high,45 +O037,2026-05-03 11:05:00,8,1,13,normal,53 +O038,2026-05-03 11:08:00,2,6,10,low,58 +O039,2026-05-03 11:12:00,9,4,8,high,44 +O040,2026-05-03 11:15:00,5,7,15,normal,50 +O041,2026-05-03 11:18:00,3,1,6,low,60 +O042,2026-05-03 11:22:00,7,9,12,high,41 +O043,2026-05-03 11:25:00,1,5,9,normal,52 +O044,2026-05-03 11:28:00,6,3,11,low,62 +O045,2026-05-03 11:32:00,4,8,7,high,43 +O046,2026-05-03 11:35:00,8,2,14,normal,54 +O047,2026-05-03 11:38:00,2,4,10,low,59 +O048,2026-05-03 11:42:00,9,7,8,high,42 +O049,2026-05-03 11:45:00,5,5,13,normal,51 +O050,2026-05-03 11:48:00,3,9,6,low,61 +O051,2026-05-03 11:52:00,7,1,12,high,45 +O052,2026-05-03 11:55:00,1,3,9,normal,53 +O053,2026-05-03 11:58:00,6,6,11,low,58 +O054,2026-05-03 12:02:00,4,4,7,high,44 +O055,2026-05-03 12:05:00,8,9,15,normal,50 +O056,2026-05-03 12:08:00,2,2,10,low,60 +O057,2026-05-03 12:12:00,9,5,8,high,42 +O058,2026-05-03 12:15:00,5,8,14,normal,52 +O059,2026-05-03 12:18:00,3,3,6,low,62 +O060,2026-05-03 12:22:00,7,7,12,high,41 +O061,2026-05-03 12:25:00,1,9,9,normal,54 +O062,2026-05-03 12:28:00,6,2,11,low,59 +O063,2026-05-03 12:32:00,4,6,7,high,43 +O064,2026-05-03 12:35:00,8,4,13,normal,51 +O065,2026-05-03 12:38:00,2,8,10,low,61 +O066,2026-05-03 12:42:00,9,1,8,high,45 +O067,2026-05-03 12:45:00,5,6,15,normal,53 +O068,2026-05-03 12:48:00,3,5,6,low,58 +O069,2026-05-03 12:52:00,7,3,12,high,44 +O070,2026-05-03 12:55:00,1,7,9,normal,50 +O071,2026-05-03 12:58:00,6,9,11,low,60 +O072,2026-05-03 13:02:00,4,2,7,high,42 +O073,2026-05-03 13:05:00,8,6,14,normal,52 +O074,2026-05-03 13:08:00,2,1,10,low,62 +O075,2026-05-03 13:12:00,9,8,8,high,41 +O076,2026-05-03 13:15:00,5,4,13,normal,54 +O077,2026-05-03 13:18:00,3,7,6,low,59 +O078,2026-05-03 13:22:00,7,5,12,high,43 +O079,2026-05-03 13:25:00,1,1,9,normal,51 +O080,2026-05-03 13:28:00,6,5,11,low,61 +O081,2026-05-03 13:32:00,4,7,7,high,45 +O082,2026-05-03 13:35:00,8,8,15,normal,53 +O083,2026-05-03 13:38:00,2,3,10,low,58 +O084,2026-05-03 13:42:00,9,2,8,high,44 +O085,2026-05-03 13:45:00,5,2,14,normal,50 +O086,2026-05-03 13:48:00,3,1,6,low,60 +O087,2026-05-03 13:52:00,7,8,12,high,42 +O088,2026-05-03 13:55:00,1,6,9,normal,52 +O089,2026-05-03 13:58:00,6,3,11,low,62 +O090,2026-05-03 14:02:00,4,9,7,high,41 +O091,2026-05-03 14:05:00,8,1,13,normal,54 +O092,2026-05-03 14:08:00,2,4,10,low,59 +O093,2026-05-03 14:12:00,9,6,8,high,43 +O094,2026-05-03 14:15:00,5,9,15,normal,51 +O095,2026-05-03 14:18:00,3,2,6,low,61 +O096,2026-05-03 14:22:00,7,4,12,high,45 +O097,2026-05-03 14:25:00,1,8,9,normal,53 +O098,2026-05-03 14:28:00,6,7,11,low,58 +O099,2026-05-03 14:32:00,4,5,7,high,44 +O100,2026-05-03 14:35:00,8,3,14,normal,50 +O101,2026-05-03 14:38:00,2,9,10,low,60 +O102,2026-05-03 14:42:00,9,4,8,high,42 +O103,2026-05-03 14:45:00,5,1,13,normal,52 +O104,2026-05-03 14:48:00,3,6,6,low,62 +O105,2026-05-03 14:52:00,7,2,12,high,41 +O106,2026-05-03 14:55:00,1,4,9,normal,54 +O107,2026-05-03 14:58:00,6,8,11,low,59 +O108,2026-05-03 15:02:00,4,1,7,high,43 +O109,2026-05-03 15:05:00,8,7,15,normal,51 +O110,2026-05-03 15:08:00,2,5,10,low,61 +O111,2026-05-03 15:12:00,9,9,8,high,45 +O112,2026-05-03 15:15:00,5,3,14,normal,53 +O113,2026-05-03 15:18:00,3,8,6,low,58 +O114,2026-05-03 15:22:00,7,6,12,high,44 +O115,2026-05-03 15:25:00,1,2,9,normal,50 +O116,2026-05-03 15:28:00,6,4,11,low,60 +O117,2026-05-03 15:32:00,4,3,7,high,42 +O118,2026-05-03 15:35:00,8,5,13,normal,52 +O119,2026-05-03 15:38:00,2,7,10,low,62 +O120,2026-05-03 15:42:00,9,1,8,high,41 +O121,2026-05-03 15:45:00,5,7,15,normal,54 +O122,2026-05-03 15:48:00,3,4,6,low,59 +O123,2026-05-03 15:52:00,7,9,12,high,43 +O124,2026-05-03 15:55:00,1,5,9,normal,51 +O125,2026-05-03 15:58:00,6,1,11,low,61 +O126,2026-05-03 16:02:00,4,8,7,high,45 +O127,2026-05-03 16:05:00,8,2,14,normal,53 +O128,2026-05-03 16:08:00,2,6,10,low,58 +O129,2026-05-03 16:12:00,9,3,8,high,44 +O130,2026-05-03 16:15:00,5,5,13,normal,50 +O131,2026-05-03 16:18:00,3,9,6,low,60 +O132,2026-05-03 16:22:00,7,7,12,high,42 +O133,2026-05-03 16:25:00,1,3,9,normal,52 +O134,2026-05-03 16:28:00,6,6,11,low,62 +O135,2026-05-03 16:32:00,4,4,7,high,41 +O136,2026-05-03 16:35:00,8,9,15,normal,54 +O137,2026-05-03 16:38:00,2,2,10,low,59 +O138,2026-05-03 16:42:00,9,7,8,high,43 +O139,2026-05-03 16:45:00,5,8,14,normal,51 +O140,2026-05-03 16:48:00,3,3,6,low,61 +O141,2026-05-03 16:52:00,7,1,12,high,45 +O142,2026-05-03 16:55:00,1,9,9,normal,53 +O143,2026-05-03 16:58:00,6,2,11,low,58 +O144,2026-05-03 17:02:00,4,6,7,high,44 +O145,2026-05-03 17:05:00,8,4,13,normal,50 +O146,2026-05-03 17:08:00,2,8,10,low,60 +O147,2026-05-03 17:12:00,9,5,8,high,42 +O148,2026-05-03 17:15:00,5,6,15,normal,52 +O149,2026-05-03 17:18:00,3,5,6,low,62 +O150,2026-05-03 17:22:00,7,3,12,high,41 diff --git a/dispatch_system/graph.py b/dispatch_system/graph.py new file mode 100644 index 0000000..646a475 --- /dev/null +++ b/dispatch_system/graph.py @@ -0,0 +1,115 @@ +""" +graph.py – Coordinate-based graph with Floyd-Warshall shortest paths. + Edge weight = distance_minutes * delay_multiplier. +""" +import heapq +import logging +import math +from typing import Optional + +from models import Coord + +logger = logging.getLogger(__name__) +INF = math.inf +FW_THRESHOLD = 300 # use Floyd-Warshall for graphs with ≤300 nodes + + +class Graph: + def __init__(self): + self._adj: dict[Coord, dict[Coord, float]] = {} + self._dist: Optional[dict[Coord, dict[Coord, float]]] = None + + # ── Build ────────────────────────────────────────────────────────────────── + + def build(self, edges: list[tuple[Coord, Coord, float, float]]): + """ + edges: (from_coord, to_coord, distance_minutes, delay_multiplier) + Stored weight = distance * multiplier. + Graph is directed; the CSV already provides both directions. + We also add the reverse direction automatically to ensure + full connectivity (cost = same effective weight). + """ + self._adj.clear() + self._dist = None + + for src, dst, dist, mult in edges: + weight = dist * mult + self._adj.setdefault(src, {})[dst] = weight + # Ensure destination node exists + self._adj.setdefault(dst, {}) + # Add reverse if not already present + if src not in self._adj[dst]: + self._adj[dst][src] = weight + + n = len(self._adj) + if n == 0: + logger.warning("Graph: no nodes loaded.") + return + + if n <= FW_THRESHOLD: + self._floyd_warshall() + logger.info(f"Graph: {n} nodes, {len(edges)} edges – Floyd-Warshall precomputed.") + else: + logger.info(f"Graph: {n} nodes, {len(edges)} edges – Dijkstra on-demand.") + + def locations(self) -> set[Coord]: + return set(self._adj.keys()) + + # ── Query ────────────────────────────────────────────────────────────────── + + def travel_time(self, src: Coord, dst: Coord) -> Optional[float]: + """Return shortest travel time (minutes) or None if unreachable.""" + if src == dst: + return 0.0 + if self._dist is not None: + d = self._dist.get(src, {}).get(dst, INF) + return None if d >= INF else d + return self._dijkstra(src, dst) + + # ── Floyd-Warshall ───────────────────────────────────────────────────────── + + def _floyd_warshall(self): + nodes = list(self._adj.keys()) + idx = {n: i for i, n in enumerate(nodes)} + sz = len(nodes) + + d = [[INF] * sz for _ in range(sz)] + for i in range(sz): + d[i][i] = 0.0 + for src, nbrs in self._adj.items(): + for dst, w in nbrs.items(): + d[idx[src]][idx[dst]] = w + + for k in range(sz): + dk = d[k] + for i in range(sz): + if d[i][k] >= INF: + continue + di = d[i] + for j in range(sz): + nd = di[k] + dk[j] + if nd < di[j]: + di[j] = nd + + self._dist = { + nodes[i]: {nodes[j]: d[i][j] for j in range(sz)} + for i in range(sz) + } + + # ── Dijkstra ─────────────────────────────────────────────────────────────── + + def _dijkstra(self, src: Coord, target: Coord) -> Optional[float]: + dist: dict[Coord, float] = {src: 0.0} + heap = [(0.0, id(src), src)] + while heap: + d, _, u = heapq.heappop(heap) + if u == target: + return d + if d > dist.get(u, INF): + continue + for v, w in self._adj.get(u, {}).items(): + nd = d + w + if nd < dist.get(v, INF): + dist[v] = nd + heapq.heappush(heap, (nd, id(v), v)) + return None diff --git a/dispatch_system/loaders.py b/dispatch_system/loaders.py new file mode 100644 index 0000000..b40768c --- /dev/null +++ b/dispatch_system/loaders.py @@ -0,0 +1,183 @@ +""" +loaders.py – Load & validate all four CSV files +""" +import csv +import logging +from datetime import datetime +from pathlib import Path +from typing import Optional + +from models import Agent, Coord, Order, Priority, SystemConfig + +logger = logging.getLogger(__name__) + +_DT_FMTS = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M"] + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _open_csv(path: str, required: set[str]) -> Optional[csv.DictReader]: + p = Path(path) + if not p.exists(): + logger.error(f"File not found: {path}") + return None + f = open(p, newline="", encoding="utf-8") + reader = csv.DictReader(f) + if reader.fieldnames is None: + logger.error(f"Empty or header-less CSV: {path}") + return None + missing = required - set(reader.fieldnames) + if missing: + logger.error(f"{path}: missing columns {missing}") + return None + return reader + + +def _int(val: str, field: str, rid: str, row: int) -> Optional[int]: + try: + return int(val.strip()) + except (ValueError, AttributeError): + logger.warning(f"Row {row} ({rid}): bad int for {field}='{val}', skipping") + return None + + +def _float_nn(val: str, field: str, rid: str, row: int) -> Optional[float]: + """Parse non-negative float.""" + try: + v = float(val.strip()) + if v < 0: + raise ValueError + return v + except (ValueError, AttributeError): + logger.warning(f"Row {row} ({rid}): bad value for {field}='{val}', skipping") + return None + + +def _dt(val: str, field: str, rid: str, row: int) -> Optional[datetime]: + for fmt in _DT_FMTS: + try: + return datetime.strptime(val.strip(), fmt) + except ValueError: + pass + logger.warning(f"Row {row} ({rid}): cannot parse datetime {field}='{val}', skipping") + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# Agents – agent_id, current_x, current_y, rating +# ───────────────────────────────────────────────────────────────────────────── + +def load_agents(path: str) -> list[Agent]: + reader = _open_csv(path, {"agent_id", "current_x", "current_y", "rating"}) + if reader is None: + return [] + agents: list[Agent] = [] + for rn, row in enumerate(reader, 2): + aid = row.get("agent_id", "").strip() + if not aid: + logger.warning(f"Row {rn}: empty agent_id, skipping") + continue + x = _int(row.get("current_x", ""), "current_x", aid, rn) + y = _int(row.get("current_y", ""), "current_y", aid, rn) + r = _float_nn(row.get("rating", ""), "rating", aid, rn) + if x is None or y is None or r is None: + continue + if not (0.0 <= r <= 5.0): + logger.warning(f"Row {rn} ({aid}): rating {r} out of [0,5], skipping") + continue + agents.append(Agent(agent_id=aid, current_location=Coord(x, y), rating=r)) + logger.info(f"Loaded {len(agents)} agents from '{path}'") + return agents + + +# ───────────────────────────────────────────────────────────────────────────── +# Orders – order_id, timestamp, location_x, location_y, +# prep_time_minutes, priority, sla_minutes +# ───────────────────────────────────────────────────────────────────────────── + +def load_orders(path: str) -> list[Order]: + required = {"order_id", "timestamp", "location_x", "location_y", + "prep_time_minutes", "priority", "sla_minutes"} + reader = _open_csv(path, required) + if reader is None: + return [] + orders: list[Order] = [] + valid_prios = {p.value for p in Priority} + for rn, row in enumerate(reader, 2): + oid = row.get("order_id", "").strip() + if not oid: + logger.warning(f"Row {rn}: empty order_id, skipping") + continue + ts = _dt(row.get("timestamp", ""), "timestamp", oid, rn) + lx = _int(row.get("location_x", ""), "location_x", oid, rn) + ly = _int(row.get("location_y", ""), "location_y", oid, rn) + pt = _float_nn(row.get("prep_time_minutes", ""), "prep_time_minutes", oid, rn) + slm = _float_nn(row.get("sla_minutes", ""), "sla_minutes", oid, rn) + prio_str = row.get("priority", "").strip().lower() + if any(v is None for v in [ts, lx, ly, pt, slm]): + continue + if prio_str not in valid_prios: + logger.warning(f"Row {rn} ({oid}): invalid priority '{prio_str}', skipping") + continue + orders.append(Order( + order_id=oid, timestamp=ts, + location=Coord(lx, ly), + prep_time=pt, priority=Priority(prio_str), + sla_minutes=slm, + )) + logger.info(f"Loaded {len(orders)} orders from '{path}'") + return orders + + +# ───────────────────────────────────────────────────────────────────────────── +# Edges – from_x, from_y, to_x, to_y, distance_minutes, delay_multiplier +# ───────────────────────────────────────────────────────────────────────────── + +def load_edges(path: str) -> list[tuple[Coord, Coord, float, float]]: + """Returns list of (from_coord, to_coord, distance_minutes, delay_multiplier).""" + required = {"from_x", "from_y", "to_x", "to_y", "distance_minutes", "delay_multiplier"} + reader = _open_csv(path, required) + if reader is None: + return [] + edges = [] + for rn, row in enumerate(reader, 2): + fx = _int(row.get("from_x", ""), "from_x", f"row{rn}", rn) + fy = _int(row.get("from_y", ""), "from_y", f"row{rn}", rn) + tx = _int(row.get("to_x", ""), "to_x", f"row{rn}", rn) + ty = _int(row.get("to_y", ""), "to_y", f"row{rn}", rn) + dm = _float_nn(row.get("distance_minutes", ""), "distance_minutes", f"row{rn}", rn) + ml = _float_nn(row.get("delay_multiplier", ""), "delay_multiplier", f"row{rn}", rn) + if any(v is None for v in [fx, fy, tx, ty, dm, ml]): + continue + edges.append((Coord(fx, fy), Coord(tx, ty), dm, ml)) + logger.info(f"Loaded {len(edges)} edges from '{path}'") + return edges + + +# ───────────────────────────────────────────────────────────────────────────── +# Constraints – constraint, value +# ───────────────────────────────────────────────────────────────────────────── + +def load_constraints(path: str) -> SystemConfig: + cfg = SystemConfig() + reader = _open_csv(path, {"constraint", "value"}) + if reader is None: + logger.warning("Using default config.") + return cfg + type_map = {k: type(v) for k, v in cfg.__dict__.items()} + for row in reader: + key = row.get("constraint", "").strip() + val = row.get("value", "").strip() + if not key or not val: + continue + if hasattr(cfg, key): + try: + setattr(cfg, key, type_map[key](val)) + except (ValueError, TypeError) as e: + logger.warning(f"Config '{key}': bad value '{val}': {e}") + else: + logger.debug(f"Unknown constraint key '{key}', ignored") + logger.info("Config loaded.") + return cfg diff --git a/dispatch_system/main.py b/dispatch_system/main.py new file mode 100644 index 0000000..016c21a --- /dev/null +++ b/dispatch_system/main.py @@ -0,0 +1,114 @@ +""" +main.py – Smart Delivery Dispatch System +Usage: + python main.py + python main.py --agents data/agents.csv --orders data/orders.csv \ + --edges data/environmental_edges.csv \ + --constraints data/constraints.csv \ + --output results.json +""" +import argparse +import json +import logging +import sys +from pathlib import Path + +from assignment import AssignmentEngine +from graph import Graph +from loaders import load_agents, load_constraints, load_edges, load_orders +from metrics import MetricsCollector +from models import OrderStatus +from simulator import Simulator +from state import AgentRegistry, PriorityOrderQueue + +# ── Logging ─────────────────────────────────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("main") + + +def parse_args(): + p = argparse.ArgumentParser(description="Smart Delivery Dispatch System") + p.add_argument("--agents", default="data/agents.csv") + p.add_argument("--orders", default="data/orders.csv") + p.add_argument("--edges", default="data/environmental_edges.csv") + p.add_argument("--constraints", default="data/constraints.csv") + p.add_argument("--output", default="results.json") + p.add_argument("--verbose", "-v", action="store_true") + return p.parse_args() + + +def main(): + args = parse_args() + if args.verbose: + logging.getLogger().setLevel(logging.DEBUG) + + print("\n" + "=" * 62) + print(" SMART DELIVERY DISPATCH SYSTEM – starting") + print("=" * 62 + "\n") + + # 1. Config + config = load_constraints(args.constraints) + logger.info(f"Config: max_active={config.max_active_orders_per_agent} " + f"latency_target={config.decision_latency_target_seconds}s " + f"default_sla={config.default_sla_minutes}min") + + # 2. Graph + edges = load_edges(args.edges) + if not edges: + logger.error("No edges loaded – cannot compute travel times. Exiting.") + sys.exit(1) + graph = Graph() + graph.build(edges) + + # 3. Agents + agents = load_agents(args.agents) + if not agents: + logger.error("No agents loaded. Exiting.") + sys.exit(1) + registry = AgentRegistry(config) + registry.register_all(agents) + + # 4. Orders + orders = load_orders(args.orders) + if not orders: + logger.error("No orders loaded. Exiting.") + sys.exit(1) + queue = PriorityOrderQueue() + for o in orders: + queue.enqueue(o) + + # 5. Run + metrics = MetricsCollector() + engine = AssignmentEngine(graph, registry, queue, config) + sim = Simulator(engine, registry, queue, metrics, config) + sim.run() + + # 6. Results + assignments = [a.cumulative_assignments for a in registry.all_agents()] + report = metrics.print_summary(assignments, dataset=Path(args.orders).stem) + + # Unresolved orders + still_pending = [o.order_id for o in queue.by_status(OrderStatus.PENDING)] + still_assigned = [o.order_id for o in queue.by_status(OrderStatus.ASSIGNED)] + still_in_transit = [o.order_id for o in queue.by_status(OrderStatus.IN_TRANSIT)] + report["unresolved"] = { + "pending": still_pending, + "assigned": still_assigned, + "in_transit": still_in_transit, + } + if still_pending: + logger.warning(f"{len(still_pending)} orders still PENDING: {still_pending}") + + # Write JSON + out = Path(args.output) + out.write_text(json.dumps(report, indent=2)) + logger.info(f"Results written to {out}") + print(f"Done. Open '{out}' for the full JSON report.\n") + + +if __name__ == "__main__": + main() diff --git a/dispatch_system/metrics.py b/dispatch_system/metrics.py new file mode 100644 index 0000000..6516ba1 --- /dev/null +++ b/dispatch_system/metrics.py @@ -0,0 +1,135 @@ +""" +metrics.py – Online metrics: delivery time, SLA, fairness (Issues 12-15) +""" +import json +import logging +import math +from datetime import datetime +from typing import Optional + +from models import Order, Priority + +logger = logging.getLogger(__name__) + + +class Welford: + """Online mean + variance (Welford's algorithm).""" + def __init__(self): + self.n = 0 + self._mean = 0.0 + self._M2 = 0.0 + + def update(self, x: float): + self.n += 1 + d = x - self._mean + self._mean += d / self.n + self._M2 += d * (x - self._mean) + + @property + def mean(self): return self._mean if self.n else 0.0 + @property + def variance(self): return self._M2 / self.n if self.n else 0.0 + @property + def std(self): return math.sqrt(self.variance) + + def to_dict(self): + return {"n": self.n, "mean": round(self.mean, 3), "std": round(self.std, 3)} + + +class Bucket: + def __init__(self): + self.delivery = Welford() + self.sla_margin = Welford() + self.violations = 0 + + def record(self, dur_min: float, margin_min: float): + self.delivery.update(dur_min) + self.sla_margin.update(margin_min) + if margin_min < 0: + self.violations += 1 + + def to_dict(self): + total = self.delivery.n + rate = self.violations / total if total else 0.0 + return { + "completed": total, + "avg_delivery_time_min": round(self.delivery.mean, 2), + "sla_violations": self.violations, + "sla_violation_rate_pct": round(rate * 100, 2), + "sla_compliance_pct": round((1 - rate) * 100, 2), + "avg_sla_margin_min": round(self.sla_margin.mean, 2), + } + + +class MetricsCollector: + def __init__(self): + self._overall = Bucket() + self._by_prio: dict[Priority, Bucket] = {p: Bucket() for p in Priority} + + def record(self, order: Order): + if order.delivered_at is None: + return + dur = (order.delivered_at - order.timestamp).total_seconds() / 60 + margin = (order.sla_deadline - order.delivered_at).total_seconds() / 60 + if margin < 0: + order.sla_violated = True + self._overall.record(dur, margin) + self._by_prio[order.priority].record(dur, margin) + + def fairness(self, assignments: list[int]) -> dict: + if not assignments: + return {} + n = len(assignments) + mn, mx = min(assignments), max(assignments) + mean = sum(assignments) / n + var = sum((x - mean) ** 2 for x in assignments) / n + return { + "agents": n, + "total_assignments": sum(assignments), + "mean": round(mean, 2), + "min": mn, "max": mx, + "range": mx - mn, + "variance": round(var, 3), + "std_dev": round(math.sqrt(var), 3), + } + + def export(self, assignments: list[int], dataset: str = "run") -> dict: + return { + "metadata": { + "generated_at": datetime.utcnow().isoformat(), + "dataset": dataset, + }, + "overall": self._overall.to_dict(), + "by_priority": {p.value: self._by_prio[p].to_dict() for p in Priority}, + "workload_fairness": self.fairness(assignments), + } + + def print_summary(self, assignments: list[int], dataset: str = "run") -> dict: + data = self.export(assignments, dataset) + ov = data["overall"] + fw = data["workload_fairness"] + sep = "=" * 62 + print(f"\n{sep}") + print(" SMART DELIVERY DISPATCH – RESULTS") + print(sep) + print(f" Orders completed : {ov['completed']}") + print(f" Avg delivery time : {ov['avg_delivery_time_min']} min") + print(f" SLA compliance : {ov['sla_compliance_pct']}%") + print(f" SLA violations : {ov['sla_violations']}") + print() + print(" By Priority:") + for p in Priority: + b = data["by_priority"][p.value] + print(f" [{p.value.upper():6s}] " + f"n={b['completed']:3d} " + f"avg={b['avg_delivery_time_min']:5.1f}min " + f"SLA✓={b['sla_compliance_pct']}%") + if fw: + print() + print(f" Workload Fairness : " + f"mean={fw['mean']} std={fw['std_dev']} range={fw['range']}") + print(sep + "\n") + return data + + def to_json(self, assignments: list[int], dataset: str = "run") -> str: + return json.dumps(self.export(assignments, dataset), indent=2) diff --git a/dispatch_system/models.py b/dispatch_system/models.py new file mode 100644 index 0000000..57e1f35 --- /dev/null +++ b/dispatch_system/models.py @@ -0,0 +1,126 @@ +""" +models.py – Data models for Smart Delivery Dispatch System +""" +from __future__ import annotations +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional + + +# ── Enums ───────────────────────────────────────────────────────────────────── + +class Priority(Enum): + HIGH = "high" + NORMAL = "normal" + LOW = "low" + + def rank(self) -> int: + return {"high": 3, "normal": 2, "low": 1}[self.value] + + def weight(self, cfg: "SystemConfig") -> float: + return { + "high": cfg.priority_weight_high, + "normal": cfg.priority_weight_normal, + "low": cfg.priority_weight_low, + }[self.value] + + +class OrderStatus(Enum): + PENDING = "PENDING" + ASSIGNED = "ASSIGNED" + IN_TRANSIT = "IN_TRANSIT" + DELIVERED = "DELIVERED" + + +# ── Coordinate ──────────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class Coord: + x: int + y: int + + def __str__(self): + return f"({self.x},{self.y})" + + +# ── Domain objects ──────────────────────────────────────────────────────────── + +@dataclass +class Order: + order_id: str + timestamp: datetime + location: Coord + prep_time: float # minutes + priority: Priority + sla_minutes: float # deadline = timestamp + sla_minutes + + # mutable state + status: OrderStatus = OrderStatus.PENDING + assigned_agent_id: Optional[str] = None + assigned_at: Optional[datetime] = None + delivered_at: Optional[datetime] = None + sla_violated: bool = False + + @property + def sla_deadline(self) -> datetime: + from datetime import timedelta + return self.timestamp + timedelta(minutes=self.sla_minutes) + + def delivery_duration_minutes(self) -> Optional[float]: + if self.delivered_at: + return (self.delivered_at - self.timestamp).total_seconds() / 60 + return None + + def sla_margin_minutes(self) -> Optional[float]: + if self.delivered_at: + return (self.sla_deadline - self.delivered_at).total_seconds() / 60 + return None + + +@dataclass +class Agent: + agent_id: str + current_location: Coord + rating: float + active_orders: list[str] = field(default_factory=list) + cumulative_assignments: int = 0 + + def is_available(self, max_active: int) -> bool: + return len(self.active_orders) < max_active + + def add_order(self, order_id: str, max_active: int) -> bool: + if len(self.active_orders) >= max_active: + return False + self.active_orders.append(order_id) + self.cumulative_assignments += 1 + return True + + def remove_order(self, order_id: str, new_location: Coord): + self.active_orders = [o for o in self.active_orders if o != order_id] + self.current_location = new_location + + +@dataclass +class SystemConfig: + max_active_orders_per_agent: int = 2 + decision_latency_target_seconds: float = 5.0 + default_sla_minutes: float = 50.0 + priority_weight_high: float = 1.5 + priority_weight_normal: float = 1.0 + priority_weight_low: float = 0.8 + # scoring weights (internal defaults, not in CSV) + w_delivery_time: float = 0.30 + w_sla_risk: float = 0.30 + w_fairness: float = 0.20 + w_priority: float = 0.10 + w_rating: float = 0.10 + + +@dataclass +class Candidate: + agent: Agent + order: Order + travel_time: float # minutes (with delay_multiplier applied) + estimated_total: float # travel + prep + score: float = 0.0 diff --git a/dispatch_system/simulator.py b/dispatch_system/simulator.py new file mode 100644 index 0000000..61cdc2d --- /dev/null +++ b/dispatch_system/simulator.py @@ -0,0 +1,353 @@ +""" +simulator.py – Smart event-driven dispatch simulator +""" + +import heapq +import logging +from datetime import datetime, timedelta +from typing import List, Tuple + +from assignment import AssignmentEngine +from metrics import MetricsCollector +from models import ( + Candidate, + Order, + OrderStatus, + SystemConfig +) +from state import ( + AgentRegistry, + PriorityOrderQueue +) + +logger = logging.getLogger(__name__) + + +class Simulator: + + def __init__( + self, + engine: AssignmentEngine, + registry: AgentRegistry, + queue: PriorityOrderQueue, + metrics: MetricsCollector, + config: SystemConfig + ): + + self.engine = engine + self.registry = registry + self.queue = queue + self.metrics = metrics + self.cfg = config + + self._orders_processed = 0 + + # ( + # delivery_time, + # order_id, + # order + # ) + self.delivery_events: List[ + Tuple[datetime, str, Order] + ] = [] + + # ───────────────────────────────────────────────────────── + # Apply assignment + # ───────────────────────────────────────────────────────── + + def _apply( + self, + cand: Candidate, + now: datetime + ) -> bool: + + order = cand.order + agent = cand.agent + + if order.status != OrderStatus.PENDING: + return False + + if not self.registry.assign( + agent.agent_id, + order.order_id + ): + return False + + self.queue.transition( + order.order_id, + OrderStatus.ASSIGNED + ) + + order.assigned_agent_id = ( + agent.agent_id + ) + + order.assigned_at = now + + logger.info( + f" ASSIGN {order.order_id} " + f"[{order.priority.value:6s}] " + f"→ {agent.agent_id} " + f"ETA {cand.estimated_total:.1f}min" + ) + + return True + + # ───────────────────────────────────────────────────────── + # Schedule delivery + # ───────────────────────────────────────────────────────── + + def _schedule_delivery( + self, + order: Order, + delivery_time: datetime + ): + + heapq.heappush( + self.delivery_events, + ( + delivery_time, + order.order_id, + order + ) + ) + + # ───────────────────────────────────────────────────────── + # Complete delivery + # ───────────────────────────────────────────────────────── + + def _deliver( + self, + order: Order, + now: datetime + ): + + aid = order.assigned_agent_id + + self.queue.transition( + order.order_id, + OrderStatus.IN_TRANSIT + ) + + self.queue.transition( + order.order_id, + OrderStatus.DELIVERED, + now + ) + + self.registry.complete( + aid, + order.order_id, + order.location + ) + + self.metrics.record(order) + + self._orders_processed += 1 + + status = ( + "✓" + if not order.sla_violated + else "✗ SLA VIOLATED" + ) + + logger.info( + f" DELIVER {order.order_id} " + f"at {now.strftime('%H:%M:%S')} " + f"{status}" + ) + + # ───────────────────────────────────────────────────────── + # Attempt assignment + # ───────────────────────────────────────────────────────── + + def _try_assign_order( + self, + order: Order, + now: datetime + ) -> bool: + + wait = ( + now - order.timestamp + ).total_seconds() / 60 + + # Log queue wait only once + if ( + wait > 10 and + not hasattr(order, "_queue_warned") + ): + + logger.warning( + f" QUEUE {order.order_id} " + f"waiting {wait:.1f}min" + ) + + order._queue_warned = True + + cand = self.engine.decide( + order, + now + ) + + if not cand: + return False + + # remove pending + self.queue.pop_next_pending() + + if not self._apply(cand, now): + return False + + delivery_time = ( + now + + timedelta( + minutes=cand.estimated_total + ) + ) + + self._schedule_delivery( + order, + delivery_time + ) + + return True + + # ───────────────────────────────────────────────────────── + # Assign ALL eligible pending orders + # ───────────────────────────────────────────────────────── + + def _assign_pending_orders( + self, + now: datetime + ): + + pending = self.queue.pending_orders() + + if not pending: + return + + for order in pending: + + # future order + if order.timestamp > now: + continue + + # already assigned + if order.status != OrderStatus.PENDING: + continue + + self._try_assign_order( + order, + now + ) + + # ───────────────────────────────────────────────────────── + # Process delivery events + # ───────────────────────────────────────────────────────── + + def _process_delivery_events( + self, + current_time: datetime + ): + + while ( + + self.delivery_events and + + self.delivery_events[0][0] + <= current_time + ): + + delivery_time, _, order = ( + heapq.heappop( + self.delivery_events + ) + ) + + self._deliver( + order, + delivery_time + ) + + # ───────────────────────────────────────────────────────── + # Main simulation loop + # ───────────────────────────────────────────────────────── + + def run(self): + + pending_orders = sorted( + + self.queue.by_status( + OrderStatus.PENDING + ), + + key=lambda o: o.timestamp + ) + + if not pending_orders: + + logger.warning( + "No orders to process." + ) + + return + + logger.info( + f"Starting simulation: " + f"{len(pending_orders)} orders, " + f"{len(self.registry.all_agents())} agents\n" + ) + + # chronological arrivals + for order in pending_orders: + + now = order.timestamp + + # complete deliveries due + self._process_delivery_events( + now + ) + + # assign all possible + self._assign_pending_orders( + now + ) + + # process remaining deliveries + while self.delivery_events: + + delivery_time, _, order = ( + heapq.heappop( + self.delivery_events + ) + ) + + self._deliver( + order, + delivery_time + ) + + # freed agents take more + self._assign_pending_orders( + delivery_time + ) + + remaining = len( + + self.queue.by_status( + OrderStatus.PENDING + ) + ) + + if remaining > 0: + + logger.warning( + f"{remaining} orders " + f"remain unassigned." + ) + + logger.info( + f"\nSimulation complete. " + f"Processed " + f"{self._orders_processed} orders." + ) \ No newline at end of file diff --git a/dispatch_system/state.py b/dispatch_system/state.py new file mode 100644 index 0000000..9059a6c --- /dev/null +++ b/dispatch_system/state.py @@ -0,0 +1,410 @@ +""" +state.py – PriorityOrderQueue + AgentRegistry +""" + +import heapq +import logging +from datetime import datetime +from typing import Optional + +from models import ( + Agent, + Coord, + Order, + OrderStatus, + SystemConfig +) + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────── +# Priority Order Queue +# ───────────────────────────────────────────────────────────── + +class PriorityOrderQueue: + + """ + Heap ordering: + + ( + -priority_rank, + timestamp, + insertion_seq, + order_id + ) + + Higher priority first, + older orders first. + """ + + def __init__(self): + + self._heap = [] + + self._orders: dict[str, Order] = {} + + self._by_status = { + s: set() + for s in OrderStatus + } + + self._seq = 0 + + # ───────────────────────────────────────────────────────── + # Add order + # ───────────────────────────────────────────────────────── + + def enqueue( + self, + order: Order + ): + + if order.order_id in self._orders: + return + + self._orders[order.order_id] = order + + self._by_status[ + order.status + ].add(order.order_id) + + self._push(order) + + # ───────────────────────────────────────────────────────── + # Push into heap + # ───────────────────────────────────────────────────────── + + def _push( + self, + order: Order + ): + + self._seq += 1 + + heapq.heappush( + self._heap, + ( + -order.priority.rank(), + order.timestamp, + self._seq, + order.order_id + ) + ) + + # ───────────────────────────────────────────────────────── + # Pop next valid pending + # ───────────────────────────────────────────────────────── + + def pop_next_pending( + self + ) -> Optional[Order]: + + while self._heap: + + _, _, _, oid = heapq.heappop( + self._heap + ) + + order = self._orders.get(oid) + + if ( + order and + order.status == OrderStatus.PENDING + ): + + return order + + return None + + # ───────────────────────────────────────────────────────── + # Peek next valid pending + # ───────────────────────────────────────────────────────── + + def peek_next_pending( + self + ) -> Optional[Order]: + + while self._heap: + + _, _, _, oid = self._heap[0] + + order = self._orders.get(oid) + + if ( + order and + order.status == OrderStatus.PENDING + ): + + return order + + # Lazy cleanup + heapq.heappop(self._heap) + + return None + + # ───────────────────────────────────────────────────────── + # Return multiple pending orders + # ───────────────────────────────────────────────────────── + + def pending_orders( + self, + limit: Optional[int] = None + ) -> list[Order]: + + pending = sorted( + self.by_status( + OrderStatus.PENDING + ), + key=lambda o: ( + -o.priority.rank(), + o.timestamp + ) + ) + + if limit: + return pending[:limit] + + return pending + + # ───────────────────────────────────────────────────────── + # Transition status + # ───────────────────────────────────────────────────────── + + def transition( + self, + order_id: str, + new_status: OrderStatus, + ts: Optional[datetime] = None + ) -> bool: + + valid = { + + OrderStatus.PENDING: + OrderStatus.ASSIGNED, + + OrderStatus.ASSIGNED: + OrderStatus.IN_TRANSIT, + + OrderStatus.IN_TRANSIT: + OrderStatus.DELIVERED, + } + + order = self._orders.get(order_id) + + if order is None: + + logger.error( + f"Unknown order {order_id}" + ) + + return False + + expected = valid.get(order.status) + + if expected != new_status: + + logger.error( + f"Bad transition " + f"{order.order_id}: " + f"{order.status.value} " + f"→ {new_status.value}" + ) + + return False + + self._by_status[ + order.status + ].discard(order_id) + + order.status = new_status + + self._by_status[ + new_status + ].add(order_id) + + if ( + new_status == + OrderStatus.DELIVERED + and ts + ): + + order.delivered_at = ts + + return True + + # ───────────────────────────────────────────────────────── + # Accessors + # ───────────────────────────────────────────────────────── + + def get( + self, + order_id: str + ) -> Optional[Order]: + + return self._orders.get(order_id) + + def by_status( + self, + status: OrderStatus + ) -> list[Order]: + + return [ + self._orders[oid] + for oid in self._by_status[status] + ] + + def all_orders( + self + ) -> list[Order]: + + return list( + self._orders.values() + ) + + +# ───────────────────────────────────────────────────────────── +# Agent Registry +# ───────────────────────────────────────────────────────────── + +class AgentRegistry: + + def __init__( + self, + config: SystemConfig + ): + + self.cfg = config + + self._agents: dict[ + str, + Agent + ] = {} + + self._available: set[str] = set() + + # ───────────────────────────────────────────────────────── + # Register agents + # ───────────────────────────────────────────────────────── + + def register_all( + self, + agents: list[Agent] + ): + + for agent in agents: + + self._agents[ + agent.agent_id + ] = agent + + self._refresh(agent) + + # ───────────────────────────────────────────────────────── + # Available agents + # ───────────────────────────────────────────────────────── + + def available_agents( + self + ) -> list[Agent]: + + return [ + self._agents[aid] + for aid in self._available + ] + + # ───────────────────────────────────────────────────────── + # Accessors + # ───────────────────────────────────────────────────────── + + def all_agents( + self + ) -> list[Agent]: + + return list( + self._agents.values() + ) + + def get( + self, + agent_id: str + ) -> Optional[Agent]: + + return self._agents.get(agent_id) + + # ───────────────────────────────────────────────────────── + # Assign order + # ───────────────────────────────────────────────────────── + + def assign( + self, + agent_id: str, + order_id: str + ) -> bool: + + agent = self._agents.get( + agent_id + ) + + if agent is None: + return False + + ok = agent.add_order( + order_id, + self.cfg.max_active_orders_per_agent + ) + + if ok: + + agent.cumulative_assignments += 1 + + self._refresh(agent) + + return ok + + # ───────────────────────────────────────────────────────── + # Complete order + # ───────────────────────────────────────────────────────── + + def complete( + self, + agent_id: str, + order_id: str, + new_loc: Coord + ): + + agent = self._agents.get( + agent_id + ) + + if agent: + + agent.remove_order( + order_id, + new_loc + ) + + self._refresh(agent) + + # ───────────────────────────────────────────────────────── + # Refresh availability + # ───────────────────────────────────────────────────────── + + def _refresh( + self, + agent: Agent + ): + + if agent.is_available( + self.cfg.max_active_orders_per_agent + ): + + self._available.add( + agent.agent_id + ) + + else: + + self._available.discard( + agent.agent_id + ) \ No newline at end of file diff --git a/ps2-byteforge-with-dashboard.zip b/ps2-byteforge-with-dashboard.zip new file mode 100644 index 0000000..af86fe7 Binary files /dev/null and b/ps2-byteforge-with-dashboard.zip differ